# Crystal Lattice Docs > Plugin-driven AI agent platform documentation Crystal Lattice is a plugin-driven AI agent platform with a Python SDK, terminal and HTTP runtime, mobile and desktop clients, provider integrations, and first-party plugin packages. # Overview # Crystal Lattice Docs Crystal Lattice is a plugin-driven AI agent platform for running local-first, provider-agnostic agent workflows across your computer, phone, and custom automation. The platform includes a Python SDK, a terminal and HTTP runtime, mobile and desktop clients, provider integrations, and a first-party plugin system for tools, features, provider behavior, application actions, and model-family harnesses. ## Start Here - [Application configuration](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugins/application-config/index.md): configure agents, providers, plugins, tools, placeholders, and runtime policy. - [Current plugin docs](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/index.md): build provider, feature, application, and tool plugins using the current plugin documentation set. - [Built-in plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/index.md): browse generated reference pages for bundled plugin packages. - [Default configuration](https://docs.cl-static-test.dynamicprogrammingsolutions.com/reference/default-config/index.md): inspect the generated default application configuration. - [Event stream types](https://docs.cl-static-test.dynamicprogrammingsolutions.com/api/events/index.md): understand application events exposed by HTTP polling and bridge streaming. ## Developer Reference - [AgentCore API](https://docs.cl-static-test.dynamicprogrammingsolutions.com/reference/agent_core/core/index.md): core session, message, request, streaming, and tool orchestration APIs. - [Core SDK guide](https://docs.cl-static-test.dynamicprogrammingsolutions.com/api/core-sdk/index.md): hand-written overview of the functional SDK surface. - [Types and protocols](https://docs.cl-static-test.dynamicprogrammingsolutions.com/reference/agent_core/types/index.md): immutable core types and plugin protocols. - [Provider wrapper](https://docs.cl-static-test.dynamicprogrammingsolutions.com/reference/agent_core/plugin/provider/index.md): provider adapter API reference. - [OpenAI-compatible provider](https://docs.cl-static-test.dynamicprogrammingsolutions.com/reference/providers/openai_provider/index.md): reference provider implementation details. - [Tool host protocol](https://docs.cl-static-test.dynamicprogrammingsolutions.com/reference/tool-host-protocol/index.md): protocol reference for external tool hosts. ## Project Links - Use the **Website Home** menu item to return to the Crystal Lattice website. - [GitHub repository](https://github.com/dynamicprogrammingsolutions/crystal-lattice) # Guides # Plugin Development This page is an older/general overview. For the current shared plugin docs, start at `plugins/docs/index.md`. This guide explains how to build provider, provider extension, feature, and tool plugins for Crystal Lattice. It includes minimal examples and links to the full API reference generated from docstrings. - Core principles - Functional style: methods are pure; state is passed explicitly. - Immutability: core data structures (Message, Session) are frozen. - Shared provider state: provider, provider extensions, and features receive and may update the same per-request state dict (tools keep their own state). See also - Reference → [Types & Protocols](https://docs.cl-static-test.dynamicprogrammingsolutions.com/reference/agent_core/types/index.md) - Reference → [Provider Wrapper](https://docs.cl-static-test.dynamicprogrammingsolutions.com/reference/agent_core/plugin/provider/index.md) - Reference → [AgentCore](https://docs.cl-static-test.dynamicprogrammingsolutions.com/reference/agent_core/core/index.md) - Reference → [OpenAI-Compatible Provider](https://docs.cl-static-test.dynamicprogrammingsolutions.com/reference/providers/openai_provider/index.md) Provider plugins (canonical guide): `plugins/docs/provider-plugins.md`. Tool plugins (canonical guide): `plugins/docs/tool-plugins.md`. Node tool plugins (canonical guide): `plugins/docs/node-tool-plugins.md`. Bash tool plugins (canonical guide): `plugins/docs/bash-tool-plugins.md`. Application plugins (current guide): `plugins/docs/application-plugins.md`. ## Provider Plugin Providers adapt LLM APIs and convert between core and provider-native messages. They can stream (hot path) and finalize (cold path). Key methods (subset): - `init(config) -> state` (required) - `call_api(native_messages, state) -> (partials, finals, natives, state)` (required, or `stream_api` for streaming providers) - `process_chunk(native_chunk, native_messages, state) -> (partials, finals, natives, state)` (required) - `finalize(native_messages, state) -> (final, native, state)` (required) - `to_native_messages(messages, state) -> list` (optional in Python; adapter defaults to identity mapping of `role` and `content`) - `from_native_messages(native_messages, state) -> list` (optional in Python; adapter maps to core messages with empty `metadata`) - `initialize_request(native_messages, state) -> (native_messages, state)` (optional in Python; adapter returns inputs unchanged) - `stream_api(native_messages, state) -> Iterator[chunk]` (optional but recommended for streaming providers) Default execution order (per request): - Tool schemas: each tool runs `init`, then `get_tool_schemas`; schemas are injected into provider config under `tools`. - Init chain (shared provider state): provider `init`, then extension `init` (in order), then feature `init`. - Stateless message transforms: - Core → native: provider `to_native_messages`, then extension `to_native_messages`, then feature `to_native_messages`. - Native → core: provider `from_native_messages`, then extension `from_native_messages`, then feature `from_native_messages`. - Stateful request setup: provider `initialize_request`, then extension `initialize_request` (in order), then feature `initialize_request`. - Streaming hot path: provider `process_chunk`, then extension `process_chunk` (in order); features are not invoked per-chunk. - Cold-path finalization: provider `finalize`, then extension `finalize` (in order), then feature `finalize`. Minimal example (echo): ``` from typing import Any, Dict, List, Tuple from agent_core.types import ProviderPlugin class EchoProvider(ProviderPlugin): name = "echo" version = "1.0.0" def get_config_schema(self) -> Dict[str, Any]: return {"model": {"type": "string", "default": "echo-1"}} def init(self, config: Dict[str, Any]) -> Dict[str, Any]: # Provider state is an opaque dict; this minimal example only # stores configuration. return {"config": config} def call_api( self, native_messages: List[Dict[str, Any]], state: Dict[str, Any], ) -> Tuple[ List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any], ]: last_user = next( (m for m in reversed(native_messages) if m.get("role") == "user"), {"content": ""}, ) content = f"Echo: {last_user.get('content', '')}" final = {"role": "assistant", "content": content} # Return (partial_messages, final_messages, native_messages, new_state) return [], [final], [*native_messages, final], state ``` In the Python SDK, adapter defaults handle `to_native_messages`, `from_native_messages`, `initialize_request`, `finalize`, and `stream_api` when they are omitted, so many providers only implement configuration, `init`, and one of the I/O entry points (`call_api` or `stream_api`). ### Recommended streaming pattern (accumulator-based) Streaming providers are expected to follow a simple accumulator-based pattern for `process_chunk` and `finalize`: - `stream_api` yields raw provider-native chunks (as returned by the HTTP client). - `process_chunk(native_chunk, native_messages, state)` is called for each chunk and must: - Extract a *delta* from the chunk (for example, the first entry in `choices[0]`). - Reduce that delta into: - A provider-native **partial** message for this chunk (or `None`). - An updated **accumulator** stored in `state["partial"]`. - Return `(partials, finals, native_messages, new_state)`, where: - `partials` is a list of provider-native partial messages (often at most one item per chunk). - `finals` is usually empty during streaming; the final message is produced in `finalize`. - `native_messages` is typically left unchanged during streaming; the full history is updated when finalization completes. - `new_state` is `state` with the updated accumulator under `state["partial"]`. - `finalize(native_messages, state)` is called once after streaming completes and must: - Read the accumulated partial from `state["partial"]`. - If present, emit it as the single final provider-native assistant message. - Append that final to `native_messages` when returning. A minimal accumulator-based `process_chunk`/`finalize` pair looks like this (simplified from the OpenAI-compatible provider): ``` class MyProvider(ProviderPlugin): ... def extract_delta(self, native_chunk: Dict[str, Any]) -> Dict[str, Any]: if "choices" in native_chunk and native_chunk["choices"]: return native_chunk["choices"][0] return {} def process_delta(self, delta: Dict[str, Any], accumulated: Dict[str, Any] | None): base = accumulated or {} content = delta.get("message") or delta.get("delta") or None if content is None: return None, base new_accumulated = { **base, "role": content.get("role", base.get("role", "assistant")), "content": (base.get("content", "") + content.get("content", "")), } return content, new_accumulated def process_chunk( self, native_chunk: Dict[str, Any], native_messages: List[Dict[str, Any]], state: Dict[str, Any], ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]: partial, accumulated = self.process_delta( self.extract_delta(native_chunk), state.get("partial", {}), ) partials = [partial] if partial else [] finals: List[Dict[str, Any]] = [] return partials, finals, native_messages, {**state, "partial": accumulated} def finalize( self, native_messages: List[Dict[str, Any]], state: Dict[str, Any], ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]: partial = state.get("partial") if partial: return [partial], [*native_messages, partial], state return [], native_messages, state ``` This pattern keeps all accumulation in `state["partial"]` and makes it easy for extensions to collaborate by reading and updating the same accumulator. ## Provider Extension Plugin Provider extensions run beside the provider on the hot path and the cold path. They do not own separate state; they receive and update the shared provider state. Key methods (subset): - `init(config, state) -> state` (required) - `process_chunk(native_chunk|None, partial_messages, final_messages, native_messages, state) -> (partials, finals, natives, state)` (required) - `finalize(final_messages, native_messages, state) -> (finals, native, state)` (required) - `to_native_messages(messages, native_messages) -> list` (optional in Python; adapter defaults to identity) - `from_native_messages(native_messages, messages) -> list` (optional in Python; adapter defaults to identity) - `initialize_request(native_messages, state) -> (native_messages, state)` (optional in Python; adapter returns inputs unchanged) Minimal example (prefix finals): ``` from typing import Any, Dict, List, Tuple, Optional from agent_core.types import ProviderExtensionPlugin class PrefixExtension(ProviderExtensionPlugin): name = "prefix" version = "1.0.0" def get_config_schema(self) -> Dict[str, Any]: return {"prefix": {"type": "string", "default": "[EXT]"}} def init(self, config: Dict[str, Any], state: Dict[str, Any]) -> Dict[str, Any]: return {**state, "config": {**state.get("config", {}), **config}} def process_chunk( self, native_chunk: Optional[Dict[str, Any]], partial_messages: List[Dict[str, Any]], final_messages: List[Dict[str, Any]], native_messages: List[Dict[str, Any]], state: Dict[str, Any], ) -> Tuple[ List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any], ]: pfx = state.get("config", {}).get("prefix", "[EXT]") finals = [{**m, "content": f"{pfx} {m['content']}"} for m in final_messages] return partial_messages, finals, native_messages, state ``` As with providers, extension adapters supply default identity implementations for the stateless conversion and `initialize_request` hooks when they are not defined on the class. ### Recommended streaming pattern (extensions) For streaming providers that use an accumulator in `state["partial"]`, extensions are encouraged to follow a similar pattern: - Treat `partial_messages` as provider-native partials for the current chunk. - Use `state["partial"]` as the shared accumulator for extension-specific fields (for example, `tool_calls`, `reasoning`, or provider-native metadata). - Prefer updating only: - The last entry in `partial_messages` (to enrich the current partial), and - The accumulator in `state["partial"]`. - Avoid mutating `final_messages` or `native_messages` during streaming unless there is a very strong reason; use `finalize` for cold-path adjustments. A minimal accumulator-based extension looks like this: ``` from typing import Any, Dict, List, Tuple, Optional from agent_core.types import ProviderExtensionPlugin class MyStreamingExtension(ProviderExtensionPlugin): name = "my_ext" version = "1.0.0" def init(self, config: Dict[str, Any], state: Dict[str, Any]) -> Dict[str, Any]: return state def extract_delta(self, native_chunk: Dict[str, Any]) -> Dict[str, Any]: if "choices" in native_chunk and native_chunk["choices"]: return native_chunk["choices"][0] return {} def process_delta( self, delta: Dict[str, Any], current_partial: Dict[str, Any] | None, accumulated: Dict[str, Any] | None, ) -> Tuple[Dict[str, Any] | None, Dict[str, Any]]: base = accumulated or {} d = delta.get("message") or delta.get("delta") or {} if not isinstance(d, dict): return current_partial, base # Example: accumulate a "reasoning" field new_partial = dict(current_partial or {}) if "reasoning" in d: new_partial["reasoning"] = (new_partial.get("reasoning", "") + d["reasoning"]) base["reasoning"] = base.get("reasoning", "") + d["reasoning"] return new_partial, base def process_chunk( self, native_chunk: Optional[Dict[str, Any]], partial_messages: List[Dict[str, Any]], final_messages: List[Dict[str, Any]], native_messages: List[Dict[str, Any]], state: Dict[str, Any], ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]: delta = self.extract_delta(native_chunk or {}) accumulated = state.get("partial", {}) new_partials = partial_messages if new_partials: last = new_partials[-1] updated_last, accumulated = self.process_delta(delta, last, accumulated) new_partials[-1] = updated_last or last return new_partials, final_messages, native_messages, {**state, "partial": accumulated} def finalize( self, final_messages: List[Dict[str, Any]], native_messages: List[Dict[str, Any]], state: Dict[str, Any], ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]: # Most extensions can simply return finals/native unchanged; the provider # already baked accumulator state into the final provider-native message. return final_messages, native_messages, state ``` This keeps streaming logic simple and predictable: the provider owns the final message shape, and extensions collaborate by enriching partials and the shared accumulator. ## Feature Plugin Features operate on the cold path only. They see the shared provider state and can transform provider-native or core messages during finalize. Key methods (subset): - `init(config, state) -> state` (required) - `finalize(final_messages, native_messages, state) -> (finals, native, state)` (required) - `to_native_messages(messages, native_messages) -> list` (optional in Python; adapter defaults to identity) - `from_native_messages(native_messages, messages) -> list` (optional in Python; adapter defaults to identity) - `initialize_request(native_messages, state) -> (native_messages, state)` (optional in Python; adapter returns inputs unchanged) - `get_template(config) -> str` (optional; returns a feature-specific template string for UIs) - `get_completions(config, text) -> list[dict]` (optional; returns completion suggestions for interactive apps) - `apply_completion(config, text, completion) -> str` (optional; turns an accepted completion into a final snippet) Minimal example (add tag): ``` from typing import Any, Dict, List, Tuple from agent_core.types import FeaturePlugin class TagFeature(FeaturePlugin): name = "tag" version = "1.0.0" def get_config_schema(self) -> Dict[str, Any]: return {"enabled": {"type": "boolean", "default": True}} def init(self, config: Dict[str, Any], state: Dict[str, Any]) -> Dict[str, Any]: return state def finalize( self, final_messages: List[Dict[str, Any]], native_messages: List[Dict[str, Any]], state: Dict[str, Any], ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]: finals = [ {**m, "metadata": {**m.get("metadata", {}), "tag": True}} for m in final_messages ] return finals, native_messages, state ``` Feature adapters mirror extension adapters: if a feature omits the stateless conversion and `initialize_request` hooks, the Python SDK supplies identity/default behaviors. ### Feature templates and completions Feature plugins can participate in interactive editing experiences by implementing `get_template`, `get_completions`, and `apply_completion`: - `get_template(config) -> str` - Returns a feature-specific template string for applications that need to render snippets (for example, when expanding a file or web-context marker). - The template is an arbitrary string; many features use Python `str.format`-style placeholders such as `{path}`, `{range}`, or `{lang}` so they can be combined with completion metadata. - May safely return an empty string when the feature does not use templates. - `get_completions(config, text) -> list[dict]` - Called by applications (via `AgentCore.get_completions`) with the full input `text` before the cursor. - Returns a list of feature-defined dictionaries describing available completions. A common minimal shape is: `` python { "replacement": "text to insert", "start": 5, # 0-based index in `text` where replacement begins "display": "label", # label shown in the UI "display_meta": "info", # optional extra description } `` - Features are free to attach additional keys that are useful when applying the completion later (for example, `"url"`, `"path"`, or parsed range information). These extra fields are passed through unchanged to `apply_completion`. - Should return an empty list when no completions apply for the current `text`/`config`. - `apply_completion(config, text, completion) -> str` - Optional hook invoked when a user accepts a completion that was previously returned by `get_completions`. - `text` is the full buffer *after* the completion's `"replacement"` has been inserted by the UI. - `completion` is the original descriptor dict from `get_completions` (including any custom keys you attached). - Returns a string that applications can insert into the buffer in place of the just-applied completion text (for example, expanding a marker into a multi-line snippet). Returning an empty string (or other falsey value) indicates that the feature does not wish to override the completion. Minimal example (marker completion): ``` from typing import Any, Dict, List from agent_core.types import FeaturePlugin class MarkerFeature(FeaturePlugin): name = "marker" version = "1.0.0" def get_config_schema(self) -> Dict[str, Any]: return {} def init(self, config: Dict[str, Any], state: Dict[str, Any]) -> Dict[str, Any]: return state def finalize(self, final_messages, native_messages, state): return final_messages, native_messages, state def get_completions(self, config: Dict[str, Any], text: str) -> List[Dict[str, Any]]: if not text.endswith("@tag:"): return [] # Offer a fixed tag; UIs use "replacement"/"start" to # update the buffer, and pass this dict back to # apply_completion when accepted. return [ { "replacement": "@tag:important", "start": len(text) - len("@tag:"), "display": "@tag:important", "display_meta": "Insert 'important' tag", "tag": "important", } ] def apply_completion( self, config: Dict[str, Any], text: str, completion: Dict[str, Any], ) -> str: tag = completion.get("tag", "") if not tag: return "" # Replace the marker with a rendered snippet. return f"[tagged:{tag}] " ``` Applications such as the terminal UI typically: - Call `AgentCore.get_completions(config, text)` to gather candidate completions from all registered features. - When a completion is accepted, insert its `"replacement"` into the buffer and then call `AgentCore.apply_feature_completion(config, text, completion)`; the first non-empty string returned by a feature's `apply_completion` is used as the final snippet. ### UI elements and `ui_type` All plugin kinds (providers, extensions, features, and tools) can optionally expose UI metadata via `get_ui_elements(config, tags, models) -> list[dict]`. These dictionaries are flattened by `AgentCore.get_ui_schema(config)` and left to applications (for example, the terminal client) to render. The core passes additional context to `get_ui_elements`: - `config`: effective configuration for the current agent/request - `tags`: capability tags computed for this config (provider + enabled plugins) - `models`: model descriptors computed for this config For backward compatibility, plugin adapters also accept legacy implementations that define `get_ui_elements()` with no arguments. Common patterns: - **Configuration elements** (default `ui_type == "config"`): `python { "type": "checkbox" | "text" | "number" | "select" | ..., "key": "config_key", # stable configuration key "label": "Human label", # short caption "description": "Help text", # optional "options": [ # for select-like fields "value" | {"value": "v", "label": "Display"}, ], }` When `ui_type` is omitted or falsy, the core normalizes the element to `"config"` and deduplicates configuration entries by `key` when combining schemas from multiple plugins. - **Per-message footers** (`ui_type == "message_footer"`): `python { "ui_type": "message_footer", "data": "metadata.timestamp", # dotted JSON path into messages "template": "{{data}}", # optional; simple placeholder }` Applications such as the terminal client use these to render compact gray footers under each message (for example, timestamps or cached token counts). - **Status bar fields** (`ui_type == "status_bar"`): `python { "ui_type": "status_bar", "data": "metadata.total_cost", "template": "Total: {{data}}", }` These are typically derived from the last assistant message in a session and are rendered in a persistent status bar. Plugins are free to attach additional keys suited to their needs; the core treats these element dictionaries as immutable and opaque when flattening the UI schema. ## Tool Plugin Tools expose callable functions to the model and manage their own tool state. Key methods (subset): - `init(config) -> state` - `get_tool_schemas(state) -> list` - `get_tool_interop_contribution(state) -> ToolInteropContribution` (optional) - `can_handle_tool_call(tool_name, payload, state, ...) -> bool | None` (optional) - `execute_tool(tool_name, payload, state, ...) -> dict` - `format_tool_result(result, state) -> str | provider-native envelope` - `format_tool_call_preview(tool_name, payload, state, ...) -> str` (optional) - `stream_tool(tool_name, payload, state, ...) -> Iterator[dict]` (optional) - `to_display_format(text, result, state) -> dict` (optional) Request-time ordering note: - In the default `AgentCore` request flow, tools run `init`/`get_tool_schemas` before the provider initializes, so the provider sees the current tool schemas in its `config`. Interop note: - Tools are no longer limited to OpenAI chat-completions function schemas. - `get_tool_schemas(...)` may return any schema shape understood by the active tool interop accessors/adapters. - At execution time the tool receives the final payload object directly, plus optional semantic metadata such as `payload_kind` and `payload_format`. - Legacy dict-argument tools remain supported through the Python wrapper compatibility layer. - `format_tool_result(...)` should usually return a string. Provider-specific multimodal tools may return an explicit provider-native envelope with `type == "provider_native_tool_result"`; ordinary dict/list values are compatibility-stringified by the default adapter. Minimal example (calculator): ``` from typing import Any, Dict, List from agent_core.types import ToolPlugin class Calculator(ToolPlugin): name = "calculator" version = "1.0.0" def get_config_schema(self) -> Dict[str, Any]: return {} def get_ui_elements(self, config: Dict[str, Any], tags: List[str], models: List[Dict[str, Any]]) -> List[Dict[str, Any]]: return [] def init(self, config: Dict[str, Any]) -> Dict[str, Any]: return {"config": config} def get_tool_schemas(self, state: Dict[str, Any]) -> List[Dict[str, Any]]: return [{"type": "function", "function": {"name": "add", "parameters": {"type": "object", "properties": {"a": {"type": "number"}, "b": {"type": "number"}}, "required": ["a", "b"]}}}] def execute_tool( self, tool_name: str | None, payload: Any, state: Dict[str, Any], *, payload_kind: str | None = None, payload_format: str | None = None, payload_metadata: Dict[str, Any] | None = None, tool_call: Dict[str, Any] | None = None, ) -> Dict[str, Any]: arguments = payload if isinstance(payload, dict) else {} if tool_name == "add": return {"success": True, "result": arguments.get("a", 0) + arguments.get("b", 0)} return {"success": False, "error": f"Unknown tool: {tool_name}"} def format_tool_result(self, result: Dict[str, Any], state: Dict[str, Any]) -> str: return f"Result: {result['result']}" if result.get("success") else f"Error: {result.get('error')}" # Optional: provide streaming and display hooks # # def stream_tool( # self, # tool_name: str | None, # payload: Any, # state: Dict[str, Any], # *, # payload_kind: str | None = None, # payload_format: str | None = None, # payload_metadata: Dict[str, Any] | None = None, # tool_call: Dict[str, Any] | None = None, # ) -> Iterator[Dict[str, Any]]: # """Yield partial payloads plus a final result dict. # # When omitted, the default adapter wraps ``execute_tool`` and # yields a single RESULT dict with no partials. Implement this # when your tool produces long-running or incremental output. # Each yielded dict with a ``"part"`` key is treated as a # display-only partial; a dict with ``"success"`` is treated as # the final result. # """ # yield {"part": {"type": "text", "content": "working..."}} # yield {"success": True, "result": 42} # def to_display_format( # self, # text: str, # result: Dict[str, Any], # state: Dict[str, Any], # ) -> Dict[str, Any]: # """Convert the tool result into a UI display payload. # # ``text`` is the string from ``format_tool_result``. If # ``format_tool_result`` returned a provider-native envelope, core # passes an empty string here. ``result`` is the raw result dict from # ``execute_tool`` / ``stream_tool``. # The adapter attaches this payload under # ``metadata.display`` on the resulting ``tool`` message so UIs # can show richer output (for example, the executed shell # command, working directory, or full patch text) without # changing what the model sees in ``content``. # # Optionally include a ``single_line`` field for compact/collapsed views: # ``{"type": "text", "content": , "single_line": }`` # """ # return {"type": "text", "content": text} ``` ### Out-of-process tool plugins Tools can also be authored out-of-process: - Node.js / TypeScript tools via the Node host - shell-script-backed tools via the Bash host Canonical guides: - `plugins/docs/node-tool-plugins.md` - `plugins/docs/bash-tool-plugins.md` Important current limitation: - the in-process Python tool API is payload-first - the current Node and Bash host contracts remain centered on classic object/function-style tool calls So if you need advanced custom/freeform payload handling today, prefer an in-process Python tool. For the full Node host wire protocol, see `docs/reference/tool-host-protocol.md`. ## Packaging & Layout - Minimal package layout for a tools package: - `dev_plugins/__init__.py` - `dev_plugins/file_reader_tool.py` (class `FileReaderTool`) - `dev_plugins/file_writer_tool.py` (class `FileWriterTool`) - `agent_plugin.json` at the repo root (installed as a top-level data file) - Packaging options - `pyproject.toml` (PEP 517/518) with setuptools, or minimal `setup.py` for local development. - The application installs Git-based plugins using `pip install --target `; ensure your package is installable. - Example `setup.py` (minimal, with root descriptor): ``` from setuptools import setup setup( name="dev_plugins", version="0.0.1", packages=["dev_plugins"], data_files=[("", ["agent_plugin.json"])], ) ``` ## Descriptor (agent_plugin.json) When loading a plugin repo via `path:...` or `git+...`, the application reads an optional descriptor to declare explicit entries. Install `agent_plugin.json` as a top-level data file in the plugin install directory (for example via setuptools `data_files`). - Schema: ``` { "entries": ["dev_plugins.file_reader_tool.FileReaderTool", "dev_plugins.file_writer_tool.FileWriterTool"], "subdirectory": "." } ``` If `entries` are provided, the loader does not auto-discover additional classes; only listed entries are loaded. ## Wiring Each Plugin Kind You can wire plugins via dotted paths, local paths, or Git installers. See also `docs/plugins/application-config.md` for the full configuration reference. - Providers (examples): - Dotted class: `"plugins.openai_provider.OpenAICompatibleProvider"` - Local repo (descriptor-driven): `"path:/abs/or/rel"` - Git repo (descriptor-driven): `"git+https://github.com/acme/ai-providers.git#v1.0.0"` - Explicit single class (verbose): `{ "git": "https://github.com/acme/ai-providers.git", "ref": "v1.0.0", "entry": "acme.provider.MyProvider" }` - Extensions: - Dotted class: `"plugins.openai_tools_extension.OpenAIToolsExtension"` - Config ordering is preserved; extensions run in list order. - Features: - Dotted class or local path; typically optional enrichments. - Tools: - Repo shorthand (descriptor-driven): `"path:/abs/or/rel"` - Single class (verbose): `{ "path": "/abs/or/rel", "entry": "dev_plugins.file_reader_tool.FileReaderTool" }` - Application plugins: - Dotted class or repo spec; plugin type is inferred by duck-typing. ## Best Practices - Keep implementations side-effect-light; use adapter defaults where possible. - Treat shared state (provider/extensions/features) as immutable per-turn. - For Git distribution: - Prefer pinned refs; remote installs are disabled by default and must be explicitly allowed. - Consider host allowlists and internal repositories. ## Registering Plugins ``` from agent_core import AgentCore from plugins.openai_provider import OpenAICompatibleProvider core = AgentCore() core.register_provider(OpenAICompatibleProvider, extensions=[PrefixExtension]) core.register_feature(TagFeature) core.register_tool(Calculator) config = { "provider": "openai_compatible", "model": "gpt-4o", "api_key": "sk-...", } session = core.create_session() session = core.add_message(session, "user", "Hello") session, finals = core.send_request(session, config) ``` ## Testing & Validation - Run unit tests (unit + ollama; integration skipped by default): `pytest core/python/tests -q` - Run OpenRouter integration tests (plugin bundle): `pytest plugins/openrouter/tests -m openrouter -q` - Run all integration tests: `pytest core/python/tests -m integration -q` - Run example tests: `pytest core/python/examples/tests -qs` - Lint/format/typecheck: `ruff`, `black`, `pyright` per AGENTS.md For full method details and signatures, see the [Types & Protocols](https://docs.cl-static-test.dynamicprogrammingsolutions.com/reference/agent_core/types/index.md) and [Provider Wrapper](https://docs.cl-static-test.dynamicprogrammingsolutions.com/reference/agent_core/plugin/provider/index.md) references. # Application Plugin Configuration This page explains how to configure plugins in the application. It covers the `plugins` block, spec formats (dotted, local path, Git), placeholders, policy, and ready-to-use examples. ## Overview - top-level `plugins`: global plugin package specs shared by all agents. - top-level `mixins`: reusable config fragments that can be applied to providers and agents. - provider-level `plugins`: additional plugin package specs for agents using that provider config. - agent-level `plugins`: additional plugin package specs for that agent only. - session metadata `plugins`: still means explicit enabled plugin ids, not plugin package specs. - `disabled_plugins`: config-level shorthand for disabling plugin ids by default after the merged catalog is built. - `enabled_plugins`: config-level list of plugin ids to enable. Used to re-enable plugins that are disabled by default (via `default_enabled = False` on the plugin class). - `force_enabled_plugins`: config-level list of plugin ids to force-enable, overriding tag-based enablement logic (`is_enabled`, `required_tags`, `forbidden_tags`). - `plugin_cache_dir`: enables plugin installs and caching (for both `git+...` and `path:...`). - `plugin_policy`: controls installation and security policy (see Policy & Security). - `mixin_policy`: controls config-mixin merge defaults and recursion depth. ## Built-in Placeholder Environment Variables When the application resolves placeholders, it now provides a few path-oriented environment variables automatically. These can still be overridden explicitly by the embedding app through `AgentApplication(..., env_overrides=...)`. Embedding apps can also pass config-resolution-only values with `AgentApplication(..., config_env=...)`; those values are available to `${env:...}` placeholders but are not installed into the process environment. - `CONFIG_DIR`: absolute path to the directory containing the active config file - `WORKING_DIR`: absolute path to the current working directory - `BUILTIN_PLUGINS`: default built-in plugin bundle directory for the current install layout These are especially useful for portable configs: ``` { "plugin_cache_dir": "${env:CONFIG_DIR}/.plugin_cache/plugins", "plugins": [ "path:${env:BUILTIN_PLUGINS}/openrouter", "path:${env:BUILTIN_PLUGINS}/feature-system-message" ], "agents": { "default": { "provider": "openrouter_tools", "system_message": { "template": "{{AGENTS}}", "variables": { "AGENTS": { "text": "${file:${env:WORKING_DIR}/AGENTS.md}" } } } } } } ``` ## Configuration Layers `plugins` intentionally has different semantics depending on where it appears: - top level: plugin package specs loaded for every agent - provider config: plugin package specs added for agents that use that provider - agent config: plugin package specs added only for that agent - session metadata: explicit enabled plugin ids controlled by the existing session UI/API flow `disabled_plugins` is available at the top level, provider level, and agent level. It is always a list of plugin ids, never plugin package specs. ## Config Mixins Application config supports reusable mixins for provider and agent entries. Top-level keys: - `mixins`: mapping of mixin id to config fragment - `mixin_policy.default_merge`: `"shallow"` (default) or `"deep"` - `mixin_policy.max_depth`: positive integer recursion limit, default `16` Per-provider, per-agent, and per-mixin keys: - `mixin_refs`: ordered list of mixin ids - `mixin_merge`: optional override for how that node merges its referenced mixins with its local keys Precedence rules: 1. Referenced mixins are resolved recursively. 1. Mixins are applied in `mixin_refs` order. 1. Later mixins override earlier mixins. 1. Keys defined directly on the provider/agent/mixin override mixin-provided keys. 1. After mixin expansion, normal config layering still applies, so agent config overrides provider config on overlap. Merge rules: - `shallow`: replace whole top-level keys - `deep`: recursively merge dictionaries - for `deep`, lists and scalar values are still replaced rather than concatenated Example: ``` { "mixin_policy": { "default_merge": "deep", "max_depth": 16 }, "mixins": { "portable-system-message": { "system_message": { "variables": { "AGENTS": { "text": "${file:${env:WORKING_DIR}/AGENTS.md}" } } } }, "openai-base": { "mixin_refs": ["portable-system-message"], "provider": "openai_compatible", "model": "gpt-5-mini" } }, "providers": { "openai": { "mixin_refs": ["openai-base"], "api_key": "${env:OPENAI_API_KEY}" } }, "agents": { "default": { "provider": "openai", "system_message": { "template": "{{AGENTS}}" } } } } ``` ## System Message Templates Multi-vendor agent configs can keep system-message content readable by combining markdown files with `feature-system-message`. Recommended pattern: - put large static prompt sections in markdown files under a runtime directory such as `application/python/agent_terminal_app/system_messages/` - load the template itself with `${file:...}` - define a reusable runtime namespace with `system_message.runtime_code` - load static sections with `files` - use `condition` for optional sections like git guidance - use `inline_code` for short runtime values like the current working directory - use file-backed `code` helpers only for moderately complex dynamic sections Example: ``` { "plugins": [ "path:${env:BUILTIN_PLUGINS}/openrouter", "path:${env:BUILTIN_PLUGINS}/feature-system-message" ], "agents": { "default": { "provider": "openrouter_gemini_tools", "system_message": { "template": "{{BASE}}\n\n{{GIT_SECTION}}\n\n{{WORKING_DIRECTORY_LINE}}", "runtime_code": "${env:CONFIG_DIR}/system_messages/helpers/runtime.py", "variables": { "BASE": { "files": [ "${env:CONFIG_DIR}/system_messages/gemini/preamble.md", "${env:CONFIG_DIR}/system_messages/gemini/core-mandates.md" ] }, "GIT_SECTION": { "files": [ "${env:CONFIG_DIR}/system_messages/gemini/git-section.md" ], "condition": { "inline_code": "is_git_repo()" } }, "WORKING_DIRECTORY_LINE": { "inline_code": "f'Current working directory: {cwd}'" } } } } } } ``` `inline_code` supports two forms: - expression form: the expression result is rendered directly - block form: the plugin executes the code and renders `VALUE` `runtime_code` is optional. When present, the plugin executes that Python file once per request and exports all non-underscore globals into the runtime namespace used by `inline_code`, file-backed `code`, and their condition variants. The runtime namespace always includes: - `CONFIG`: the effective resolved config dict for the current request When UI schema is queried, the runtime namespace also includes: - `TAGS` - `MODELS` If the runtime file exports a zero-argument `get_ui_elements()` function, `feature-system-message` calls it and merges the returned items into the normal UI schema. Typical `runtime_code` exports include: - `cwd` - `is_git_repo()` - `git_root()` - `Path` - `os` Without `runtime_code`, dynamic snippets only see normal Python builtins unless they import what they need themselves. This makes it possible to keep model- or provider-specific prompt routing in config-owned helper code. A common pattern is to store an ordered config list such as `codex_developer_messages` and let `runtime_code` select the matching prompt text by inspecting `CONFIG`. For a complete example, see: - [Default configuration](https://docs.cl-static-test.dynamicprogrammingsolutions.com/reference/default-config/index.md) - [feature-system-message](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/feature-system-message/index.md) ### Merge Order The effective plugin catalog for a session is built in this order: 1. Load top-level `plugins`. 1. Load `plugins` from the selected provider config. 1. Load `plugins` from the selected agent config. 1. Deduplicate by plugin id with first registration winning. 1. Identify plugins with `default_enabled = False` class attribute (disabled by default). 1. Compute the default enabled set: 1. Start with the merged catalog 1. Remove plugins with `default_enabled = False` 1. Re-add plugins listed in `enabled_plugins` config 1. Remove plugins listed in merged `disabled_plugins` 1. If session metadata contains `plugins`, use that explicit enabled-id list instead of the config-derived default. This means provider-level and agent-level packages are additive. They do not replace the global packages. ### Plugin Enablement at Runtime During request processing, the core applies tag-based filtering: 1. Compute tags from provider and enabled plugins. 1. For each plugin, check `is_enabled(config, tags, models, context)`: 1. If returns `True`: plugin is enabled 1. If returns `False`: plugin is disabled 1. If returns `None`: fall back to tag-based check 1. Tag-based check: 1. `required_tags()`: all tags must be present 1. `forbidden_tags()`: no tag may be present 1. Apply `force_enabled_plugins` config override (plugins listed are always enabled). ### Duplicate Plugin Ids When multiple config layers contribute plugin classes with the same plugin id, the first registration wins. - global package beats provider package for the same plugin id - provider package beats agent package for the same plugin id This is especially important for tool families that reuse names such as `read_file` or `glob`. ## Example: Global + Provider + Agent ``` { "plugin_cache_dir": "${env:PLUGIN_CACHE_DIR}", "providers": { "openrouter_codex": { "provider": "openrouter", "model": "openai/gpt-5-mini", "api_key": "${env:OPENROUTER_API_KEY}", "plugins": [ "path:${env:REPO_ROOT}/plugins/codex-tools" ], "disabled_plugins": ["apply_patch"] }, "openrouter_gemini": { "provider": "openrouter", "model": "google/gemini-2.5-flash-lite", "api_key": "${env:OPENROUTER_API_KEY}", "plugins": [ "path:${env:REPO_ROOT}/plugins/gemini-tools" ] } }, "plugins": [ "path:${env:REPO_ROOT}/plugins/openrouter", "path:${env:REPO_ROOT}/plugins/feature-request-options" ], "disabled_plugins": ["openrouter_usage"], "agents": { "codex-agent": { "provider": "openrouter_codex" }, "gemini-agent": { "provider": "openrouter_gemini", "plugins": [ "path:${env:REPO_ROOT}/plugins/session-title-app" ], "disabled_plugins": ["session_title_app"] } } } ``` In that example: - every agent gets the global OpenRouter provider package - `codex-agent` also gets `codex-tools` from its provider config - `gemini-agent` also gets `gemini-tools` from its provider config - `gemini-agent` adds one more package from its agent config - the default enabled set is the merged catalog minus top-level, provider-level, and agent-level `disabled_plugins` - if a session later stores `metadata.plugins`, that explicit enabled-id list overrides the config-derived default ## Migration Note Older configs could use agent-level `plugins` as a list of enabled plugin ids. That meaning is no longer supported. Use: - agent/provider/top-level `plugins` for plugin package specs - config-level `disabled_plugins` for default opt-outs - session metadata `plugins` for explicit per-session enabled plugin ids ## Spec Formats Each plugin reference is a “spec” that can take one of the following forms: - Dotted class (no installation) - `"pkg.module.Class"` - Local plugin repo (descriptor-driven; recommended) - `"path:/abs/or/rel"` The repo is installed into `plugin_cache_dir` via `pip install --target ...`, then plugin classes are loaded from `agent_plugin.json`. - Git plugin repo (descriptor-driven; recommended) - `"git+[#]"` - Verbose object spec (explicit single-class target) - Local: `{ "path": "/abs/or/rel", "subdirectory": "optional/subdir", "entry": "pkg.module.Class" }` - Git: `{ "git": "", "ref": "tag-or-commit", "subdirectory": "optional/subdir", "entry": "pkg.module.Class" }` - JavaScript/TypeScript tool plugins (Node.js) - Local directory: `{ "node_tool": { "path": "./path/to/js-tool" } }` - Git repo: `{ "node_tool": { "git": "https://github.com/acme/my-js-tool.git", "ref": "v0.1.0" } }` - Single file: `{ "node_tool": { "file": "./my_tool.js", "id": "my_tool", "entry": "my_tool.js" } }` - Bash tool plugins (single-file or manifest-driven) - Single file: `{ "bash_tool": { "file": "./tools/my_tool.bash" } }` - Local directory: `{ "bash_tool": { "path": "./plugins/bash-tools" } }` (requires `agent_plugin.json` with `bash_tools`) - Git repo: `{ "bash_tool": { "git": "https://github.com/acme/bash-tools.git", "ref": "v0.1.0", "subdirectory": "optional/subdir" } }` (requires `agent_plugin.json` with `bash_tools`) String shortcuts: - Local directory or single file: `"node:/abs/or/rel"` - Git repo: `"node+git:[#]"` Bash string shortcuts: - Local directory or single file: `"bash:/abs/or/rel"` - Git repo: `"bash+git:[#]"` For repos, `subdirectory` selects a subfolder within the repo to install (useful for monorepos). ### Descriptor (agent_plugin.json) For repo specs (`path:...` or `git+...`), the loader reads `agent_plugin.json`. The plugin's packaging should install it as a top-level data file. ``` { "entries": ["dev_plugins.file_reader_tool.FileReaderTool", "dev_plugins.file_writer_tool.FileWriterTool"], } ``` Optional fields: - `subdirectory`: default install subdirectory (used when the config spec does not provide `subdirectory`). Other fields may exist for authoring convenience; the loader only requires `entries`. ### Descriptor (Node.js tools via package.json) For `node_tool` directory specs, the loader reads `package.json#agent.tools`. Example: ``` { "name": "@acme/my-js-tools", "version": "0.1.0", "type": "module", "agent": { "kinds": ["tools"], "tools": [ { "id": "echo", "entry": "dist/index.js" }, { "id": "reverse", "entry": "dist/index.js", "export": "reverseTool" } ] } } ``` When `export` is present, the Node tool host selects `module[export]`; otherwise it uses the default export. ### Descriptor (bash tool repos via agent_plugin.json) For `bash_tool` directory/git specs, the loader reads `agent_plugin.json` and requires a `bash_tools` list. Example: ``` { "bash_tools": [ {"file": "read_line_range.bash"}, {"file": "tools/grep_context.bash"} ] } ``` The `kinds` field is intentionally omitted for bash tool repos (bash repos are always tools). ## Bash Tool File Contract Each bash tool file is executed as: ``` bash /abs/path/to/tool.bash [args...] ``` Subcommands: - `schema`: prints JSON schema to stdout. - `preview`: prints a single-line preview string. - `run`: executes the tool and writes output to stdout (streamed). - `error` (optional): formats the final tool message text on failure. The bash tool host also injects: - `AGENT_TOOL_PYTHON`: absolute path to the Python interpreter running the host - `AGENT_TOOL_TIMED_OUT=1`: when invoking `error` after a timeout - `AGENT_TOOL_TIMEOUT_SECONDS=`: timeout value for timed-out invocations ### Schema JSON `schema` must return a JSON object containing at least: ``` { "id": "my_tool", "version": "0.1.0", "args_mode": "flags", "tools": [ ... OpenAI tool schemas ... ] } ``` Optional schema field: ``` { "config_keys": ["my_setting", "another_setting"] } ``` When present, the bash tool host reads those keys from the resolved request config and exports them into the subprocess environment as: - `AGENT_TOOL_CONFIG_MY_SETTING` - `AGENT_TOOL_CONFIG_ANOTHER_SETTING` v1 constraints: - Exactly one tool schema per file. - `schema.id` must match `tools[0].function.name`. ### Argument Passing (`args_mode`) - `flags`: - Scalars: `--name ` - Booleans: `true` → `--name`, `false` → `--no-name` - `positional`: - `schema.positional` defines the ordered argv mapping: - Each entry is `{ "name": "...", "required": true|false, "default": }`. - Values are passed in order as plain argv strings. - Trailing non-provided arguments with defaults may be omitted. - `json`: - Python calls: `run --args-json` (and `preview --args-json` / `error --args-json`). - The JSON `arguments` object is written to stdin. - Tools are expected to parse it with `jq`. ### Error Formatting Hook (`error`) When `run` fails (non-zero exit code) or times out, the host may call: ``` bash tool.bash error [args...] ``` If `error` exits 0 and prints non-empty stdout, that text is used as the final tool message `content` (what the LLM sees). Otherwise, the host falls back to a default formatting including exit code + stderr/stdout. On timeout, the host sets: - `AGENT_TOOL_TIMED_OUT=1` - `AGENT_TOOL_TIMEOUT_SECONDS=` For all bash-tool subprocesses, the host also sets: - `AGENT_TOOL_PYTHON=` This is intended for bash tools that delegate part of their work to helper Python scripts and need a stable interpreter path. ### Streaming Tool stdout is streamed as tool partial events. When enabled by policy, stderr is also streamed. The final tool message is sent after the process exits. ## Config Forms - Flat list (auto-kind via duck-typing): ``` { "plugins": [ "plugins.openai_provider.OpenAICompatibleProvider", "plugins.openai_tools_extension.OpenAIToolsExtension", { "path": "${env:DEV_PLUGINS_DIR}", "entry": "dev_plugins.file_reader_tool.FileReaderTool" }, "path:${env:DEV_PLUGINS_DESC_DIR}", "git+https://github.com/acme/dev-plugins.git#v1.0.0" ] } ``` ## Placeholders - `${env:VAR}` is supported anywhere within strings. Missing env vars are logged as `env_missing` and replaced with an empty string. - `${file:...}` is supported as a whole-string placeholder only. ## Policy & Security - `plugin_cache_dir`: enables installs and caching. - `plugin_policy`: - `allow_remote` (default false): allow `https/ssh` and similar remote Git URLs. - `allowed_git_hosts`: optional allowlist of hosts for remote Git (enforced when `allow_remote` is true). - `pip_args`: extra pip args for Git and local installs. - `pip_cache_dir`: pip download/build cache for Git and local installs. Defaults to a sibling `pip` directory next to the plugin install cache, such as `${CONFIG_DIR}/.plugin_cache/pip` when `plugin_cache_dir` is `${CONFIG_DIR}/.plugin_cache/plugins`. - `install_deps` (default false): when false, installs run with `--no-deps`. Node.js tool policy keys: - `node_timeout_seconds` (default 60): timeout per tool RPC. - `node_install_deps` (default true): run a package manager install step in cached copies. - `node_build` (default true): run a build step in cached copies (uses `package.json#scripts.build` when present). - `node_package_manager`: - If set: force `npm`, `pnpm`, or `yarn` for all Node tool plugins. - If omitted: inferred from `package.json#packageManager` (preferred) or lockfiles (`pnpm-lock.yaml`, `yarn.lock`), falling back to `npm`. - `node_allow_install_scripts` (default true): allow install-time scripts. - `node_build_command`: optional global override for the build command (otherwise uses ` run build`). Bash tool policy keys: - `allow_bash_tools` (default false): enable `bash_tool` plugin loading. - `bash_timeout_seconds` (default 60): timeout for `schema`/`preview`/`run`. - `bash_error_timeout_seconds` (default 5): timeout for the optional `error` hook. - `bash_stream_stderr` (default false): stream stderr as tool partials. Security guidance: - Git installs run arbitrary code via pip; prefer pinned refs (tags/commits), and use `allowed_git_hosts` where possible. Local path installs are also executed via `pip install` (against local source trees), so treat them as code execution in the current environment. ## Troubleshooting - Errors are logged during resolution (e.g., `plugin_manager_resolve_errors`). - You can also access them programmatically: ``` app = AgentApplication("config.json", "./sessions") for err in app.get_config_errors(): print(err.get("type"), err.get("detail")) ``` ## See Also - [Plugin development](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugins/development/index.md) — authoring, packaging, and wiring guidelines - [Packaging and loading plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/plugin-packaging-and-loading/index.md) — packaging and loader behavior for local/git plugin packages - [codex-tools](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/codex-tools/index.md) — Codex-style tool package quickstart - [gemini-tools](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/gemini-tools/index.md) — Gemini-style tool package quickstart - [qwen-tools](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/qwen-tools/index.md) — Qwen-style tool package quickstart - [grok-tools](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/grok-tools/index.md) — Grok-style tool package quickstart - [Tool host protocol](https://docs.cl-static-test.dynamicprogrammingsolutions.com/reference/tool-host-protocol/index.md) — the NDJSON tool host protocol # Current Plugin Docs # Plugin development docs Start here for the current shared plugin documentation set. - [Provider plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/provider-plugins/index.md) - [Provider extensions (tools + reasoning)](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/provider-extensions/index.md) - [Feature plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/feature-plugins/index.md) - [Application plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/application-plugins/index.md) - [Tool plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/tool-plugins/index.md) - [Tool interop flow](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/tool-interop/index.md) - [Node tool plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/node-tool-plugins/index.md) - [Bash tool plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/bash-tool-plugins/index.md) - [Plugin actions](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/actions/index.md) - [Packaging & loading plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/plugin-packaging-and-loading/index.md) - [Execution order (providers, extensions, features, tools)](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/plugin-execution-order/index.md) - [Configuration schema (`get_config_schema`)](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/config-schema/index.md) - [UI elements (`get_ui_elements` and `ui_type`)](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/ui-elements/index.md) - [Testing & validation](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/testing-and-validation/index.md) - [Specialized task agents and real-model validation](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/specialized-task-agents/index.md) - [Older/general plugin overview](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugins/development/index.md) # Provider plugins Provider plugins integrate LLM APIs and convert between: - **Core messages**: AgentCore’s stable, provider-agnostic message format - **Provider-native messages**: the wire-format your provider uses This guide is **provider-specific** and intended to be more complete than the general plugin overview in `docs/plugins/development.md`. Runtime code references (source of truth): - Protocols/docstrings: `core/python/agent_core/types.py` - Provider wrapper orchestration: `core/python/agent_core/plugin/provider.py` - Defaulting/normalization: `core/python/agent_core/plugin/adapters.py` - Core request flow: `core/python/agent_core/core.py` ## Contents - [Concepts & vocabulary](#concepts-vocabulary) - [Quickstart / development workflow](#quickstart-development-workflow) - [Complete guide: build a full provider](#complete-guide-build-a-full-provider-step-by-step) - Deep dives: - [Provider state shape](#deep-dive-provider-state-shape) - [Native history retention](#deep-dive-native-history-retention) - [Streaming patterns & recipes](#deep-dive-streaming-patterns-recipes) - [Debugging streaming chunks](#deep-dive-debugging-streaming-chunks) - [Interop (extensions/features/tools)](#deep-dive-interop-extensionsfeaturestools) - [Testing provider plugins](#deep-dive-testing-provider-plugins) - [Reference implementations](#reference-implementations) See also: - [Tool interop flow](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/tool-interop/index.md) - [Provider extensions (tools + reasoning)](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/provider-extensions/index.md) - [Configuration schema (`get_config_schema`)](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/config-schema/index.md) - [Packaging & loading plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/plugin-packaging-and-loading/index.md) - [Execution order (providers, extensions, features, tools)](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/plugin-execution-order/index.md) - [UI elements (`get_ui_elements`)](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/ui-elements/index.md) - [Testing & validation](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/testing-and-validation/index.md) ______________________________________________________________________ ## Concepts & vocabulary ### Core message shape Core messages are dicts like: ``` { "role": "user" | "assistant" | "system" | "tool", "content": "...", "metadata": {"any": "json"}, # optionally: "multipartContent": [ {"type": "image", "content": {"url": "..."}, "metadata": {}} ] } ``` ### Provider-native message shape Provider-native messages are whatever your provider needs. Common OpenAI-like shape: ``` {"role": "user", "content": "Hello"} ``` Provider-native messages may include provider-specific keys (tool calls, reasoning, cache metadata, etc.): ``` { "role": "assistant", "content": "...", "tool_calls": [{"id": "...", "type": "function", "function": {"name": "x", "arguments": "{}"}}], "reasoning": "...", "_metadata": {"cached_tokens": 123} } ``` ### Partials vs finals - **partials**: incremental updates during streaming - **finals**: completed assistant messages for the turn Partial example (during streaming): ``` {"role": "assistant", "content": "Hel"} ``` Final example (after finalize): ``` {"role": "assistant", "content": "Hello world"} ``` ### Shared provider state (`state: dict`) Providers, provider extensions, and features share a single state dict (owned by the provider wrapper). Recommended state shape: ``` state = { "config": config, "request": {"url": "...", "payload": {...}, "headers": {...}, "timeout": 60}, "partial": None, } ``` Runtime-only config keys may also be injected by the application layer or by features before request execution. Providers and features can also use the real process environment installed by `AgentApplication` from the `${env:...}` config-resolution mapping. That makes keys like `CONFIG_DIR` available through `os.environ` without hardcoding repo-local locations. Another common pattern is a feature updating `state["config"]` before the provider request starts. This is useful for auth bridging, where a feature can: - read a credential file from `CONFIG_DIR` - choose an effective auth mode such as `api` / `chatgpt` / `auto` - inject runtime-only values like access tokens or alternate base URLs - clear `state["client"]` so the provider recreates its SDK client with the new config ______________________________________________________________________ ## Quickstart / development workflow This workflow matches how provider integrations are typically built in practice: 1. package scaffold → 2) non-streaming first (learn shapes) → 3) streaming → 4) try in a real app ### Step 0 — Create a provider plugin package (recommended) Start from the template: - `plugins/template-python-provider` Copy it (or fork it) and rename: ``` template-python-provider/ -> my-provider/ src/template_python_provider/ -> src/my_provider/ ``` Update the descriptor `agent_plugin.json` entries to your package/module/class names. Example `agent_plugin.json`: ``` { "entries": [ "my_provider.provider.MyProvider", "my_provider.extension.MyProviderExtension", "my_provider.feature.MyFeature" ], "subdirectory": "." } ``` More details: [Packaging & loading plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/plugin-packaging-and-loading/index.md). ### Step 1 — Implement non-streaming first (`call_api`) + write the first tests Non-streaming is the fastest way to learn provider request/response shapes without chunk parsing. Minimum provider methods to start: - `init(config) -> state` - `call_api(native_messages, state)` - (optionally) `to_native_messages` / `from_native_messages` if you need custom conversion Example `call_api` skeleton (transport omitted): ``` def call_api(self, native_messages, state): request = state.get("request") or {} # TODO: send the request using your transport (SDK, HTTP client, etc.) # raw = requests.post(...).json() # one possible implementation raw = {"choices": [{"message": {"role": "assistant", "content": "hi"}}]} # TODO: extract provider-native assistant message msg = raw["choices"][0]["message"] # Return (partial_messages, final_messages, native_messages, new_state) return [], [msg], [*native_messages, msg], state ``` First smoke test (pytest) should call `send_request`: ``` def test_call_api_smoke(): core = AgentCore() core.register_provider(MyProvider) session = core.create_session() session = core.add_message(session, "user", "Hello") _new_session, finals = core.send_request(session, {"model": "m"}) assert finals and finals[-1]["role"] == "assistant" ``` ### Step 2 — Add debug logging for shape discovery (trial-and-error) When you don’t know the response shape yet, add *temporary* debug logging. Recommended pattern: log truncated payloads and never log secrets. ``` from agent_core.utils import get_logger log = get_logger("my_provider") log.info("provider_response", response=str(raw)[:2000]) ``` Recommended test to drive this: ``` def test_debug_dump_response_shape(caplog): # Run one request; inspect caplog output while developing. with caplog.at_level("INFO"): _session2, finals = core.send_request(session, {"model": "m"}) assert finals # Inspect caplog.text while iterating. ``` ### Step 3 — Implement streaming (choose a path) There are **two supported paths**. #### Path A (recommended): accumulator pattern You keep shared `process_chunk`/`finalize` and implement: - `stream_api` - `extract_delta` - `process_delta` Example delta extractor (OpenAI-like): ``` def extract_delta(self, chunk: dict) -> dict: return (chunk.get("choices") or [{}])[0] ``` Example delta reducer: ``` def process_delta(self, delta: dict, accumulated: dict | None): base = accumulated or {} d = delta.get("delta") or {} frag = {"role": d.get("role", "assistant"), "content": d.get("content", "")} new_acc = { **base, # IMPORTANT: preserve keys written by extensions "role": base.get("role", frag["role"]), "content": base.get("content", "") + (frag.get("content") or ""), } return frag, new_acc ``` In the template, the shared implementation lives in `AccumulatorStreamingMixin`. Note: if you use a mixin/base-class that provides `process_chunk`/`finalize`, put it **before** `ProviderPlugin` in your class bases so Python’s method resolution order uses the mixin implementation. #### Path B: custom streaming Implement `process_chunk` and `finalize` yourself. ``` def process_chunk(self, native_chunk, native_messages, state): # TODO: parse chunk partial = {"role": "assistant", "content": native_chunk.get("content", "")} # IMPORTANT: do not append raw chunk to native history return [partial], [], list(native_messages), state def finalize(self, native_messages, state): final = {"role": "assistant", "content": state.get("acc", "")} return [final], [*native_messages, final], state ``` ### Step 4 — Streaming tests (recommended) First streaming test should: - assert partial events exist - assert final exists - assert final content == concatenation of partials (for simple text providers) ``` def test_streaming_smoke(): partial = "" final = None for e in core.send_request_stream(session, cfg): if e["type"] == "partial": partial += e["message"].get("content", "") if e["type"] == "final": final = e assert final is not None assert final["messages"][-1]["content"] ``` ### Step 5 — Try it in a real app config (terminal/desktop/mobile) Automated tests provide the fastest feedback loop, but you’ll eventually want to try the provider in a real app. Example terminal app config pattern (adapt from `application/python/agent_terminal_app/config_echo_app.json`): ``` { "plugin_cache_dir": "~/.crystal/cache/plugins", "plugins": [ "path:/abs/or/rel/to/my-provider-repo" ], "providers": { "my_provider_default": { "provider": "my_provider_id", "model": "my-model", "api_key": "${env:MY_API_KEY}", "base_url": "https://example.com/v1" } }, "agents": { "default": {"provider": "my_provider_default"} } } ``` Then run the terminal app with that config (see `application/python/agent_terminal_app`): ``` cd application/python python -m agent_terminal_app --console --config /path/to/your_config.json ``` For more config formats, see: - [Packaging & loading plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/plugin-packaging-and-loading/index.md) - `docs/plugins/application-config.md` ______________________________________________________________________ ## Complete guide: build a full provider (step-by-step) This section expands each step with best practices and links to deep dives. ### 1) Define identity, config schema, and UI ``` class MyProvider(AccumulatorStreamingMixin, ProviderPlugin): name = "my_provider_id" version = "0.1.0" def get_config_schema(self): return { "model": {"type": "string", "required": True}, "api_key": {"type": "string", "required": False}, } def get_ui_elements(self, config, tags, models): return [{"type": "text", "key": "model", "label": "Model"}] def get_tags(self, config, models): # Tags are used for request-time dependency resolution. # Provider extensions can declare required_tags() and will only # be enabled when those tags are present. return ["provider:my_provider"] ``` UI deep dive: [UI elements](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/ui-elements/index.md). #### Optional: models and capability tags Providers can optionally implement two hooks that let the core and UIs adapt to the selected model: - `get_models(config) -> list[dict[str, Any]]` - `get_tags(config, models) -> list[str]` The core uses these hooks for request-time dependency resolution: - model descriptors are computed for the effective config - tags are computed from the provider and enabled plugins - extensions/features/tools can declare `required_tags()` and will only be enabled when those tags are present Runtime references: - model caching + validation: `core/python/agent_core/core.py` (`_get_models_for_config`) - tag-based dependency loop: `core/python/agent_core/core.py` (`_resolve_plugins_for_config`) Model descriptor requirements: - each returned model must include `{"id": "..."}` (a non-empty string) - any additional keys are provider-defined (for example `"name"`, `"capabilities"`, or pricing metadata) Keep these hooks conservative: - `get_models` should be fast and resilient; return `[]` on failure rather than raising. - avoid returning huge, unstable lists when you can filter to what the UI needs. Example (hypothetical OpenAI-compatible provider): ``` from __future__ import annotations from typing import Any from agent_core.types import ProviderPlugin class MyOpenAICompatibleProvider(ProviderPlugin): name = "my_openai_compatible" version = "0.1.0" def get_models(self, config: dict[str, Any]) -> list[dict[str, Any]]: # TODO: fetch models from your provider (if supported) using config. # Return [] on errors/timeouts. return [ {"id": "gpt-4.1-mini", "name": "GPT-4.1 mini", "capabilities": {"tools": True}}, {"id": "gpt-4.1", "name": "GPT-4.1", "capabilities": {"tools": True, "reasoning": True}}, ] def get_tags(self, config: dict[str, Any], models: list[dict[str, Any]]) -> list[str]: tags = ["provider:openai_compatible", "supports_streaming"] selected = config.get("model") selected_info = ( next((m for m in models if m.get("id") == selected), None) if isinstance(selected, str) and selected else None ) caps = selected_info.get("capabilities") if isinstance(selected_info, dict) else None if isinstance(caps, dict) and caps.get("tools"): tags.append("supports_tools") if isinstance(caps, dict) and caps.get("reasoning"): tags.append("supports_reasoning") return tags def get_ui_elements( self, config: dict[str, Any], tags: list[str], models: list[dict[str, Any]], ) -> list[dict[str, Any]]: options = [ {"value": m["id"], "label": (m.get("name") or m["id"])} for m in models if isinstance(m.get("id"), str) and m.get("id") ] if options: return [{"type": "select", "key": "model", "label": "Model", "options": options}] return [{"type": "text", "key": "model", "label": "Model id"}] ``` Notes: - The `"capabilities"` field above is a provider-defined convention used only by this provider. The core does not interpret it; only the tags matter outside the provider. - Prefer stable tag names like `provider:...` and `supports_...` so that extensions can depend on them. ### 2) Implement `init` and `initialize_request` ``` def init(self, config): return {"config": config, "request": {}, "partial": None} def initialize_request(self, native_messages, state): cfg = state.get("config") or {} request = { "url": (cfg.get("base_url") or "https://api.example.com/v1").rstrip("/") + "/chat/completions", "payload": {"model": cfg.get("model")}, "headers": {"Authorization": f"Bearer {cfg.get('api_key','')}"}, "timeout": float(cfg.get("timeout", 60)), } return list(native_messages), {**state, "request": request, "partial": None} ``` ### 3) Implement conversion hooks (only when needed) If you need to preserve metadata across rebuild-from-native, use `_metadata`. ``` def to_native_messages(self, messages, state): out = [] for m in messages: md = dict(m.get("metadata") or {}) md.pop("native_indices", None) nm = {"role": m.get("role"), "content": m.get("content", "")} if md: nm["_metadata"] = md out.append(nm) return out def from_native_messages(self, native_messages, state): out = [] for nm in native_messages: md = dict(nm.get("_metadata") or {}) if isinstance(nm.get("_metadata"), dict) else {} md.pop("native_indices", None) out.append({"role": nm.get("role"), "content": nm.get("content", ""), "metadata": md}) return out ``` ### 4) Non-streaming I/O (`call_api`) first ``` def call_api(self, native_messages, state): request = state["request"] # Optional: features may override the exact message history for a request # by placing a rendered list under state["request"]["messages"]. override = request.get("messages") base_history = override if isinstance(override, list) else native_messages # If you use provider-native `_metadata` for native-history retention, # strip it before sending to the provider. messages_to_send = [{k: v for k, v in m.items() if k != "_metadata"} for m in base_history] # TODO: send the request using your transport (SDK, HTTP client, etc.) and parse the response raw = {"choices": [{"message": {"role": "assistant", "content": "hi"}}]} msg = raw["choices"][0]["message"] # Return (partial_messages, final_messages, native_messages, new_state) return [], [msg], [*native_messages, msg], state ``` ### 5) Streaming I/O Accumulator path (recommended): implement `stream_api`, `extract_delta`, `process_delta`. Custom path: implement `process_chunk` and `finalize` yourself. Example `stream_api` skeleton (OpenAI-compatible streaming chunks), including message override and `_metadata` stripping: ``` import json def stream_api(self, native_messages, state): request = state["request"] # Optional: features may override the exact message history for a request # by placing a rendered list under state["request"]["messages"]. override = request.get("messages") base_history = override if isinstance(override, list) else native_messages # If you use provider-native `_metadata` for native-history retention, # strip it before sending to the provider. messages_to_send = [ {k: v for k, v in m.items() if k != "_metadata"} for m in base_history if isinstance(m, dict) ] # TODO: start a streaming request using your transport (SDK, HTTP client, etc.). # It must use a payload like: # {**request["payload"], "messages": messages_to_send, "stream": True} # # The iterator may yield either: # - dict chunks (already-decoded JSON) # - line/SSE-like strings containing JSON objects stream = [] for item in stream: if not item: continue # SDK-style: already a dict chunk. if isinstance(item, dict): yield item continue # Line/SSE-style: decode a line into JSON. data = str(item).strip() if data.startswith("data:"): data = data[len("data:") :].strip() if data == "[DONE]": break if not data: continue try: chunk = json.loads(data) except json.JSONDecodeError: continue yield chunk ``` ### 6) Native history retention (don’t break it) Provider rules of thumb: - append *final* assistant messages to native history - do **not** append raw stream chunks to native history Example finalize (custom): ``` def finalize(self, native_messages, state): final = state.get("partial") if final: return [final], [*native_messages, final], state return [], list(native_messages), state ``` ### 7) Add extension and feature hooks (optional) Provider extensions are recommended for reasoning/tool-call accumulation. Example extension stub: ``` class MyToolsExtension(ProviderExtensionPlugin): name = "my_tools" version = "0.1.0" def required_tags(self): return ["supports_tools"] ``` ## Deep dive: provider state shape The provider wrapper owns a shared state dict for one request. Providers, provider extensions, and features can read/write it. Recommended keys: ``` state = { "config": config, # resolved request config "request": { "url": "...", "payload": {...}, "headers": {...}, "timeout": 60, }, "partial": None, # streaming accumulator (dict) "debug_stream": False, # optional } ``` Guidelines: - Keep `state` JSON-serializable when you can (debugging is easier). - Avoid storing raw chunks in native history; if you need them for debugging, store them under a state key that is not persisted. Runtime reference: - `core/python/agent_core/plugin/provider.py` (wrapper stores/discards state) ______________________________________________________________________ ## Deep dive: native history retention AgentCore can retain provider-native history in `session.metadata["native_messages"]`. Why it matters: - Core↔native conversion can be lossy. - Some provider-native fields (tool call IDs, cache metadata, special roles) must survive across turns. Provider rules of thumb: 1. Always return a coherent **full** native history list. 1. Append final assistant messages to native history. 1. Do not append raw stream chunks to native history. 1. Use `_metadata` on native messages if you need metadata to survive rebuild-from-native. Example: preserving metadata through rebuild-from-native: ``` # in to_native_messages nm = {"role": role, "content": content, "_metadata": {"cached_tokens": 123}} # in from_native_messages md = dict(native.get("_metadata") or {}) core_msg = {"role": native.get("role"), "content": native.get("content"), "metadata": md} ``` Recommended retention test pattern (native-only marker): ``` class MarkerExt(ProviderExtensionPlugin): def finalize(self, finals, native, state): return finals, [{**m, "_native_only": True} for m in native], state class MyProvider(...): def call_api(self, native_messages, state): preserved = any(m.get("_native_only") for m in native_messages) ... ``` See also: - `core/python/tests/test_native_retention_across_core_instances.py` ______________________________________________________________________ ## Deep dive: streaming patterns & recipes ### Recipe 1: OpenAI-style SSE JSON chunks Many providers stream line-delimited JSON where each line contains something like: ``` {"choices": [{"delta": {"role": "assistant", "content": "H"}}]} ``` Typical accumulator-based implementation: ``` def extract_delta(self, chunk: dict) -> dict: return (chunk.get("choices") or [{}])[0] def process_delta(self, delta: dict, accumulated: dict | None): base = accumulated or {} d = delta.get("delta") or {} frag = {"role": d.get("role", "assistant"), "content": d.get("content", "")} new_acc = { **base, # IMPORTANT: preserve keys written by extensions "role": base.get("role", frag["role"]), "content": base.get("content", "") + frag["content"], } return frag, new_acc ``` ### Recipe 2: Custom `process_chunk` / `finalize` (when the accumulator pattern isn’t a fit) The accumulator pattern works well when your provider emits clean text/tool/reasoning deltas that can be reduced into a single “final assistant message”. Implementing `process_chunk`/`finalize` directly is appropriate when: - The stream has multiple event types that don’t map cleanly to a single accumulator (e.g. separate “content blocks”, “thinking blocks”, “tool blocks”). - You need special-case handling of deltas (restarts, rewinds, out-of-order indices, partial JSON, etc.). - You want to emit more than one final message for a turn (for example, a primary answer plus an additional assistant note/message). - Using `extract_delta`/`process_delta` would be more complicated than handling the protocol explicitly. In these cases, treat `native_chunk` as an opaque provider event and write an explicit reducer. Example (illustrative event-type switch): ``` def process_chunk(self, native_chunk, native_messages, state): event_type = native_chunk.get("type") # Example: text delta if event_type == "content_delta": text = (native_chunk.get("delta") or {}).get("text", "") acc = (state.get("acc") or "") + (text or "") partials = [{"role": "assistant", "content": text}] if text else [] # IMPORTANT: keep native history unchanged while streaming. return partials, [], list(native_messages), {**state, "acc": acc} # Example: tool-call delta or other structured data if event_type == "tool_delta": tool_state = {**(state.get("tool") or {}), **(native_chunk.get("delta") or {})} return [], [], list(native_messages), {**state, "tool": tool_state} # Ignore unknown/non-message events return [], [], list(native_messages), state def finalize(self, native_messages, state): # Build one or more final provider-native assistant messages. finals = [] if state.get("acc"): finals.append({"role": "assistant", "content": state["acc"]}) if state.get("tool"): # Example: attach tool summary as a second assistant message finals.append({"role": "assistant", "content": f"Tool summary: {state['tool']}"}) full_history = [*native_messages, *finals] return finals, full_history, state ``` Even with fully custom streaming, keep these invariants: - Do not append raw stream chunks/events into native history. - Append only final provider-native chat messages (assistant/tool/etc.) to native history. - Return `native_messages` as a coherent full-history list. ### Tool calls and reasoning Best practice in this repo: - Provider: transport + base accumulation - Extension: tool_call and reasoning delta accumulation (shared `state["partial"]`) Reference extensions: - `core/python/plugins/openai_tools_extension.py` - `core/python/plugins/ollama_thinking_extension.py` See also: [Provider extensions (tools + reasoning)](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/provider-extensions/index.md) for step-by-step recipes, accumulator interop rules, and copyable code skeletons. ______________________________________________________________________ ## Deep dive: debugging streaming chunks When you don’t know the chunk shape, log the first N chunks/deltas. Example (with truncation): ``` from agent_core.utils import get_logger log = get_logger("my_provider") if state.get("debug_stream"): log.info("chunk", chunk=str(native_chunk)[:2000]) ``` Recommended test during development: ``` def test_capture_first_chunks(caplog): # Enable debug_stream in config and run one streaming request. with caplog.at_level("INFO"): for _ in core.send_request_stream(session, {"debug_stream": True, "model": "m"}): pass # Inspect caplog.text while iterating. assert "chunk" in caplog.text ``` ______________________________________________________________________ ## Deep dive: interop (extensions/features/tools) ### Provider extensions Provider extensions are the preferred place to implement streaming accumulation and metadata mapping for tools and reasoning. For a dedicated guide with recipes and reusable code patterns, see: - [Provider extensions (tools + reasoning)](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/provider-extensions/index.md) Extensions can: - read and update `state["partial"]` during streaming - attach provider-native fields to the final message during finalize - surface provider-native fields into core `metadata` via `from_native_messages` Example required-tags gating: ``` class MyToolsExtension(ProviderExtensionPlugin): def required_tags(self): return ["supports_tools"] ``` ### Tools (schema injection) Tools run before the provider initializes; tool schemas are injected into provider config under `config["tools"]`. Provider-side payload merge example: ``` tools = state.get("config", {}).get("tools") if tools: payload = {**payload, "tools": tools, "tool_choice": "auto"} ``` See ordering details: [Execution order](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/plugin-execution-order/index.md). ______________________________________________________________________ ## Deep dive: testing provider plugins A good development loop is: - implement one method - run a focused test - iterate Minimum recommended provider test suite: 1. non-streaming `send_request` smoke test 1. streaming `send_request_stream` smoke test 1. native retention across two turns (preferably across two AgentCore instances) 1. extension/feature hook produces observable change Example streaming smoke skeleton: ``` def test_streaming_smoke(): partial = "" final = None for e in core.send_request_stream(session, cfg): if e["type"] == "partial": partial += e["message"].get("content", "") elif e["type"] == "final": final = e assert final is not None ``` Tests to copy patterns from: - Offline unit tests (always run): - `plugins/template-python-provider/tests/test_provider_skeleton_unit.py` - `plugins/template-python-provider/tests/test_extension_patterns_unit.py` - Integration scaffolds (run with `pytest -m integration`; `xfail` by default until implemented): - `plugins/template-python-provider/tests/test_provider_integration_basic.py` - `plugins/template-python-provider/tests/test_provider_integration_reasoning.py` - `plugins/template-python-provider/tests/test_provider_integration_tools.py` - `plugins/template-python-provider/tests/test_provider_integration_tools_reasoning.py` - `plugins/template-python-provider/tests/test_provider_integration_usage.py` - `plugins/template-python-provider/tests/test_agent_application_integration_tools_e2e.py` - Native retention regression coverage: - `core/python/tests/test_native_retention_across_core_instances.py` ______________________________________________________________________ ## Reference implementations Providers: - `core/python/plugins/openai_provider.py` - `plugins/openrouter/src/openrouter_plugins/openrouter_provider.py` Provider extensions: - `core/python/plugins/openai_tools_extension.py` - `core/python/plugins/ollama_thinking_extension.py` Native retention tests: - `core/python/tests/test_native_retention_across_core_instances.py` # Provider extensions (tools + reasoning) Provider extensions (`ProviderExtensionPlugin`) are the recommended way to add **tool calling** and **reasoning/thinking** support to a provider without bloating the provider itself. This page is a focused companion to [`plugins/docs/provider-plugins.md`](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/provider-plugins/index.md). It documents the runtime contracts that the tool loop and UIs rely on, and provides clean, copyable recipes. Runtime references (source of truth): - Protocols/docstrings: `core/python/agent_core/types.py` - Provider wrapper + hot path: `core/python/agent_core/plugin/provider.py` - Extension wrapper + defaults: `core/python/agent_core/plugin/extension.py`, `core/python/agent_core/plugin/adapters.py` - Tool loop expectations: `core/python/agent_app/tool_loop.py` - Terminal rendering expectations: `application/python/agent_terminal_app/event_rendering.py` Reference implementations: - Tools: `core/python/plugins/openai_tools_extension.py` - Reasoning/thinking: `core/python/plugins/ollama_thinking_extension.py` See also: - [Tool interop flow](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/tool-interop/index.md) ______________________________________________________________________ ## What extensions are for Provider extensions run in the provider’s language and share the provider’s `state: dict`. Typical responsibilities: - **Request decoration:** add fields to `state["request"]["payload"]` (tools schemas, reasoning knobs, etc.). - **Hot-path accumulation:** during streaming, accumulate deltas into the shared accumulator `state["partial"]`. - **Interop mapping:** map provider-native fields ↔ core `message["metadata"]` so apps and the tool loop can work. ______________________________________________________________________ ## Enabling extensions with tags Extensions can declare dependencies using `required_tags()`. Those tags are computed per-request from: - the provider’s `get_tags(config, models)`, and - tags contributed by enabled extensions/features/tools. If `required_tags()` is not satisfied, the extension is not enabled and its hooks will not run. Runtime reference: `core/python/agent_core/core.py` (`_resolve_plugins_for_config`). Example (hypothetical OpenAI-compatible reasoning extension): ``` from __future__ import annotations from typing import Any from agent_core.types import ProviderExtensionPlugin class MyReasoningExtension(ProviderExtensionPlugin): name = "my_reasoning" version = "0.1.0" def required_tags(self) -> list[str]: return ["provider:openai_compatible", "supports_reasoning"] def get_ui_elements( self, config: dict[str, Any], tags: list[str], models: list[dict[str, Any]], ) -> list[dict[str, Any]]: # This check is optional: if the tag is missing, the extension # should not have been enabled. if "supports_reasoning" not in tags: return [] return [{"type": "checkbox", "key": "enable_reasoning", "label": "Enable reasoning"}] ``` ______________________________________________________________________ ## Runtime interop contracts ### Canonical core metadata keys Apps and the application-layer tool loop rely on these keys on **core messages**. Assistant messages: - `metadata["tool_calls"]`: list of tool calls in any format supported by the active tool interop accessors - `metadata["reasoning"]`: reasoning/thinking text (optional) - `metadata["reasoning_details"]`: provider-native detail blocks (optional; may be large) Tool messages: - `metadata["tool_call_id"]`: id linking tool output back to an assistant tool call - `metadata["tool_name"]`: tool name (produced by core tools) - `metadata["display"]`: optional display payload for UIs (produced by tools) ### Tool call shape The generic tool loop no longer assumes a single canonical tool-call shape. Core inspects tool calls via the active tool interop registry and extracts a semantic view consisting of: - tool name, when available - tool call id, when available - final payload value - payload kind (for example `object` or `text`) - optional payload format and metadata OpenAI-style chat-completions function calls remain a common and well-supported shape: ``` tool_calls = [ { "id": "call_1", "type": "function", "function": {"name": "read_file", "arguments": "{\"file_path\": \"README.md\"}"}, } ] ``` Responses-native custom/freeform calls are also valid when the active accessors support them: ``` tool_calls = [ { "type": "custom_tool_call", "call_id": "call_patch_1", "name": "apply_patch", "input": "*** Begin Patch\n*** End Patch", } ] ``` Provider/extension authors should preserve whichever tool-call shape they actually receive or emit rather than forcing everything into OpenAI chat format unless a concrete target API requires that conversion. ### Tool call sanitization When LLMs return corrupted JSON in tool call `arguments` fields, the corrupted data can be stored in history and cause subsequent API calls to fail. The tool interop system provides sanitization to prevent this. #### `ToolCallAccessor.sanitize_call(call)` Each accessor implements `sanitize_call(call: JsonObject) -> JsonObject` that returns a sanitized copy of the tool call with valid arguments: ``` class OpenAIChatToolCallAccessor: def sanitize_call(self, call: JsonObject) -> JsonObject: """Return a sanitized copy with valid JSON arguments. If arguments contain invalid JSON, returns a copy with arguments set to "{}". Otherwise returns the call unchanged. """ fn = call.get("function") if not isinstance(fn, dict): return call arguments = fn.get("arguments") if isinstance(arguments, str): try: json.loads(arguments) except (json.JSONDecodeError, Exception): return { **call, "function": {**fn, "arguments": "{}"}, } return call ``` For custom tool calls with text payloads (not JSON), `sanitize_call` returns the call unchanged: ``` class OpenAIResponsesCustomToolCallAccessor: def sanitize_call(self, call: JsonObject) -> JsonObject: # Text payload, no JSON sanitization needed return call ``` #### `ToolInteropRegistry.sanitize_tool_call(call)` The registry provides a convenience method that delegates to the appropriate accessor: ``` sanitized_call = registry.sanitize_tool_call(corrupted_call) ``` If the call format is not recognized, the call is returned unchanged. #### When to sanitize Provider extensions should sanitize tool calls in their `finalize` method before they are stored in history: ``` from agent_core.tool_interop import get_registry_from_context class OpenAICompatibleToolsExtension(ProviderExtensionPlugin): # ... other methods ... def finalize( self, final_messages: list[dict[str, Any]], native_messages: list[dict[str, Any]], state: dict[str, Any], *, context: dict[str, Any] | None = None, ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], dict[str, Any]]: """Sanitize tool_calls before storing in history.""" partial = state.get("partial") if isinstance(partial, dict): tool_calls = partial.get("tool_calls") if isinstance(tool_calls, list): registry = get_registry_from_context(context) sanitized_calls = [ registry.sanitize_tool_call(tc) for tc in tool_calls if isinstance(tc, dict) ] partial["tool_calls"] = sanitized_calls return final_messages, native_messages, state ``` This ensures that corrupted JSON arguments are replaced with valid empty JSON objects (`"{}"`) before being stored in native history and sent back to the API. ______________________________________________________________________ ## Lifecycle: what each hook is for Practical guidance (not exhaustive): - `init(config, state) -> state` - Store derived config into shared state. Keep it small and serializable. - `initialize_request(native_messages, state) -> (native_messages, state)` - Per-turn boundary. The provider usually builds `state["request"]`; extensions typically *modify* its payload. - `process_chunk(native_chunk, partial_messages, final_messages, native_messages, state) -> (...)` - Hot path for streaming. Keep work minimal. Prefer updating `state["partial"]` (the shared accumulator). - `finalize(final_messages, native_messages, state) -> (...)` - Cold path after provider finalize. Useful for last-mile normalization. - `to_native_messages(messages, native_messages)` / `from_native_messages(native_messages, messages)` - Stateless mapping between provider-native fields and core metadata. - Must handle both full-history conversions and **single-message lists** (e.g. `AgentCore.add_message`). ______________________________________________________________________ ## Shared streaming accumulator: `state["partial"]` The default streaming pattern in this repo uses a shared accumulator: - Providers build the assistant message over time in `state["partial"]`. - Extensions add their own accumulated fields into the same dict (`tool_calls`, `reasoning`, `usage`, etc.). Critical interop rule: - Providers must merge deltas in a way that **preserves unknown keys** already present in the accumulator. - Extensions must update `state["partial"]` by **merging**, not by replacing it. Provider-side merge pattern (the important part is `**current`): ``` def merge_partial(current: dict, delta: dict) -> dict: return { **current, # preserves keys written by extensions "role": delta.get("role", current.get("role", "assistant")), "content": (current.get("content") or "") + (delta.get("content") or ""), } ``` ______________________________________________________________________ ## Generic accumulator pattern for extensions (provider-style) This mirrors the provider “accumulator” streaming path from the provider docs. In the template, these helpers live in `plugins/template-python-provider/src/template_python_provider/extension.py`. ``` from __future__ import annotations from typing import Any, Iterable def _merge_delta_content( current: dict[str, Any], delta: dict[str, Any], *, override: Iterable[str] = (), accumulate: Iterable[str] = (), ) -> dict[str, Any]: return { **current, **{k: delta.get(k, current.get(k, "")) for k in override}, **{k: (current.get(k) or "") + (delta.get(k) or "") for k in accumulate}, } class AccumulatorExtensionMixin: """Generic process_chunk that delegates extract_delta + process_delta.""" def extract_delta(self, native_chunk: dict[str, Any]) -> dict[str, Any]: # pragma: no cover raise NotImplementedError def process_delta( self, delta: dict[str, Any], accumulated: dict[str, Any] | None, ) -> tuple[dict[str, Any] | None, dict[str, Any]]: # pragma: no cover raise NotImplementedError def process_chunk( self, native_chunk: dict[str, Any] | None, partial_messages: list[dict[str, Any]], final_messages: list[dict[str, Any]], native_messages: list[dict[str, Any]], state: dict[str, Any], ): delta = self.extract_delta(native_chunk or {}) acc = state.get("partial") if isinstance(state.get("partial"), dict) else {} patch, acc = self.process_delta(delta, acc) # Optional: patch the provider's current partial so streaming UIs can render it. new_partials = partial_messages if isinstance(patch, dict) and patch and partial_messages: new_partials = [*partial_messages[:-1], {**partial_messages[-1], **patch}] return new_partials, final_messages, list(native_messages), {**state, "partial": acc} ``` ______________________________________________________________________ ## Recipe: tools extension (OpenAI-compatible deltas) ### 1) Inject tool schemas into the request payload Tools are injected into provider config as `config["tools"]`. In `initialize_request`, merge them into `state["request"]["payload"]`: ``` tools = state.get("config", {}).get("tools") if tools: payload = {**payload, "tools": tools, "tool_choice": payload.get("tool_choice", "auto")} ``` ### 2) Accumulate streaming `delta.tool_calls` OpenAI-compatible streams send tool calls incrementally, keyed by `index`, with `function.arguments` arriving as string fragments. Helper functions (generic enough to copy): ``` from __future__ import annotations from typing import Any def _merge_shallow( current: dict[str, Any], delta: dict[str, Any], *, exclude: tuple[str, ...] = (), ) -> dict[str, Any]: return {**current, **{k: v for k, v in delta.items() if k not in exclude}} def _merge_tool_call(current: dict[str, Any], delta: dict[str, Any]) -> dict[str, Any]: out = _merge_shallow(current, delta, exclude=("index", "function")) func_delta = delta.get("function") if isinstance(func_delta, dict): func_current = out.get("function") func_current = func_current if isinstance(func_current, dict) else {} out["function"] = _merge_delta_content( func_current, func_delta, override=("name",), accumulate=("arguments",), ) return out def _merge_tool_calls(existing: list[dict[str, Any]], deltas: list[dict[str, Any]]) -> list[dict[str, Any]]: merged = [dict(tc) for tc in existing] for d in deltas: if not isinstance(d, dict): continue i = d.get("index") i = i if isinstance(i, int) and i >= 0 else len(merged) while len(merged) <= i: merged.append({}) merged[i] = _merge_tool_call(merged[i], d) return merged ``` ### 3) Full tools extension skeleton ``` from __future__ import annotations from typing import Any from agent_core.types import ProviderExtensionPlugin class OpenAICompatibleToolsExtension(AccumulatorExtensionMixin, ProviderExtensionPlugin): name = "my_tools" version = "0.1.0" def required_tags(self) -> list[str]: return ["provider:openai_compatible", "supports_tools"] def init(self, config: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]: tools = config.get("tools") return {**state, "_tools": tools if isinstance(tools, list) else []} def initialize_request(self, native_messages: list[dict[str, Any]], state: dict[str, Any]): tools = state.get("_tools") or [] if not tools: return native_messages, state req = state.get("request") or {} payload = dict(req.get("payload") or {}) payload |= {"tools": tools, "tool_choice": payload.get("tool_choice", "auto")} return native_messages, {**state, "request": {**req, "payload": payload}} def extract_delta(self, native_chunk: dict[str, Any]) -> dict[str, Any]: choice = (native_chunk.get("choices") or [{}])[0] return choice if isinstance(choice, dict) else {} def process_delta(self, delta: dict[str, Any], accumulated: dict[str, Any] | None): base = accumulated or {} d = delta.get("delta") or delta.get("message") or {} tool_deltas = d.get("tool_calls") if isinstance(d, dict) else None if not isinstance(tool_deltas, list) or not tool_deltas: return None, base existing = base.get("tool_calls") if isinstance(base.get("tool_calls"), list) else [] merged = _merge_tool_calls( existing=[tc for tc in existing if isinstance(tc, dict)], deltas=[tc for tc in tool_deltas if isinstance(tc, dict)], ) new_acc = {**base, "tool_calls": merged} return {"tool_calls": merged}, new_acc def to_native_messages(self, messages: list[dict[str, Any]], native_messages: list[dict[str, Any]]): if len(messages) != len(native_messages): return native_messages out: list[dict[str, Any]] = [] for msg, nm in zip(messages, native_messages): nm = dict(nm) md = msg.get("metadata") or {} if msg.get("role") == "assistant" and isinstance(md.get("tool_calls"), list): nm["tool_calls"] = md["tool_calls"] if msg.get("role") == "tool" and isinstance(md.get("tool_call_id"), str): nm["tool_call_id"] = md["tool_call_id"] out.append(nm) return out def from_native_messages(self, native_messages: list[dict[str, Any]], messages: list[dict[str, Any]]): if len(messages) != len(native_messages): return messages out: list[dict[str, Any]] = [] for msg, nm in zip(messages, native_messages): md = dict(msg.get("metadata") or {}) if isinstance(nm.get("tool_calls"), list) and nm["tool_calls"]: md["tool_calls"] = nm["tool_calls"] if isinstance(nm.get("tool_call_id"), str) and nm["tool_call_id"]: md.setdefault("tool_call_id", nm["tool_call_id"]) out.append({**msg, "metadata": md}) return out ``` For providers that accept multiple tool schema formats, prefer using the active tool interop runtime context plus the provider / extension `accepted_tool_schema_formats(...)` hooks rather than hardcoding one source schema format. In `initialize_request(...)`, use `context["tool_interop"]["registry"]` and `context["tool_interop"]["schema_target_formats"]` so core-owned, request-specific interop contributions are respected. That allows: - already-native schemas to pass through unchanged - explicit adapters to be additive rather than exclusive - custom/freeform tool schemas to coexist with legacy function schemas ______________________________________________________________________ ## Recipe: reasoning/thinking extension (OpenAI-compatible deltas) OpenAI-compatible chunks may carry a `delta.reasoning` string fragment. Note on request options: - Some OpenAI-compatible servers only emit reasoning/thinking deltas when a provider-specific request option is enabled. - Because the request schema is not fully standardized across OpenAI-compatible implementations, prefer a conservative pattern: - accept an explicit provider-specific config knob (for example Ollama uses `config["think"]: bool`) and - only add the corresponding request payload field when that knob is enabled. Skeleton: ``` from __future__ import annotations from typing import Any from agent_core.types import ProviderExtensionPlugin class OpenAICompatibleReasoningExtension(AccumulatorExtensionMixin, ProviderExtensionPlugin): name = "my_reasoning" version = "0.1.0" def required_tags(self) -> list[str]: return ["provider:openai_compatible", "supports_thinking"] def get_config_schema(self) -> dict[str, Any]: # Keep the knob provider-specific. For Ollama's OpenAI-compatible # endpoint, this is commonly `think: true`. return {"think": {"type": "boolean", "required": False, "default": False}} def initialize_request(self, native_messages: list[dict[str, Any]], state: dict[str, Any]): if not state.get("config", {}).get("think"): return native_messages, state req = state.get("request") or {} payload = dict(req.get("payload") or {}) payload["think"] = True return native_messages, {**state, "request": {**req, "payload": payload}} def extract_delta(self, native_chunk: dict[str, Any]) -> dict[str, Any]: choice = (native_chunk.get("choices") or [{}])[0] return choice if isinstance(choice, dict) else {} def process_delta(self, delta: dict[str, Any], accumulated: dict[str, Any] | None): base = accumulated or {} d = delta.get("delta") or delta.get("message") or {} frag = d.get("reasoning") if isinstance(d, dict) else None if not isinstance(frag, str) or not frag: return None, base new_acc = _merge_delta_content(base, {"reasoning": frag}, accumulate=("reasoning",)) return {"reasoning": frag}, new_acc def from_native_messages(self, native_messages: list[dict[str, Any]], messages: list[dict[str, Any]]): if len(messages) != len(native_messages): return messages out: list[dict[str, Any]] = [] for msg, nm in zip(messages, native_messages): md = dict(msg.get("metadata") or {}) reasoning = nm.get("reasoning") if isinstance(reasoning, str) and reasoning: md["reasoning"] = reasoning out.append({**msg, "metadata": md}) return out ``` ### Optional: `reasoning_details` If your provider emits structured `reasoning_details` (sometimes streamed), preserve it on assistant metadata as `metadata["reasoning_details"]`. Keep the merge rule conservative: - match entries by `id` when present - otherwise match by `(type, index)` - concatenate common string content fields When in doubt, prefer preserving provider-native blocks unchanged via native history retention. ______________________________________________________________________ ## Session actions Provider extensions can define session-scoped actions that operate on provider-native history. These actions are similar to feature plugin actions but run in the provider's context with access to shared provider state. ### Action definitions ``` def get_actions(self, state: Dict[str, Any]) -> List[Dict[str, Any]]: return [ { "id": "compact_native_history", "label": "Compact native history", "description": "Compress provider-native history for this session.", "inputs": { "instructions": {"type": "string", "required": False}, }, # Optional: trigger on lifecycle events "trigger": ["session_save_prepare"], } ] ``` ### execute_action signature ``` def execute_action( self, action_id: str, session: Session, native_messages: List[Dict[str, Any]], params: Dict[str, Any], context: Optional[Dict[str, Any]], state: Dict[str, Any], ) -> Dict[str, Any]: """Execute a session-scoped provider-native action.""" ``` ### Return value contract Provider extension actions return: ``` { # Required: replacement native messages "native_messages": [...], # Full provider-native history # Optional: session metadata patch "session_metadata": { "last_compaction": "2025-01-15T10:30:00", }, # Optional: error information "error": {"type": "disabled", "message": "..."}, } ``` ______________________________________________________________________ ## Context in `execute_action` Provider extensions receive layered context that provides access to runtime capabilities. Caller-supplied context is additive only. Reserved keys owned by the context builders are not overridden; if a caller reuses one of those keys, the builder keeps the owned value and emits a warning. ### Layer 1: Core-level context (always present) ``` context = { "core": self, # AgentCore instance "config": config, # Current resolved config "trigger_source": "core", # Where action was triggered "session": session.to_dict(), # Serialized session "lifecycle": trigger, # Lifecycle trigger name (if applicable) } ``` | Key | Type | Description | | ----------------- | ----------- | ----------------------------------------------------------------------- | | `core` | `AgentCore` | Core instance for session/message operations | | `config` | `dict` | Current resolved request configuration | | `trigger_source` | `str` | Where action was triggered (`"core"`, `"application"`) | | `session` | `dict` | Serialized session | | `lifecycle` | `str` | Lifecycle trigger name (for lifecycle-triggered actions) | | `request_context` | `dict` | Request-initialized plugin context for action-style flows | | `request_runtime` | `dict` | Request runtime helpers (`provider`, `features`) for action-style flows | ### Layer 2: Application-level context (when called from AgentApplication) When called from `AgentApplication.execute_session_action()` or `AgentApplication.run_session_lifecycle()`: ``` context = { # Layer 1 (from core) "core": core, "config": effective_config, "trigger_source": "application", "session": session.to_dict(), # Layer 2 (from application) "app": self, # AgentApplication instance "application": self, # Alias "base_config": self._config, # Full base config } ``` ### Checking for capabilities Provider extensions should check for capabilities before using them: ``` def execute_action( self, action_id: str, session: Session, native_messages: List[Dict[str, Any]], params: Dict[str, Any], context: Optional[Dict[str, Any]], state: Dict[str, Any], ) -> Dict[str, Any]: # Core is always available core = context.get("core") config = context.get("config", {}) # Application is optional app = context.get("app") base_config = context.get("base_config", config) if app is not None: # Application-level capabilities available pass else: # Core-only mode pass ``` ______________________________________________________________________ ## Testing strategy Recommended layers: 1. Unit tests for reducers/mappers (offline) - `_merge_tool_calls` merges `function.arguments` fragments by index - reasoning accumulator concatenates fragments - mapping to/from core metadata is stable 2. Integration scaffolds (networked; optional) - Use the template scaffolding in `plugins/template-python-provider/tests/` - For a comprehensive real-world suite, see `plugins/openrouter/tests/` # Feature plugins Feature plugins (`FeaturePlugin`) operate at the core layer and can participate in message transformation, lifecycle hooks, and session-scoped actions. They receive layered context that provides core capabilities at baseline, with optional application-level enhancements when called from `AgentApplication`. This page documents FeaturePlugin capabilities, context structure, and when to use them versus other plugin types. Runtime references (source of truth): - Protocol: `core/python/agent_core/types.py` (`FeaturePlugin` class) - Core context enrichment: `core/python/agent_core/core.py` (`execute_session_action`, `execute_lifecycle_actions`) - Application context enrichment: `core/python/agent_app/application_future.py` (`_build_lifecycle_context`) Reference implementations: - `plugins/gemini-compaction-feature/src/gemini_compaction_feature/__init__.py` ______________________________________________________________________ ## What feature plugins are for Feature plugins are ideal for features that need: - **Message transformation**: Modify `native_messages` during request processing - **Session-scoped actions**: Execute actions on a single session's native history - **Lifecycle participation**: React to session creation, save, request events - **Provider-agnostic behavior**: Work with any provider's native message format - **Core-level operation**: Access to `AgentCore` for session/message operations Feature plugins **cannot** directly: - Persist sessions (return values only) - Create or delete sessions - Publish events - Switch agents - Coordinate multiple sessions For these capabilities, use [Application plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/application-plugins/index.md) instead. ______________________________________________________________________ ## Capabilities ### Core capabilities (Layer 1 - always available) | Capability | Method/Access | Description | | ------------------ | ----------------------------------------------- | ------------------------------ | | Session operations | `core.slice_session()` | Create temporary sessions | | Message operations | `core.add_message()` | Add messages to sessions | | LLM requests | `core.send_request()` / `core.stream_request()` | Send LLM requests (same agent) | | Native messages | `native_messages` parameter | Access provider-native history | | Session info | `session` parameter | Access current session object | | Config access | `context["config"]` | Current resolved config | ### Application capabilities (Layer 2 - when called from AgentApplication) | Capability | Method/Access | Description | | ----------------- | ------------------------------------- | ------------------------------------ | | Agent switching | `app.update_agent(agent_id, session)` | Switch to a different agent | | Full LLM requests | `app.send_request(...)` | Full request with tool loop | | Base config | `context["base_config"]` | Full application config (all agents) | | Session assets | `context.get("session_asset_store")` | Access file attachments | ______________________________________________________________________ ## Protocol ``` class FeaturePlugin(BasePlugin, Protocol): # Identity (required) name: str version: str priority: int = 100 # Execution order (lower runs first) # Configuration def get_config_schema(self) -> Dict[str, Any]: """Return JSON schema for plugin configuration.""" def get_ui_elements( self, config: Dict[str, Any], tags: List[str], models: List[Dict[str, Any]], ) -> List[Dict[str, Any]]: """Return UI element definitions.""" # Lifecycle def init(self, config: Dict[str, Any], state: Dict[str, Any]) -> Dict[str, Any]: """Initialize with config and shared provider state.""" def initialize_request( self, native_messages: List[Dict[str, Any]], state: Dict[str, Any] ) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: """Prepare for a new request (optional).""" # Message transformation (stateless) def to_native_messages( self, messages: List[Dict[str, Any]], native_messages: List[Dict[str, Any]] ) -> List[Dict[str, Any]]: """Transform core messages to native (optional).""" def from_native_messages( self, native_messages: List[Dict[str, Any]], messages: List[Dict[str, Any]] ) -> List[Dict[str, Any]]: """Transform native messages to core (optional).""" # Finalization def finalize( self, final_messages: List[Dict[str, Any]], native_messages: List[Dict[str, Any]], state: Dict[str, Any], ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]: """Final processing after streaming (optional).""" # Actions def get_actions(self, state: Dict[str, Any]) -> List[Dict[str, Any]]: """Return session-scoped action definitions.""" def execute_action( self, action_id: str, session: Session, native_messages: List[Dict[str, Any]], params: Dict[str, Any], context: Optional[Dict[str, Any]], state: Dict[str, Any], ) -> Dict[str, Any]: """Execute a session-scoped action.""" # Capabilities def get_tags(self, config: Dict[str, Any], models: List[Dict[str, Any]]) -> List[str]: """Return capability tags contributed by this feature.""" def required_tags(self) -> List[str]: """Tags required for this feature to be enabled.""" def forbidden_tags(self) -> List[str]: """Tags that disable this feature.""" def is_enabled( self, config: Dict[str, Any], tags: List[str], models: List[Dict[str, Any]], context: Dict[str, Any] ) -> Optional[bool]: """Complex enablement logic (optional).""" # Templates and completions (optional) def get_template(self, config: Dict[str, Any]) -> str: """Return a template string for applications.""" def get_completions(self, config: Dict[str, Any], text: str) -> List[Dict[str, Any]]: """Return completion suggestions.""" def apply_completion(self, config: Dict[str, Any], text: str, completion: Dict[str, Any]) -> str: """Transform an accepted completion.""" ``` ______________________________________________________________________ ## Layered context in `execute_action` Feature plugins receive layered context that provides different capabilities depending on where the action is triggered. Caller-supplied context is additive only. Reserved keys owned by the context builders are not overridden; if a caller reuses one of those keys, the builder keeps the owned value and emits a warning. ### Layer 1: Core-level context (always present) When called from `AgentCore.execute_session_action()` or `AgentCore.execute_lifecycle_actions()`: ``` context = { "core": self, # AgentCore instance "config": config, # Current resolved config "trigger_source": "core", # Where action was triggered "session": session.to_dict(), # Serialized session "lifecycle": trigger, # Lifecycle trigger name (if applicable) } ``` | Key | Type | Description | | ----------------- | ----------- | ----------------------------------------------------------------------- | | `core` | `AgentCore` | Core instance for session/message operations | | `config` | `dict` | Current resolved request configuration | | `trigger_source` | `str` | Where action was triggered (`"core"`, `"application"`) | | `session` | `dict` | Serialized session (`session.to_dict()`) | | `lifecycle` | `str` | Lifecycle trigger name (for lifecycle-triggered actions) | | `request_context` | `dict` | Request-initialized plugin context for action-style flows | | `request_runtime` | `dict` | Request runtime helpers (`provider`, `features`) for action-style flows | ### Layer 2: Application-level context (when called from AgentApplication) When called from `AgentApplication.execute_session_action()` or `AgentApplication.run_session_lifecycle()`: ``` context = { # Layer 1 (from core) "core": core, "config": effective_config, "trigger_source": "application", "session": session.to_dict(), "lifecycle": lifecycle, # if applicable # Layer 2 (from application) "app": self, # AgentApplication instance "application": self, # Alias for "app" "base_config": self._config, # Full base config (all agents) "session_asset_store": self._session_asset_store, # File attachment store } ``` | Key | Type | Description | | --------------------- | ------------------- | ---------------------------------------------- | | `app` / `application` | `AgentApplication` | Application instance for enhanced capabilities | | `base_config` | `dict` | Full application configuration (all agents) | | `session_asset_store` | `SessionAssetStore` | File attachment store (optional) | ### Layer 3: Terminal/Server-level context (implementation-specific) Terminal or HTTP server implementations may add additional context keys: ``` # TerminalApplication may add: context["terminal"] = self # HTTP server may add: context["server"] = self context["request_id"] = request_id ``` These keys are implementation-specific and should be documented by the respective applications. ______________________________________________________________________ ## Checking for capabilities Feature plugins should gracefully handle missing capabilities by checking context: ``` def execute_action( self, action_id: str, session: Session, native_messages: List[Dict[str, Any]], params: Dict[str, Any], context: Optional[Dict[str, Any]], state: Dict[str, Any], ) -> Dict[str, Any]: # Core is always available core = context["core"] config = context["config"] # Application is optional (check before using) app = context.get("app") base_config = context.get("base_config", config) if app is not None: # Application-level capabilities available # Can switch agents, use full request flow pass else: # Core-only mode # Use core.send_request() for same-agent requests pass ``` ### Note on config resolution The `config` key in context is the **resolved request configuration** for the current agent, passed to `execute_session_action()`. When called from `AgentApplication`, this is the result of `resolve_request_config(base_config, session_overrides)`. The `base_config` key (when present) is the **full flattened agent configuration** from `build_agent_config()`. This includes all agent-level settings like `compaction`, `reasoning`, etc. When implementing feature plugins that access agent-level configuration: ``` # Correct: Use base_config for agent-level settings compaction_cfg = base_config.get("compaction") or {} # Fallback to config when base_config is not available (core-only calls) if not compaction_cfg and "compaction" in config: compaction_cfg = config.get("compaction") or {} ``` ### Error handling pattern Feature plugins should return error dictionaries rather than raising exceptions, allowing the caller to decide how to handle errors: ``` def execute_action( self, action_id: str, session: Session, native_messages: List[Dict[str, Any]], params: Dict[str, Any], context: Optional[Dict[str, Any]], state: Dict[str, Any], ) -> Dict[str, Any]: # Validate context if not isinstance(context, dict): return { "native_messages": native_messages, "error": {"type": "invalid_context", "message": "Context is required"}, } core = context.get("core") if core is None: return { "native_messages": native_messages, "error": {"type": "missing_core", "message": "Context must include 'core'"}, } # Check enablement base_config = context.get("base_config", context.get("config", {})) if not self._is_enabled(base_config): return { "native_messages": native_messages, "error": {"type": "disabled", "message": "Feature is not enabled."}, } # ... rest of implementation ``` This pattern differs from Application plugins, which typically raise exceptions for validation errors. The difference exists because feature plugins operate at the core layer where errors should be returned as part of the result dictionary, allowing the core to properly handle native_messages updates even in error cases. ______________________________________________________________________ ## Return value contract Feature plugin `execute_action()` returns a dictionary with: ``` { # Required: replacement native messages "native_messages": [...], # Full provider-native history # Optional: session metadata patch "session_metadata": { "last_compaction": "2025-01-15T10:30:00", "compaction_count": 5, }, # Optional: error information "error": { "type": "disabled", "message": "Feature is not enabled for this agent.", }, # Optional: status "status": "ok", # or "error", "noop" "message": "Operation completed successfully.", # Optional: debug information "debug_info": {...}, } ``` The `native_messages` field is required and should contain the full provider-native message history after the action is applied. The `session_metadata` field is merged into `session.metadata`. For the core-owned `response_finalize` lifecycle, actions may also return: ``` { "final_messages": [...], # replacement core final messages for the current turn } ``` This lifecycle runs after provider/features finish native finalization and after native-to-core conversion, but before the final payload is emitted. ______________________________________________________________________ ## Action definitions Feature plugins define session-scoped actions via `get_actions()`: ``` def get_actions(self, state: Dict[str, Any]) -> List[Dict[str, Any]]: return [ { "id": "compact_range", "label": "Compact range", "description": "Compact messages into a state snapshot.", "inputs": { "start": {"type": "integer", "required": False}, "end": {"type": "integer", "required": False}, "instructions": {"type": "string", "required": False}, }, # Optional: lifecycle triggers "trigger": ["session_save_prepare"], } ] ``` Unlike application plugins, feature plugin actions: - Operate on a single session's native history - Return replacement `native_messages` instead of mutations - Cannot create/delete sessions or publish events ______________________________________________________________________ ## Example implementation ``` from datetime import datetime from typing import Any, Dict, List, Optional from agent_core import Message class MyFeaturePlugin: """Example feature plugin demonstrating key capabilities.""" name = "my_feature" version = "1.0.0" priority = 100 def __init__(self) -> None: self._state: Dict[str, Any] = {} def get_config_schema(self) -> Dict[str, Any]: return { "enabled": {"type": "boolean", "default": True}, "max_items": {"type": "integer", "default": 100}, } def init(self, config: Dict[str, Any], state: Dict[str, Any]) -> Dict[str, Any]: # Store config in state return {**state, "config": config} def get_tags(self, config: Dict[str, Any], models: List[Dict[str, Any]]) -> List[str]: # Contribute capability tags return ["my_feature_capable"] def required_tags(self) -> List[str]: # No required tags return [] def finalize( self, final_messages: List[Dict[str, Any]], native_messages: List[Dict[str, Any]], state: Dict[str, Any], ) -> tuple[list[Dict[str, Any]], list[Dict[str, Any]], Dict[str, Any]]: # Optional: modify final messages return final_messages, native_messages, state def get_actions(self, state: Dict[str, Any]) -> List[Dict[str, Any]]: return [ { "id": "summarize_range", "label": "Summarize range", "description": "Summarize a range of messages.", "inputs": { "start": {"type": "integer", "required": False}, "end": {"type": "integer", "required": False}, }, } ] def execute_action( self, action_id: str, session: Any, native_messages: List[Dict[str, Any]], params: Dict[str, Any], context: Optional[Dict[str, Any]], state: Dict[str, Any], ) -> Dict[str, Any]: if action_id != "summarize_range": return { "native_messages": native_messages, "error": {"type": "unknown_action", "message": f"Unknown action: {action_id}"}, } # Validate context if not isinstance(context, dict): return { "native_messages": native_messages, "error": {"type": "invalid_context", "message": "Context is required"}, } core = context.get("core") config = context.get("config", {}) base_config = context.get("base_config", config) if core is None: return { "native_messages": native_messages, "error": {"type": "missing_core", "message": "Context must include 'core'"}, } # Check enablement if not self._is_enabled(base_config): return { "native_messages": native_messages, "error": {"type": "disabled", "message": "Feature is not enabled."}, } # Get parameters start = params.get("start") end = params.get("end") # Perform action using core capabilities # ... # Return modified native_messages new_native_messages = self._apply_summarization(native_messages, start, end) return { "native_messages": new_native_messages, "session_metadata": { "last_summarization": datetime.now().isoformat(), }, } def _is_enabled(self, config: Dict[str, Any]) -> bool: feature_cfg = config.get("my_feature") or {} return feature_cfg.get("enabled", True) def _apply_summarization( self, native_messages: List[Dict[str, Any]], start: Optional[int], end: Optional[int], ) -> List[Dict[str, Any]]: # Implementation details... return native_messages ``` ______________________________________________________________________ ## Using application capabilities When operating at Layer 2 (called from `AgentApplication`), feature plugins can access enhanced capabilities: ``` def execute_action( self, action_id: str, session: Session, native_messages: List[Dict[str, Any]], params: Dict[str, Any], context: Optional[Dict[str, Any]], state: Dict[str, Any], ) -> Dict[str, Any]: core = context["core"] config = context["config"] app = context.get("app") base_config = context.get("base_config", config) # Check if we should use a different agent for summarization agent_id = base_config.get("summarization", {}).get("agent_id") if app is not None and agent_id: # Switch to dedicated summarization agent core_compact, base_config_compact, session_for_compact = app.update_agent( agent_id, session ) # Use app.send_request for full tool loop support events = app.send_request( core_compact, session_for_compact, base_config_compact, {}, stream=False, ) else: # Core-only: use same agent new_session, final_messages = core.send_request( session, config, ) # Process results... return {"native_messages": new_native_messages} ``` ______________________________________________________________________ ## UI elements Feature plugins can also contribute UI elements. The shape is similar to application plugins: ``` def get_ui_elements( self, config: Dict[str, Any], tags: List[str], models: List[Dict[str, Any]], ) -> List[Dict[str, Any]]: # Filter based on enablement if not self._is_enabled(config): return [] return [ { "ui_type": "session_action", "id": "summarize_range", "label": "Summarize range", "icon": "compress", "order": 45, "action_id": "summarize_range", "dialog": { "kind": "form", "title": "Summarize range", "inputs": [ {"name": "start", "type": "integer", "label": "Start"}, {"name": "end", "type": "integer", "label": "End"}, ], }, } ] ``` ______________________________________________________________________ ## When to use FeaturePlugin vs ApplicationPlugin | Use FeaturePlugin when | Use ApplicationPlugin when | | ----------------------------------------- | ----------------------------------- | | You operate on native messages | You need to persist sessions | | You want provider-agnostic behavior | You need multi-session coordination | | You want lightweight, stateless operation | You need to switch agents | | You participate in lifecycle hooks | You need to publish events | | You return modified messages only | You need UI effects/navigation | | You work at the core level | You need session locking | ______________________________________________________________________ ## See also - [Application plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/application-plugins/index.md) for application-level capabilities - [Provider extensions](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/provider-extensions/index.md) for hot-path streaming plugins - [Plugin actions](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/actions/index.md) for action and lifecycle documentation - [Provider plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/provider-plugins/index.md) for provider implementation # Application plugins Application plugins (`ApplicationPlugin`) operate at the application layer with full access to session persistence, multi-session coordination, and UI effects. They are the most powerful plugin type and are suitable for features that need application-level capabilities. This page documents ApplicationPlugin capabilities, context structure, and when to use them versus other plugin types. Runtime references (source of truth): - Protocol: `core/python/agent_app/app_plugins.py` - Application context: `core/python/agent_app/application_future.py` Reference implementations: - `plugins/gemini-compaction-app/src/gemini_compaction_app/__init__.py` ______________________________________________________________________ ## What application plugins are for Application plugins are ideal for features that need: - **Session persistence**: Load and save sessions to the session store - **Multi-session coordination**: Create, fork, delete, or coordinate multiple sessions - **LLM requests**: Send requests through the application layer with full tool loop support - **Session locking**: Acquire locks for safe concurrent session modifications - **Event publishing**: Publish events for UI updates or other subscribers - **Agent switching**: Switch a session from one agent to another - **UI effects**: Return navigation hints, reload requests, or other UI coordination ______________________________________________________________________ ## Capabilities | Capability | Method/Access | Description | | ------------------------- | ---------------------------------------------------- | ------------------------------------------------------------- | | Load sessions | `app.load_session(session_id)` | Load a session from the session store | | Save sessions | `app.save_session(session)` | Persist a session to the session store | | LLM requests | `app.send_request(core, session, config, overrides)` | Send an LLM request with tool loop | | Session locking | `app.acquire_session_lock(session_id)` | Context manager for safe concurrent access | | Event publishing | `app.publish_event(event)` | Publish events to subscribers | | Switch agents | `app.update_agent(agent_id, session)` | Switch a session to a different agent | | Full config access | `self._config` (via `init`) | Access to the full application configuration | | Runtime env access | `os.environ[...]` | Real process environment installed from config resolution env | | Request config resolution | `resolve_request_config(base_config, overrides)` | Resolve effective config for a request | ______________________________________________________________________ ## Protocol ``` class ApplicationPlugin(Protocol): # Identity (required) name: str version: str # Configuration def get_config_schema(self) -> Dict[str, Any]: """Return JSON schema for plugin configuration.""" def get_ui_elements( self, state: Dict[str, Any], config: Optional[Dict[str, Any]] = None, tags: Optional[List[str]] = None, models: Optional[List[Dict[str, Any]]] = None, ) -> List[Dict[str, Any]]: """Return UI element definitions.""" # Lifecycle def init(self, app_config: Dict[str, Any]) -> Dict[str, Any]: """Initialize plugin state from application config.""" def get_actions(self, state: Dict[str, Any]) -> List[Dict[str, Any]]: """Return action definitions.""" def execute_action( self, app: "AgentApplication", action_id: str, params: Dict[str, Any], context: Optional[Dict[str, Any]], state: Dict[str, Any], ) -> Dict[str, Any]: """Execute an action with validated parameters.""" ``` ______________________________________________________________________ ## Runtime env contract `AgentApplication` installs the same runtime env mapping used during config placeholder resolution into the real process environment before application plugins initialize. Example: ``` import os def init(self, app_config: Dict[str, Any]) -> Dict[str, Any]: config_dir = os.environ.get("CONFIG_DIR") return { "config_dir": config_dir, } ``` This runtime environment is intended for path derivation and subprocesses that need to inherit the same anchors used during `${env:...}` config resolution. Common keys include: - `CONFIG_DIR` - `WORKING_DIR` - `BUILTIN_PLUGINS` This is the preferred way for application plugins to anchor sidecar files such as cached metadata, auth credentials, or other application-owned state. ______________________________________________________________________ ## Context in `execute_action` Application plugins receive the `AgentApplication` instance as the first parameter to `execute_action`. This provides full access to application capabilities. Additional context is available via the `context` parameter: ``` def execute_action( self, app: "AgentApplication", action_id: str, params: Dict[str, Any], context: Optional[Dict[str, Any]], state: Dict[str, Any], ) -> Dict[str, Any]: # Application instance app.load_session(session_id) app.save_session(session) app.send_request(core, session, config, overrides) # Context (for lifecycle-triggered actions) lifecycle = context.get("lifecycle") # e.g., "session_create" trigger_source = context.get("trigger_source") # "application" session_dict = context.get("session") # Serialized session config = context.get("config") # Effective config base_config = context.get("base_config") # Full app config ``` ### Context keys | Key | Type | Description | | --------------------- | ------------------ | -------------------------------------------------------- | | `lifecycle` | `str` | Lifecycle trigger name (e.g., `"session_create"`) | | `trigger_source` | `str` | Where action was triggered (`"application"`, `"manual"`) | | `session` | `dict` | Serialized session (`session.to_dict()`) | | `config` | `dict` | Effective config for the current agent | | `app` / `application` | `AgentApplication` | Application instance | | `base_config` | `dict` | Full application configuration (all agents) | ______________________________________________________________________ ## Action definitions Application plugins define actions via `get_actions()`. Each action is a dictionary describing the action's interface: ``` def get_actions(self, state: Dict[str, Any]) -> List[Dict[str, Any]]: return [ { "id": "compact_range", "label": "Compact range", "description": "Compact messages into a state snapshot.", "inputs": { "session_id": {"type": "string", "required": True}, "start": {"type": "integer", "required": False}, "end": {"type": "integer", "required": False}, "instructions": {"type": "string", "required": False}, }, # Optional: lifecycle triggers "trigger": ["session_create", "request_prepare"], } ] ``` ### Lifecycle triggers Actions can include a `trigger` field to be automatically invoked during application lifecycle events: | Trigger | When it runs | | ------------------------ | ------------------------------ | | `session_create` | After a new session is created | | `session_save_prepare` | Before a session is saved | | `request_prepare` | Before an LLM request | | `request_complete` | After a successful request | | `request_error` | After a failed request | | `session_fork` | After a session is forked | | `agent_switch_prepare` | Before switching agents | | `agent_switch_complete` | After switching agents | | `session_delete_prepare` | Before a session is deleted | See [Plugin actions](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/actions/index.md) for detailed lifecycle documentation. ______________________________________________________________________ ## Return value contract Application plugin actions return a dictionary with the following structure: ``` { # Session mutations (optional) "mutations": { "created_session_ids": ["new-session-1"], "updated_session_ids": ["session-2"], "deleted_session_ids": ["session-3"], }, # UI effects (optional) "ui_effects": { "reload_session_ids": ["session-2"], "navigate": {"screen": "sessions"}, }, # Result message (optional) "message": "Operation completed successfully.", # Error information (for failures) "status": "error", "error_type": "disabled", "message": "Feature is not enabled.", # Plugin-specific data "debug": {...}, } ``` ______________________________________________________________________ ## UI elements Application plugins contribute UI elements via `get_ui_elements()`. The UI element shape differs from core plugins: ``` def get_ui_elements( self, state: Dict[str, Any], config: Optional[Dict[str, Any]] = None, tags: Optional[List[str]] = None, models: Optional[List[Dict[str, Any]]] = None, ) -> List[Dict[str, Any]]: return [ # Session action button { "ui_type": "session_action", "id": "compact_range", "label": "Compact range", "icon": "archive", "order": 45, "action_id": "compact_range", "fixed_params": {}, "param_map": { "session_id": "$session.session_id", "start": "$dialog.start", "end": "$dialog.end", }, "dialog": { "kind": "form", "title": "Compact range", "inputs": [...], }, }, # Message action button (appears on each message) { "ui_type": "message_action", "id": "compact_up_to_here", "label": "Compact up to here", "icon": "archive", "order": 25, "action_id": "compact_range", "fixed_params": {"start": 0, "end_inclusive": True}, "param_map": { "session_id": "$session.session_id", "end": "$message.index", }, }, ] ``` ______________________________________________________________________ ## Example implementation ``` from typing import Any, Dict, List, Optional, TYPE_CHECKING if TYPE_CHECKING: from agent_app.application_future import AgentApplication class MyApplicationPlugin: """Example application plugin demonstrating key capabilities.""" name = "my_app_plugin" version = "1.0.0" def __init__(self) -> None: self._state: Dict[str, Any] = {} def get_config_schema(self) -> Dict[str, Any]: return { "enabled": {"type": "boolean", "default": True}, "max_iterations": {"type": "integer", "default": 10}, } def get_ui_elements( self, state: Dict[str, Any], config: Optional[Dict[str, Any]] = None, tags: Optional[List[str]] = None, models: Optional[List[Dict[str, Any]]] = None, ) -> List[Dict[str, Any]]: # Filter based on enablement if config is provided if config is not None and not config.get("my_app_plugin", {}).get("enabled", True): return [] return [ { "ui_type": "session_action", "id": "my_action", "label": "My Action", "icon": "star", "order": 50, "action_id": "my_action", "dialog": {"kind": "form", "title": "My Action", "inputs": []}, } ] def init(self, app_config: Dict[str, Any]) -> Dict[str, Any]: # Store any application-level state return {"app_config": app_config} def get_actions(self, state: Dict[str, Any]) -> List[Dict[str, Any]]: return [ { "id": "my_action", "label": "My Action", "description": "Perform an application-level action.", "inputs": { "session_id": {"type": "string", "required": True}, }, # Optional: trigger on session creation "trigger": "session_create", } ] def execute_action( self, app: "AgentApplication", action_id: str, params: Dict[str, Any], context: Optional[Dict[str, Any]], state: Dict[str, Any], ) -> Dict[str, Any]: if action_id != "my_action": raise ValueError(f"Unknown action: {action_id}") session_id = params.get("session_id") if not session_id: raise ValueError("session_id is required") # Load session with lock with app.acquire_session_lock(session_id): ctx = app.load_session(session_id) if ctx is None: raise KeyError(f"Session {session_id} not found") core, base_config, session = ctx # Perform operations # ... your logic here ... # Save modified session app.save_session(session) # Publish event for UI update app.publish_event({ "type": "my_action_complete", "session_id": session_id, }) return { "mutations": { "updated_session_ids": [session_id], }, "ui_effects": { "reload_session_ids": [session_id], }, "message": "Action completed successfully.", } ``` ______________________________________________________________________ ## Pattern: Background auth flows Application plugins are the right place for minimal login flows that need to: - start a long-running process in the background - write credentials to disk under `CONFIG_DIR` - expose small manual actions such as `login_start`, `check_status`, `cancel_login`, and `logout` For cross-device login, keep persistence in the application plugin itself: 1. `login_start` begins the backend-side workflow and returns a URL/code. 1. A background worker polls or waits for completion. 1. The plugin writes credentials atomically when login succeeds. 1. `check_status` reports state only; it should not be responsible for saving credentials. Provider or feature plugins can then consume the saved file during request-time runtime setup. ______________________________________________________________________ ## When to use ApplicationPlugin vs FeaturePlugin | Use ApplicationPlugin when | Use FeaturePlugin when | | ----------------------------------- | ------------------------------------------ | | You need to persist sessions | You operate on native messages only | | You need multi-session coordination | You want provider-agnostic behavior | | You need to switch agents | You need lightweight, stateless operation | | You need to publish events | You want to work at the core level | | You need UI effects/navigation | You only need to return modified messages | | You need session locking | You want to participate in lifecycle hooks | See also: - [Feature plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/feature-plugins/index.md) for core-level plugin documentation - [Provider extensions](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/provider-extensions/index.md) for hot-path streaming plugins - [Plugin actions](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/actions/index.md) for action and lifecycle documentation # Tool plugins Tool plugins (`ToolPlugin`) expose callable tools to the model and execute the resulting tool calls inside the core/application tool loop. This guide is the canonical tool-authoring companion to the general overview in `docs/plugins/development.md`. Runtime references (source of truth): - Protocol/docstrings: `core/python/agent_core/types.py` - Tool wrapper: `core/python/agent_core/plugin/tool.py` - Defaulting/compatibility adapter: `core/python/agent_core/plugin/adapters.py` - Tool discovery + execution: `core/python/agent_core/core.py` - Application preview/tool loop: `core/python/agent_app/tool_loop.py` Reference implementations: - Simple object-payload tool: `core/python/plugins/file_reader_tool.py` - Template package: `plugins/template-python-tools/src/template_python_tools/echo_tool.py` - Payload-first + custom routing: `plugins/codex-tools/src/codex_tools/apply_patch_tool.py` - Streaming shell-style tools: `plugins/codex-tools/src/codex_tools/base_shell_tool.py` See also: - [Tool interop flow](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/tool-interop/index.md) - [Provider extensions (tools + reasoning)](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/provider-extensions/index.md) - [Node tool plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/node-tool-plugins/index.md) - [Bash tool plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/bash-tool-plugins/index.md) - [Packaging & loading plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/plugin-packaging-and-loading/index.md) - [Execution order (providers, extensions, features, tools)](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/plugin-execution-order/index.md) - [Configuration schema (`get_config_schema`)](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/config-schema/index.md) - [UI elements (`get_ui_elements` and `ui_type`)](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/ui-elements/index.md) - [Testing & validation](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/testing-and-validation/index.md) ______________________________________________________________________ ## What tool plugins are for Tool plugins are the right place for model-invoked operations such as: - file reads/writes - shell execution - structured calculators or search helpers - custom/freeform tools such as patch editors or REPLs Unlike providers, provider extensions, and features, tools do **not** share the provider state dict. Each tool manages its own independent `state: dict` between `init(...)`, optional `prepare(...)` / `prepare_async(...)`, `get_tool_schemas(...)`, `execute_tool(...)`, and related hooks. Minimal core message flow: 1. Tool runs `init(config)`. 1. Optionally, a caller runs `prepare(config, state)` or `prepare_async(...)`. 1. Tool returns one or more schemas from `get_tool_schemas(state, prepared=...)`. 1. Provider/provider extensions expose those schemas to the model. 1. Model emits tool-call objects. 1. Core inspects each tool call through the active interop registry. 1. Core routes the call to a tool. 1. Tool executes and returns a result dict. 1. Core formats the result into a `role="tool"` message. Example final tool message shape: ``` { "role": "tool", "content": "Result: 5", "metadata": { "tool_call_id": "call_1", "tool_name": "add", "tool_plugin": "calculator", "display": {"type": "text", "content": "Result: 5"}, }, } ``` ______________________________________________________________________ ## Quickstart / development workflow The fastest workflow is: 1. start from the Python tools template 1. implement one simple object-payload tool 1. test it through `AgentCore.execute_tool_calls(...)` 1. add previews/streaming/display formatting if needed 1. try it in a real application config via `path:` loading ### Step 0: start from the template package Recommended starting point: - `plugins/template-python-tools` Note: the current template still demonstrates the legacy object-payload style. That remains valid for classic function tools through the compatibility layer, but new tools can also adopt the payload-first signatures shown on this page. Typical repo layout: ``` my-tools/ pyproject.toml agent_plugin.json src/ my_tools/ __init__.py my_tool.py tests/ test_my_tool.py ``` Example `agent_plugin.json`: ``` { "entries": ["my_tools.my_tool.MyTool"], "subdirectory": "." } ``` More packaging details: [Packaging & loading plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/plugin-packaging-and-loading/index.md). ### Step 1: implement one small tool first This is the recommended first implementation pattern for new Python tools: ``` from __future__ import annotations from typing import Any from agent_core.types import ToolPlugin class EchoTool(ToolPlugin): name = "echo_tool" version = "0.1.0" def init(self, config: dict[str, Any]) -> dict[str, Any]: return {"config": config} def get_tool_schemas(self, state: dict[str, Any]) -> list[dict[str, Any]]: return [ { "type": "function", "function": { "name": "echo", "description": "Echo back the provided value.", "parameters": { "type": "object", "properties": { "value": {"type": "string"}, }, "required": ["value"], }, }, } ] def format_tool_call_preview( self, tool_name: str | None, payload: Any, state: dict[str, Any], *, payload_kind: str | None = None, payload_format: str | None = None, payload_metadata: dict[str, Any] | None = None, tool_call: dict[str, Any] | None = None, ) -> str: if tool_name != "echo" or not isinstance(payload, dict): return "" return f"echo value={payload.get('value')!r}" def execute_tool( self, tool_name: str | None, payload: Any, state: dict[str, Any], *, payload_kind: str | None = None, payload_format: str | None = None, payload_metadata: dict[str, Any] | None = None, tool_call: dict[str, Any] | None = None, ) -> dict[str, Any]: if tool_name != "echo": return {"success": False, "error": f"Unknown tool: {tool_name}"} if not isinstance(payload, dict): return {"success": False, "error": "payload must be an object"} value = payload.get("value") if not isinstance(value, str): return {"success": False, "error": "value must be a string"} return {"success": True, "result": value} def format_tool_result(self, result: dict[str, Any], state: dict[str, Any]) -> str: if result.get("success"): return str(result.get("result") or "") return f"Error: {result.get('error')}" ``` ### Step 2: write the first tests before adding complexity Quick-feedback test loop: ``` from agent_core import AgentCore def test_echo_tool_round_trip(): core = AgentCore() core.register_tool(EchoTool) tool_calls = [ { "id": "call_1", "type": "function", "function": { "name": "echo", "arguments": {"value": "hello"}, }, } ] results = core.execute_tool_calls(tool_calls, config={}) assert results[0]["role"] == "tool" assert results[0]["content"] == "hello" assert results[0]["metadata"]["tool_name"] == "echo" ``` Recommended command while iterating: ``` pytest plugins/my-tools/tests -q ``` ### Step 3: try the tool in a real application config After the first tests pass, load the tool package through the application layer. Example config fragment: ``` { "plugin_cache_dir": "~/.crystal/cache/plugins", "plugins": [ "plugins.openai_provider.OpenAICompatibleProvider", "path:../my-tools" ], "provider": "openai_compatible", "model": "gpt-4o-mini" } ``` That exercises the real request flow: - tool enablement - `get_tool_schemas(...)` - provider/provider-extension schema injection - model tool call generation - tool execution - tool message rendering in the terminal/app ______________________________________________________________________ ## Tool protocol The full protocol lives in `core/python/agent_core/types.py`. The most important tool hooks are: ``` class ToolPlugin(BasePlugin, Protocol): def init(self, config: dict[str, Any]) -> dict[str, Any]: ... def prepare( self, config: dict[str, Any], state: dict[str, Any], *, context: dict[str, Any] | None = None, ) -> dict[str, Any]: ... async def prepare_async( self, config: dict[str, Any], state: dict[str, Any], *, context: dict[str, Any] | None = None, ) -> dict[str, Any]: ... def get_tool_schemas( self, state: dict[str, Any], *, prepared: dict[str, Any] | None = None, ) -> list[dict[str, Any]]: ... def get_tool_interop_contribution( self, state: dict[str, Any], ) -> ToolInteropContribution: ... def can_handle_tool_call( self, tool_name: str | None, payload: Any, state: dict[str, Any], *, payload_kind: str | None = None, payload_format: str | None = None, payload_metadata: dict[str, Any] | None = None, tool_call: dict[str, Any] | None = None, tool_schema: dict[str, Any] | None = None, prepared: dict[str, Any] | None = None, ) -> bool | None: ... def execute_tool( self, tool_name: str | None, payload: Any, state: dict[str, Any], *, payload_kind: str | None = None, payload_format: str | None = None, payload_metadata: dict[str, Any] | None = None, tool_call: dict[str, Any] | None = None, prepared: dict[str, Any] | None = None, ) -> dict[str, Any]: ... async def execute_tool_async(...) -> dict[str, Any]: ... def format_tool_result(self, result: dict[str, Any], state: dict[str, Any]) -> Any: ... def format_tool_call_preview( self, tool_name: str | None, payload: Any, state: dict[str, Any], *, payload_kind: str | None = None, payload_format: str | None = None, payload_metadata: dict[str, Any] | None = None, tool_call: dict[str, Any] | None = None, prepared: dict[str, Any] | None = None, ) -> str: ... def stream_tool( self, tool_name: str | None, payload: Any, state: dict[str, Any], *, payload_kind: str | None = None, payload_format: str | None = None, payload_metadata: dict[str, Any] | None = None, tool_call: dict[str, Any] | None = None, prepared: dict[str, Any] | None = None, ) -> Iterator[dict[str, Any]]: ... async def stream_tool_async(...) -> AsyncIterator[dict[str, Any]]: ... def to_display_format( self, text: str, result: dict[str, Any], state: dict[str, Any], ) -> dict[str, Any]: ... ``` Additional common hooks: - `get_config_schema(...)` - `get_ui_elements(...)` - `get_tags(...)` - `required_tags()` / `forbidden_tags()` - `is_enabled(config, tags, models, context)` ### Recommended mental model - `get_tool_schemas(...)` describes what the model can call - `can_handle_tool_call(...)` optionally claims responsibility for a call - `execute_tool(...)` performs the work and returns a result dict - `format_tool_result(...)` produces the string or explicit provider-native envelope the model sees - `to_display_format(...)` produces richer UI-only output for humans ______________________________________________________________________ ## Tool schemas and interop ### Tools are not limited to one schema format `get_tool_schemas(...)` may return tool schemas in **any format understood by the active interop registry**. Common case: OpenAI-style function schema. ``` def get_tool_schemas(self, state: dict[str, Any]) -> list[dict[str, Any]]: return [ { "type": "function", "function": { "name": "read_file", "description": "Read a file from disk.", "parameters": { "type": "object", "properties": { "file_path": {"type": "string"}, }, "required": ["file_path"], }, }, } ] ``` Custom/freeform example: ``` def get_tool_schemas(self, state: dict[str, Any]) -> list[dict[str, Any]]: return [ { "type": "custom", "name": "apply_patch", "description": "Apply a freeform patch.", "format": { "type": "grammar", "syntax": "lark", "definition": "start: /.+/", }, } ] ``` ### Choosing among multiple schema variants Some tools expose different schema variants depending on provider capabilities. The tool init config may include `_tool_schema_target_formats`, which is a hint from the active provider/extensions about which schema formats they can send. Example pattern: ``` def get_tool_schemas(self, state: dict[str, Any]) -> list[dict[str, Any]]: config = state.get("config") or {} targets = set(config.get("_tool_schema_target_formats") or []) if "openai.responses.custom.tool_schema" in targets: return [{ "type": "custom", "name": "apply_patch", "description": "Apply a freeform patch.", "format": {"type": "grammar", "syntax": "lark", "definition": "start: /.+/"}, }] return [{ "type": "function", "function": { "name": "apply_patch", "parameters": { "type": "object", "properties": {"input": {"type": "string"}}, "required": ["input"], "additionalProperties": False, }, }, }] ``` ### Advanced: contribute custom accessors/adapters Most tools do **not** need this. Use it only when your tool emits a schema or call format that built-in/provider-contributed interop cannot already inspect. Example shape: ``` from agent_core.tool_interop import ToolInteropContribution def get_tool_interop_contribution(self, state: dict[str, Any]) -> ToolInteropContribution: return ToolInteropContribution( schema_accessors=(MyCustomSchemaAccessor(),), call_accessors=(MyCustomToolCallAccessor(),), ) ``` Important behavior: - explicit/config-provided contributions are tried first - provider / extension / tool contributions are tried next - built-in defaults are used as fallback That means tool-level contributions are additive, not all-or-nothing. ______________________________________________________________________ ## Routing and execution ### Payload-first execution Core no longer assumes every tool call is a JSON object of `arguments`. Instead, the active call accessor extracts: - `tool_name`, when available - `payload`, the final payload object - `payload_kind`, such as `object` or `text` - `payload_format`, when available - `payload_metadata`, when available Example object payload tool: ``` def execute_tool( self, tool_name: str | None, payload: Any, state: dict[str, Any], *, payload_kind: str | None = None, payload_format: str | None = None, payload_metadata: dict[str, Any] | None = None, tool_call: dict[str, Any] | None = None, ) -> dict[str, Any]: if tool_name != "add" or not isinstance(payload, dict): return {"success": False, "error": "unsupported call"} return { "success": True, "result": float(payload.get("a", 0)) + float(payload.get("b", 0)), } ``` Example text payload tool: ``` def execute_tool( self, tool_name: str | None, payload: Any, state: dict[str, Any], *, payload_kind: str | None = None, payload_format: str | None = None, payload_metadata: dict[str, Any] | None = None, tool_call: dict[str, Any] | None = None, ) -> dict[str, Any]: if tool_name not in {None, "apply_patch"}: return {"success": False, "error": f"Unknown tool: {tool_name}"} if payload_kind != "text" or not isinstance(payload, str) or not payload: return {"success": False, "error": "expected raw patch text"} return {"success": True, "result": f"received {len(payload)} bytes"} ``` ### Optional routing with `can_handle_tool_call(...)` Core asks each tool `can_handle_tool_call(...)` before falling back to legacy name-based matching. Returning: - `True` claims the call - `False` explicitly rejects it - `None` means “no opinion; try legacy fallback” This is useful when: - the tool name is optional or absent - multiple schemas share the same tool name - routing depends on payload kind/format rather than name alone Today, `tool_call` is the most useful advanced input for this hook. The `tool_schema` argument may be `None` in the current core routing path, so do not rely on it being populated. Example: ``` def can_handle_tool_call( self, tool_name: str | None, payload: Any, state: dict[str, Any], *, payload_kind: str | None = None, payload_format: str | None = None, payload_metadata: dict[str, Any] | None = None, tool_call: dict[str, Any] | None = None, tool_schema: dict[str, Any] | None = None, ) -> bool | None: if tool_name not in {None, "apply_patch"}: return False return payload_kind == "text" and isinstance(payload, str) ``` ### Legacy dict-argument tools still work Many existing tools still implement the older object-arguments contract: ``` def execute_tool(self, tool_name: str, arguments: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]: ... ``` That remains supported by the Python wrapper compatibility layer in `core/python/agent_core/plugin/adapters.py`. New tools should prefer the payload-first signature, but older object-payload tools do not need an immediate rewrite. ______________________________________________________________________ ## Results, previews, display payloads, and streaming ### `format_tool_result(...)` is what the model sees The value returned by `format_tool_result(...)` becomes the tool message `content` sent back into the conversation. Most tools should return a plain string. This is the stable compatibility path for text tools: Example: ``` def format_tool_result(self, result: dict[str, Any], state: dict[str, Any]) -> str: if result.get("success"): return f"Read {result.get('bytes_read', 0)} bytes" return f"Error: {result.get('error')}" ``` Provider-specific multimodal tools may instead return an explicit provider-native tool result envelope. Do this only when the provider adapter is known to support the envelope format: ``` from agent_core.tool_result_payloads import ( FORMAT_OPENAI_CHAT_COMPLETIONS, make_provider_native_tool_result, ) def format_tool_result(self, result: dict[str, Any], state: dict[str, Any]) -> Any: return make_provider_native_tool_result( format=FORMAT_OPENAI_CHAT_COMPLETIONS, content=[ {"type": "text", "text": ""}, { "type": "image_url", "image_url": {"url": result["image_url"]}, }, {"type": "text", "text": ""}, ], ) ``` Only explicit envelopes with `type == "provider_native_tool_result"` are preserved as structured model-facing content by the default adapter. Ordinary non-string values are stringified for compatibility, so accidental dictionaries do not silently become provider-native payloads. ### `to_display_format(...)` is for richer UI output `to_display_format(...)` lets tools attach a richer display payload under `metadata["display"]` without changing what the model sees. When `format_tool_result(...)` returns a provider-native envelope, core passes an empty string as the `text` argument; use the raw `result` dict for display data. Example: ``` def to_display_format( self, text: str, result: dict[str, Any], state: dict[str, Any], ) -> dict[str, Any]: return { "type": "text", "content": text, "single_line": result.get("summary", text.splitlines()[0] if text else ""), } ``` Good uses for `display` payloads: - showing the executed shell command and workdir - showing changed files after a patch - showing compact summaries in collapsed/mobile views ### `format_tool_call_preview(...)` This hook is used by the application tool loop to preview tool calls before they execute. Example: ``` def format_tool_call_preview( self, tool_name: str | None, payload: Any, state: dict[str, Any], *, payload_kind: str | None = None, payload_format: str | None = None, payload_metadata: dict[str, Any] | None = None, tool_call: dict[str, Any] | None = None, ) -> str: if tool_name == "read_file" and isinstance(payload, dict): return f"read_file {payload.get('file_path', '')}".strip() return "" ``` ### `stream_tool(...)` Use `stream_tool(...)` when the tool produces incremental output. Rules used by `AgentCore.iter_tool_messages(...)`: - a yielded dict containing `"success"` is treated as the final result dict - a yielded dict containing `"part"` is treated as a partial display payload - any other yielded dict is wrapped as `{"part": chunk}` for backward compatibility Example: ``` from collections.abc import Iterator def stream_tool( self, tool_name: str | None, payload: Any, state: dict[str, Any], *, payload_kind: str | None = None, payload_format: str | None = None, payload_metadata: dict[str, Any] | None = None, tool_call: dict[str, Any] | None = None, ) -> Iterator[dict[str, Any]]: yield {"part": {"type": "text", "content": "starting..."}} yield {"part": {"type": "text", "content": "still working..."}} yield {"success": True, "result": "done"} ``` If you do **not** implement `stream_tool(...)`, the default adapter simply calls `execute_tool(...)` once and yields that final result dict. ______________________________________________________________________ ## Tool state and enablement ### State shape Tool state is tool-owned. Keep it explicit and serializable. Recommended pattern: ``` def init(self, config: dict[str, Any]) -> dict[str, Any]: return { "config": config, "max_bytes": int(config.get("max_bytes", 1024 * 1024)), } ``` Guidelines: - treat state as immutable by convention - put derived config into state if it simplifies execution hooks - avoid relying on long-lived instance attributes ### Preparation and prepared data Use `prepare(...)` or `prepare_async(...)` for long-running setup that should not be hidden inside cheap hooks such as `get_tool_schemas(...)`. Recommended split: - `init(config)` stays cheap and synchronous - `prepare(...)` / `prepare_async(...)` may do network I/O, subprocess startup, discovery, auth handshakes, or cache hydration - `prepare*()` returns JSON-like **prepared data** - cheap hooks consume that prepared data through an optional `prepared=...` keyword argument Example: ``` def init(self, config: dict[str, Any]) -> dict[str, Any]: return {"config": config} def prepare( self, config: dict[str, Any], state: dict[str, Any], *, context: dict[str, Any] | None = None, ) -> dict[str, Any]: remote_schema = self._discover_remote_schema(config) return { "schemas": [remote_schema], "runtime_key": "remote:primary", } def get_tool_schemas( self, state: dict[str, Any], *, prepared: dict[str, Any] | None = None, ) -> list[dict[str, Any]]: return list((prepared or {}).get("schemas") or []) ``` Important rules: - prepared data should stay JSON-like / serializable - do not put live runtime handles into `prepared` - if a tool needs long-lived managers or clients, keep those in application context such as `context["tool_runtime"]` ### Tags and enablement Tools can participate in tag-based enablement just like other plugin kinds. Example: ``` def required_tags(self) -> list[str]: return ["supports_tools"] def is_enabled( self, config: dict[str, Any], tags: list[str], models: list[dict[str, Any]], context: dict[str, Any], ) -> bool | None: model = str(config.get("model") or "") if model.startswith("gpt-5"): return True return None ``` Use this for: - model-specific tool exposure - provider capability gating - feature-flagged tools ______________________________________________________________________ ## Tool execution context Tools receive an optional `context` parameter that provides access to runtime capabilities. This enables tools to make LLM calls, access configuration, and interact with the application layer. ### Context structure The context is layered, with each layer adding capabilities: Caller-supplied context is additive only. Reserved keys owned by the context builders are not overridden; if a caller reuses one of those keys, the builder keeps the owned value and emits a warning. **Layer 1 (Core - always present when called from AgentCore):** ``` context = { "core": AgentCore, # Core instance for session/message operations "config": Dict[str, Any], # Current resolved request configuration "trigger_source": "core", # Where the tool was triggered } ``` **Layer 2 (Application - when called from AgentApplication):** ``` context = { **layer1_context, "app": AgentApplication, # Application instance "application": AgentApplication, # Alias for backward compatibility "base_config": Dict[str, Any], # Full base configuration (all agents) "session_asset_store": SessionAssetStore, # For file attachments "tool_runtime": Any, # Optional app-owned live runtime store "trigger_source": "application", } ``` ### Using context to make LLM calls Tools can use `context["core"]` to access `AgentCore` methods for making additional LLM requests: ``` def execute_tool( self, tool_name: str | None, payload: Any, state: dict[str, Any], *, context: dict[str, Any] | None = None, ) -> dict[str, Any]: core = (context or {}).get("core") if core is None: # No LLM access - return simple result return {"success": True, "result": "processed locally"} # Use core to make an LLM call temp_session = core.create_session() temp_session = core.add_message(temp_session, "user", "Summarize: " + str(payload), None, {}) # Stream response events = list(core.send_request_stream(temp_session, context.get("config", {}))) # Extract summary from events summary = self._extract_summary(events) return {"success": True, "result": summary} ``` ### Using application context for enhanced features When called from `AgentApplication`, tools can access the full application instance for features like agent switching: ``` def execute_tool( self, tool_name: str | None, payload: Any, state: dict[str, Any], *, context: dict[str, Any] | None = None, ) -> dict[str, Any]: app = (context or {}).get("app") core = (context or {}).get("core") if app is not None: # Full application context - can switch agents, access session store base_config = context.get("base_config", {}) # Use a dedicated summarizer agent if configured summarizer_agent = base_config.get("summarizer_agent") if summarizer_agent: # Create session for the summarizer agent return self._delegate_to_agent(app, core, summarizer_agent, payload, context) # Fall back to core-only processing return self._process_with_core(core, payload, context) ``` ### Backward compatibility The `context` parameter is optional with default `None`. Existing tools that don't accept the parameter continue to work without modification: ``` # Legacy tool without context parameter - still works class LegacyTool: def execute_tool(self, tool_name: str, payload: Any, state: dict[str, Any]) -> dict[str, Any]: return {"success": True, "result": "done"} # New tool with context parameter class ModernTool: def execute_tool( self, tool_name: str, payload: Any, state: dict[str, Any], *, context: dict[str, Any] | None = None, ) -> dict[str, Any]: # Can use context if available return {"success": True, "result": "done"} ``` The adapter layer uses inspection to determine whether the underlying tool implementation accepts the `context` parameter. Long-running hooks and execution hooks may use `context["tool_runtime"]` for live managers or clients that should outlive a single `AgentCore` instance. Cheap hooks such as `get_tool_schemas(...)` and `format_tool_call_preview(...)` should instead consume only the JSON-like `prepared` data returned by `prepare(...)` / `prepare_async(...)`. ______________________________________________________________________ ## Testing tool plugins Recommended test layers: 1. unit tests for the tool logic itself 1. `AgentCore.execute_tool_calls(...)` tests for end-to-end tool messages 1. preview/streaming tests when implementing `format_tool_call_preview(...)` or `stream_tool(...)` 1. application/provider integration tests only after the first three pass ### Unit test the pure execution path first ``` def test_echo_tool_execute_direct(): tool = EchoTool() state = tool.init({}) result = tool.execute_tool("echo", {"value": "hello"}, state) assert result == {"success": True, "result": "hello"} ``` ### Then test through `AgentCore` ``` def test_echo_tool_core_round_trip(): core = AgentCore() core.register_tool(EchoTool) results = core.execute_tool_calls( [ { "id": "call_1", "type": "function", "function": {"name": "echo", "arguments": {"value": "hello"}}, } ], config={}, ) assert results[0]["metadata"]["tool_name"] == "echo" ``` ### Test advanced interop explicitly If your tool emits nonstandard schemas/calls, add tests for: - schema inspection - call inspection - routing via `can_handle_tool_call(...)` - passthrough vs adaptation for different target formats The new raw-input/custom tool tests in `core/python/tests/test_core_execute_tool_calls.py` are a good reference pattern. ______________________________________________________________________ ## Out-of-process tool hosts This repo also supports out-of-process tool plugins: - [Node tool plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/node-tool-plugins/index.md) for JavaScript/TypeScript tools - [Bash tool plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/bash-tool-plugins/index.md) for shell-script-backed tools Important current limitation: - the in-process Python tool API is payload-first and can handle raw text/freeform payloads - the current Node and Bash host contracts remain centered on classic object/function-style tool calls So if you need advanced custom/freeform payload handling today, Python tools are still the best fit. ______________________________________________________________________ ## Reference implementations - `core/python/plugins/file_reader_tool.py`: small, conventional object-payload tool - `core/python/plugins/file_writer_tool.py`: object-payload write tool - `plugins/template-python-tools/src/template_python_tools/echo_tool.py`: package template - `plugins/codex-tools/src/codex_tools/apply_patch_tool.py`: payload-first raw-text tool with `can_handle_tool_call(...)` - `plugins/codex-tools/src/codex_tools/base_shell_tool.py`: display payloads, previews, and streaming patterns # Tool interop flow This page explains how tool interop works across core, providers, provider extensions, and tool plugins. It focuses on three related but distinct things: - tool schema interop - tool call interop - tool result message flow The short version is: - schemas are adapted at request injection time - tool calls are interpreted when responses are converted back into core - tool results are not adapter-based today; they flow through normal core-message \<-> provider-native message conversion If you are writing a provider or provider extension, this page explains where conversions belong and which runtime data you should use. ## Core concepts ### Schema A tool schema is the provider-facing declaration of a tool. Examples: - OpenAI chat-completions function schema - OpenAI Responses function schema - OpenAI Responses custom/freeform schema ### Tool call A tool call is the provider response object that says "invoke this tool with this payload". Examples: - OpenAI chat `tool_calls[*]` - OpenAI Responses `function_call` - OpenAI Responses `custom_tool_call` ### Tool result A tool result is the output produced by the tool after core executes it. Today, tool results are represented as normal core `role=="tool"` messages. There is currently no separate tool-result interop adapter registry. That means result conversion is handled by normal provider or provider-extension message conversion code, not by `ToolInteropRegistry`. For ordinary text tools, `format_tool_result(...)` returns a string and that string becomes the core tool message `content`. For provider-specific multimodal output, `format_tool_result(...)` may return an explicit provider-native envelope: ``` { "type": "provider_native_tool_result", "format": "openai.chat_completions", "content": [ {"type": "text", "text": ""}, {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}, {"type": "text", "text": ""}, ], } ``` Only explicit envelopes are preserved as structured content. Ordinary dict/list formatter outputs are compatibility-stringified by the default tool adapter. ## Runtime ownership Tool interop is owned by core for the active request. Core builds the effective registry from: 1. explicit config contributions 1. provider contributions 1. provider-extension contributions 1. tool contributions 1. built-in defaults Core also computes the accepted target schema formats for the active provider stack from `accepted_tool_schema_formats(...)`. Core passes both pieces of runtime data through request context: ``` context["tool_interop"]["registry"] context["tool_interop"]["schema_target_formats"] ``` Provider and provider-extension code should prefer those values instead of using `DEFAULT_TOOL_INTEROP_REGISTRY`. `DEFAULT_TOOL_INTEROP_REGISTRY` is fallback-only and should be treated as a last resort for non-request-bound utility paths. ## End-to-end flow ### 1. Tool plugins emit source schemas Tool plugins expose schemas via `get_tool_schemas(...)`. Those schemas may already be in the provider's native format, or they may be in some other supported source format. Examples: - a chat-function schema for OpenAI-compatible providers - a Responses custom schema for a freeform tool - a tool-specific custom schema format contributed by the tool plugin At this stage, schemas are not globally rewritten by core. Core keeps the original source schemas and records tool descriptors from them. ### 2. Core builds the effective tool interop registry Before request execution, core composes the active registry and determines the accepted target schema formats for the current provider and enabled extensions. This is the source of truth for request-time adaptation. ### 3. Core passes interop runtime data into request context During `initialize_request(...)` and `finalize(...)`, providers, provider extensions, and features receive: ``` context = { "core": ..., "config": ..., "tool_interop": { "registry": ..., "schema_target_formats": [...], }, ... } ``` Use this runtime context when adapting schemas or sanitizing calls. ## Where schema conversion happens Schema conversion belongs in provider or provider-extension request injection. In practice, this means `initialize_request(...)`. That code should: 1. read the source schemas from state/config 1. fetch the active registry from `context["tool_interop"]["registry"]` 1. fetch target formats from `context["tool_interop"]["schema_target_formats"]` 1. call `registry.convert_schemas(...)` 1. inject the converted schemas into the request payload Example pattern: ``` from agent_core.tool_interop import ( ToolInteropTarget, get_registry_from_context, get_schema_target_formats_from_context, ) def initialize_request(self, native_messages, state, *, context=None): request = state.get("request") or {} payload = dict(request.get("payload") or {}) tools = state.get("_tools") or [] if tools and "tools" not in payload: registry = get_registry_from_context(context) target_formats = get_schema_target_formats_from_context(context) payload["tools"] = registry.convert_schemas( [tool for tool in tools if isinstance(tool, dict)], target=ToolInteropTarget("openai.chat_completions"), target_formats=target_formats, ) payload["tool_choice"] = payload.get("tool_choice", "auto") return native_messages, {**state, "request": {**request, "payload": payload}} ``` ### Why conversion happens here This is the point where the active provider stack is known. Only the provider and enabled extensions know: - which request wire format is being built - which schema formats are accepted natively - which fallback behavior should be used when extensions are absent Core deliberately does not pre-convert every schema into one canonical wire format before `initialize_request(...)`. ## End-to-end example: tool schema flow This example shows a freeform `apply_patch`-style tool whose source schema is a Responses custom schema, while the active provider stack expects OpenAI chat function schemas. ### Source schema emitted by the tool plugin This is what `get_tool_schemas(...)` returns: ``` { "type": "custom", "name": "apply_patch", "description": "Apply a textual patch to files in the workspace.", "format": { "type": "grammar", "syntax": "lark", "definition": "start: /.+/" } } ``` ### What core keeps before request injection Core stores this original schema in the tool descriptor. It does not rewrite it yet. Conceptually: ``` ToolDescriptor( plugin_name="apply_patch", tool_name="apply_patch", schema={ "type": "custom", "name": "apply_patch", "description": "Apply a textual patch to files in the workspace.", "format": { "type": "grammar", "syntax": "lark", "definition": "start: /.+/", }, }, ) ``` ### Runtime context passed into request injection When the request is prepared, core passes the active registry and accepted target formats through context: ``` context = { "tool_interop": { "registry": registry, "schema_target_formats": ["openai.chat_completions.function"], }, ... } ``` ### Where conversion happens Conversion happens in provider or provider-extension `initialize_request(...)`. Example: ``` payload["tools"] = registry.convert_schemas( [source_schema], target=ToolInteropTarget("openai.chat_completions"), target_formats=["openai.chat_completions.function"], ) ``` ### Converted schema sent to the provider The converted request payload may look like: ``` { "model": "gpt-4.1", "tools": [ { "type": "function", "function": { "name": "apply_patch", "description": "Apply a textual patch to files in the workspace.", "parameters": { "type": "object", "properties": { "input": { "type": "string", "description": "Patch text." } }, "required": ["input"] } } } ], "tool_choice": "auto" } ``` ### What is stored after request injection Core does not replace the tool plugin's source schema everywhere with the converted one. The important distinction is: - source schema remains the tool's authored schema - converted schema is what gets injected into the provider request ### Why this matters This lets one tool schema source participate in multiple provider stacks: - chat-function targets - Responses function targets - Responses custom/freeform targets without forcing the tool plugin itself to guess the active wire format. ## Where tool call conversion happens Tool call conversion happens when provider-native responses are translated back into core messages or tool-loop inputs. Typical places: - provider `from_native_messages(...)` - provider-extension `from_native_messages(...)` - provider helpers that build assistant metadata tool-call entries The active registry inspects or converts provider-native tool call objects so core can work with: - tool name - call id - payload - payload kind - payload format - payload metadata Examples: - Responses `function_call` -> chat-style function tool call - Responses `custom_tool_call` -> preserved raw-input tool call object ### Accessors vs adapters Accessors inspect a schema or call and tell core what it means. Adapters translate one format into another. Use: - `registry.inspect_schema(...)` and `registry.inspect_call(...)` when you need semantic information from an existing object - `registry.convert_schemas(...)` and `registry.convert_tool_calls(...)` when you need a different wire format ## End-to-end example: tool call flow This example shows a Responses function call emitted by the provider and then converted into the core-visible tool call metadata shape. ### Provider-native tool call received from the model The provider may receive something like: ``` { "type": "function_call", "call_id": "call_123", "name": "read_file", "arguments": "{\"path\":\"README.md\"}" } ``` ### Where the call is interpreted This happens in provider or provider-extension native-to-core response conversion, typically inside `from_native_messages(...)` or a helper called by it. There are two common paths: 1. inspect the provider-native object and build a core tool-call object 1. convert the provider-native object into another supported wire format first Example conversion call: ``` chat_tool_calls = registry.convert_tool_calls( [native_item], target=ToolInteropTarget("openai.chat_completions"), ) ``` ### Converted core-visible tool call After conversion, the assistant metadata may contain: ``` { "id": "call_123", "type": "function", "function": { "name": "read_file", "arguments": "{\"path\":\"README.md\"}" } } ``` ### What core extracts from the call When the tool loop inspects it, the registry exposes normalized semantics: ``` inspected = registry.inspect_call(tool_call) assert inspected.call_id == "call_123" assert inspected.tool_name == "read_file" assert inspected.payload == {"path": "README.md"} assert inspected.payload_kind == "object" ``` ### What is stored in session/core messages The tool call is stored in assistant metadata on the core message: ``` { "role": "assistant", "content": "", "metadata": { "tool_calls": [ { "id": "call_123", "type": "function", "function": { "name": "read_file", "arguments": "{\"path\":\"README.md\"}" } } ] } } ``` ### Streaming + sanitization example If streaming delivered corrupted partial arguments: ``` { "id": "call_123", "type": "function", "function": { "name": "read_file", "arguments": "{\"path\":" } } ``` then provider-extension `finalize(...)` sanitizes before the call is retained in native history: ``` sanitized = registry.sanitize_tool_call(corrupted_call) ``` Result: ``` { "id": "call_123", "type": "function", "function": { "name": "read_file", "arguments": "{}" } } ``` ### What is sent on the next request When native history is replayed into the next provider request, the sanitized version is what gets sent, not the corrupted fragment. That is why sanitization must happen before history retention. ## Where sanitization happens Sanitization is a tool-call concern, not a schema concern. Provider extensions that accumulate streamed tool calls should sanitize them in `finalize(...)` before those calls are retained in native history. That should also use the request-owned registry from context: ``` from agent_core.tool_interop import get_registry_from_context def finalize(self, final_messages, native_messages, state, *, context=None): partial = state.get("partial") if isinstance(partial, dict): tool_calls = partial.get("tool_calls") if isinstance(tool_calls, list): registry = get_registry_from_context(context) partial["tool_calls"] = [ registry.sanitize_tool_call(call) for call in tool_calls if isinstance(call, dict) ] return final_messages, native_messages, state ``` This prevents corrupted JSON argument fragments from being stored and replayed into later API requests. ## Where core tool execution uses interop Once tool calls are in core, the tool loop and core execution path use the registry to interpret them in a payload-first way. Core uses inspected call semantics to decide: - which tool should handle the call - what payload should be passed to the tool - whether the payload is structured data or raw freeform input This is the layer that lets older dict-argument tools and newer raw-input tools coexist. ## End-to-end example: tool result flow This example shows a `read_file` tool returning text content after execution. ### Core executes the tool After the inspected call is routed, the tool receives normalized payload: ``` payload = {"path": "README.md"} result = tool.execute_tool("read_file", payload, state, context=context) ``` Assume the tool returns: ``` { "path": "README.md", "content": "# Project title" } ``` ### Tool wrapper formats the result `format_tool_result(...)` returns the canonical structured tool-result payload. For example: ``` { "role": "tool", "content": "{\"path\":\"README.md\",\"content\":\"# Project title\"}", "toolResult": { "type": "tool_result", "text": "{\"path\":\"README.md\",\"content\":\"# Project title\"}" }, "metadata": { "tool_name": "read_file", "tool_call_id": "call_123" } } ``` This is the core-side result representation that gets appended to the session. ### Where result conversion happens Tool results now use the same interop layer as schemas and calls: - `ToolResultAccessor` inspects stored/public `toolResult` payloads - `ToolResultAdapter` converts `toolResult` payloads between formats - provider or provider-extension `to_native_messages(...)` still decides where the converted payload lands in the native message/item shape For an OpenAI-compatible provider, the sent native message may become: ``` { "role": "tool", "content": "{\"path\":\"README.md\",\"content\":\"# Project title\"}", "tool_call_id": "call_123", "_metadata": { "tool_name": "read_file" } } ``` For a Responses-style provider, the sent native item may instead become: ``` { "type": "function_call_output", "call_id": "call_123", "output": "{\"path\":\"README.md\",\"content\":\"# Project title\"}" } ``` ### What is stored The core session stores the core tool message: ``` { "role": "tool", "content": "{\"path\":\"README.md\",\"content\":\"# Project title\"}", "toolResult": { "type": "tool_result", "text": "{\"path\":\"README.md\",\"content\":\"# Project title\"}" }, "metadata": { "tool_call_id": "call_123", "tool_name": "read_file" } } ``` The provider-native history stores the provider-native result message or item. ### What is sent later If the next request reuses native history, the provider-native result message is what gets replayed to the provider. If native history must be rebuilt from core messages, provider or provider-extension `to_native_messages(...)` converts the stored `toolResult` into the provider-native result shape and preserves compact attachment refs until request-time expansion. ### Why results now use interop adapters Results are still ordinary message flow after tool execution, but the structured `toolResult` payload is now explicitly inspectable and convertible. So the conversion points are: - schema: `initialize_request(...)` - tool call: native response -> core metadata/tool loop input - tool result: core/public `toolResult` -> provider-native result payload ## General rule for tool results The flow is: 1. core executes the tool 1. the tool returns a result value 1. the tool wrapper formats that result with `format_tool_result(...)` 1. core stores: 1. string `content` fallback 1. structured top-level `toolResult` 1. user-facing `metadata.display` 1. provider or provider-extension `to_native_messages(...)` converts `toolResult` into provider-native output form 1. provider `initialize_request(...)` expands compact attachment refs into the final API payload when needed That means: - schema adapters are for request-time tool declarations - call adapters/accessors are for provider response tool calls - tool result adapters cover structured result conversion - provider/provider-extension message mapping decides how converted tool-result payloads are embedded into native history ### Why results are different Results are not provider-generated tool-call objects. They are regular message traffic produced after tool execution, so adapters convert the structured `toolResult` payload rather than the outer message. So if you need provider-specific result handling, implement it in: - tool/provider/provider-extension `get_tool_interop_contribution(...)` when you need new result accessors or adapters - provider `to_native_messages(...)` - provider-extension `to_native_messages(...)` - provider `initialize_request(...)` for request-time attachment expansion - provider `from_native_messages(...)` if you also need rebuild symmetry ## Practical responsibilities by plugin type ### Tool plugin Responsible for: - emitting source schemas - contributing extra schema/call accessors or adapters when needed - contributing result accessors/adapters when tool results use a non-default structured envelope - executing calls based on normalized payload semantics - formatting tool results Not responsible for: - deciding the final provider wire-format target ### Provider extension Responsible for: - injecting converted schemas into provider request payloads - accumulating and sanitizing streamed tool calls - converting provider-native tool calls into core metadata - converting core tool-result messages into provider-native message shapes when needed ### Provider Responsible for: - provider-level fallback injection when no extension handles tools - native message conversion for provider-specific result/message formats - provider-specific helper conversions around request/response payloads ## Common mistakes ### Mistake: convert schemas in tool plugins based on guessed provider shape Avoid hardcoding one provider target inside a tool plugin unless the tool is explicitly provider-specific. Prefer emitting source schemas and letting request-time injection adapt them. ### Mistake: use `DEFAULT_TOOL_INTEROP_REGISTRY` in request-bound code That bypasses request-specific contributions and can break custom/freeform tools. Prefer `get_registry_from_context(context)`. ### Mistake: store the registry in config or state The active registry is runtime-only and may contain non-serializable objects. Use request context instead. ### Mistake: expect tool results to use schema/call adapters They do not today. Use normal provider/provider-extension message conversion hooks for results. ## Summary The clean mental model is: - tool plugins emit source schemas - core builds the request-owned interop registry and accepted target formats - providers/extensions adapt schemas during `initialize_request(...)` - providers/extensions inspect or convert tool calls during native-to-core response handling - providers/extensions sanitize streamed calls during `finalize(...)` - core executes tools using normalized payload semantics - tool results return through normal core-message/provider-native message conversion - provider-native result envelopes are an explicit content payload carried by core and interpreted by providers that support their `format` See also: - [Tool plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/tool-plugins/index.md) - [Provider extensions (tools + reasoning)](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/provider-extensions/index.md) - [Provider plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/provider-plugins/index.md) # Node tool plugins Node tool plugins are out-of-process `ToolPlugin` implementations executed by a Node.js tool host. They are a good fit when you want to author tools in JavaScript/TypeScript, reuse npm libraries, or ship a self-contained JS tool package without adding Python runtime code. This page is the canonical authoring guide for `node_tool` plugins. Runtime references (source of truth): - Loader / plugin source: `core/python/agent_app/plugin_sources/node_tool.py` - Python proxy plugin: `core/python/agent_core/tool_hosts/node_tool_plugin.py` - Wire protocol: `docs/reference/tool-host-protocol.md` Reference implementations: - Small object export: `plugins/js-echo-tool` - Class export: `plugins/js-class-tool` - Multi-tool package: `plugins/js-multi-tools` - Filesystem tools with streaming toggle: `plugins/js-fs-tools` - Template: `plugins/template-js-multi-tools` See also: - [Tool plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/tool-plugins/index.md) - [Packaging & loading plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/plugin-packaging-and-loading/index.md) - [Testing & validation](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/testing-and-validation/index.md) ______________________________________________________________________ ## What Node tools are for Use a Node tool plugin when: - your tool logic is already in JavaScript/TypeScript - you want to use npm packages directly - you want to ship tools as a Node package with `package.json` - you are comfortable with a subprocess boundary Prefer an in-process Python tool when: - you need the newest payload-first Python tool API directly - you need custom/freeform raw-text payload handling today - you want simpler debugging without subprocess/protocol layers Important current limitation: - the in-process Python tool API is payload-first - the v1 Node tool-host protocol still uses object-shaped `arguments` So Node tools are best suited to classic object/function-style tool calls right now. ______________________________________________________________________ ## Development workflow Recommended workflow: 1. start from `plugins/template-js-multi-tools` 1. implement one small tool first 1. test the JS module directly 1. test it through the Python plugin manager / `AgentCore` 1. try it from a real app config using `node_tool` ### Step 0: package layout Typical layout: ``` my-js-tools/ package.json src/ index.ts EchoTool.ts dist/ index.js ``` Directory-based `node_tool` packages are discovered from `package.json#agent.tools`. Minimal example: ``` { "name": "my-js-tools", "version": "0.1.0", "type": "module", "main": "dist/index.js", "scripts": { "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outdir=dist" }, "agent": { "kinds": ["tools"], "tools": [ { "id": "echo", "entry": "dist/index.js", "export": "echoTool" } ] } } ``` The `agent.tools` entries are what the loader reads for directory-based tools. ### Step 1: implement one small tool Minimal object-export example: ``` export type ToolHostEmit = (event: { type: string; payload?: unknown }) => void; type ToolState = { config: Record; calls?: number; }; export const echoTool = { name: "my_js_echo_tool", version: "0.1.0", init(config: Record): ToolState { return { config, calls: 0 }; }, getToolSchemas(_state: ToolState) { return [ { type: "function", function: { name: "echo", description: "Echo back the provided value.", parameters: { type: "object", properties: { value: { type: "string" } }, required: ["value"] } } } ]; }, async executeTool( toolName: string, args: Record, state: ToolState, emit: ToolHostEmit ) { if (toolName !== "echo") { return { success: false, error: `Unknown tool: ${toolName}` }; } state.calls = (state.calls ?? 0) + 1; emit({ type: "part", payload: { message: "working..." } }); return { success: true, result: { value: String(args?.value ?? ""), calls: state.calls } }; }, formatToolResult(result: any) { if (!result?.success) { return `Error: ${String(result?.error ?? "unknown")}`; } return `Echo: ${String(result?.result?.value ?? "")}`; }, toDisplayFormat(text: string, result: any) { const single = result?.success ? `echo ${String(result?.result?.value ?? "")}` : "echo (error)"; return { type: "text", content: text, single_line: single }; } } as const; export default echoTool; ``` Class exports are also supported: - plain object export - class export instantiated with `new` - factory function returning an object See `plugins/js-class-tool` for the class pattern. ### Step 2: build it Typical command: ``` npm install npm run build ``` If `dist/` is not checked in, the application plugin manager can install dependencies and run the build in its cache for directory-based tools. Relevant policy knobs: ``` { "plugin_policy": { "node_install_deps": true, "node_build": true, "node_allow_install_scripts": false } } ``` ### Step 3: load it in app config Supported config forms: ``` { "plugins": [ { "node_tool": { "path": "./plugins/my-js-tools" } } ] } ``` ``` { "plugins": [ { "node_tool": { "git": "https://github.com/acme/my-js-tools.git", "ref": "v0.1.0" } } ] } ``` ``` { "plugins": [ { "node_tool": { "file": "./tools/my_tool.js", "id": "my_tool" } } ] } ``` String shortcuts: ``` { "plugins": [ "node:./plugins/my-js-tools", "node+git:https://github.com/acme/my-js-tools.git#v0.1.0" ] } ``` ______________________________________________________________________ ## Runtime contract The Python side treats a Node tool package as a proxy `ToolPlugin`. The host protocol methods are described in more detail in `docs/reference/tool-host-protocol.md`. ### Required methods The v1 host expects these methods: - `init(config) -> state` - `getToolSchemas(state) -> schema[]` - `executeTool(toolName, args, state, emit) -> result` The current v1 host does **not** expose Python-side `prepare(...)` / `prepareAsync(...)` hooks yet. Node-hosted tools should therefore keep schema discovery inside `init(...)` / `getToolSchemas(...)` for now, or wait for a future host-protocol extension if they need an explicit long-running preparation phase. The host protocol method names are snake_case on the wire, but the JS tool surface uses camelCase in practice. ### Optional methods Common optional methods: - `getConfigSchema()` - `getUiElements()` - `getTags(config, models)` - `requiredTags()` - `formatToolResult(result, state)` - `formatToolCallPreview(toolName, args, state)` - `toDisplayFormat(text, result, state)` ### State round-tripping Node tools do not keep durable state in the Python process. Instead, the host round-trips `state` through RPC calls. Example: ``` type ToolState = { config: Record; calls?: number; }; init(config: Record): ToolState { return { config, calls: 0 }; } ``` If you mutate state inside `executeTool(...)`, the updated state is sent back to Python and reused on the next call. ### Streaming Streaming uses the `emit(...)` callback: ``` emit({ type: "part", payload: { message: "starting" } }); emit({ type: "part", payload: { message: "working" } }); ``` On the Python side, these become partial tool events. Important rule: - `stdout` is reserved for NDJSON protocol messages - write logs/debug output to `stderr`, not `stdout` ### Tool schemas Today, Node-hosted tools should expose classic object/function-style schemas. Example: ``` getToolSchemas() { return [ { type: "function", function: { name: "read_file", description: "Read a file from disk.", parameters: { type: "object", properties: { file_path: { type: "string" } }, required: ["file_path"] } } } ]; } ``` Do not assume the Node host currently supports the full in-process Python payload-first/custom-freeform tool contract. ______________________________________________________________________ ## Exports and discovery ### Directory-based tools For a package directory, the loader reads `package.json#agent.tools`. Example: ``` { "agent": { "tools": [ { "id": "echo", "entry": "dist/index.js", "export": "echoTool" }, { "id": "reverse", "entry": "dist/index.js", "export": "reverseTool" } ] } } ``` Notes: - `entry` is relative to the package root - `export` is optional; without it, the default export is used - one package can expose multiple tools ### Single-file tools Single-file specs skip `package.json#agent.tools` and point directly to a file: ``` { "plugins": [ { "node_tool": { "file": "./plugins/js-single-file-tool/UpperTool.ts", "id": "js_single_file_tool" } } ] } ``` ### Supported export shapes The host supports: - plain object export - class export - factory function returning an object See: - `plugins/js-echo-tool` - `plugins/js-class-tool` ______________________________________________________________________ ## Testing Node tools Recommended layers: 1. JS/TS unit tests for pure logic 1. build test (`npm run build`) 1. Python-side loader/integration tests 1. full app config test ### Quick local loop ``` npm install npm run build pytest core/python/tests/test_node_tool_plugins.py -q ``` Useful references: - `core/python/tests/test_node_tool_plugins.py` - `core/python/tests/fixtures/node_tools/` ### What to test At minimum: - schema discovery - tool execution result shape - streaming partials - state round-tripping - directory vs single-file loading - class/default/named exports if relevant If your tool uses config restrictions such as `allowed_paths`, add tests for the failure path too. ______________________________________________________________________ ## Common pitfalls - Do not print logs to `stdout`; use `stderr`. - Keep returned state JSON-serializable. - Keep tool results JSON-serializable. - `format_tool_result` should usually return a string. It may return an explicit provider-native envelope with `type: "provider_native_tool_result"` when the target provider supports that format. Arbitrary objects are not treated as structured model-facing content. - Prefer small, copyable schemas and result objects. - Remember that host protocol methods are still object-arguments-based. - If you need raw-text/freeform payloads today, prefer a Python tool. ______________________________________________________________________ ## Reference examples - `plugins/template-js-multi-tools` - `plugins/js-echo-tool` - `plugins/js-class-tool` - `plugins/js-fs-tools` - `docs/reference/tool-host-protocol.md` # Bash tool plugins Bash tool plugins are out-of-process `ToolPlugin` implementations backed by a single `bash` script. They are a good fit for small shell-native tools, command wrappers, and simple file/text utilities that are easiest to express as shell scripts. This page is the canonical authoring guide for `bash_tool` plugins. Runtime references (source of truth): - Loader / plugin source: `core/python/agent_app/plugin_sources/bash_tool.py` - Python proxy plugin: `core/python/agent_core/tool_hosts/bash_tool_plugin.py` - Config + file contract: `docs/plugins/application-config.md` Reference implementations: - Template: `plugins/template-bash-tool` - Minimal real example: `plugins/bash-read-line-range` See also: - [Tool plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/tool-plugins/index.md) - [Packaging & loading plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/plugin-packaging-and-loading/index.md) - [Testing & validation](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/testing-and-validation/index.md) ______________________________________________________________________ ## What Bash tools are for Use a Bash tool when: - the tool is naturally a shell script - you want a very small dependency footprint - the tool mainly wraps existing CLI utilities - the logic is straightforward enough to express through shell subcommands Prefer an in-process Python tool when: - you need richer data structures or complex validation - you need the payload-first/custom-freeform Python tool API - you want easier portability or deeper unit testing Important current limitations: - one bash tool file maps to one tool - the schema contract is currently centered on classic function-style schemas - the bash host does not expose the newer Python payload-first/custom-freeform contract directly ______________________________________________________________________ ## Development workflow Recommended workflow: 1. start from `plugins/template-bash-tool` 1. implement `schema`, `preview`, and `run` 1. test the script directly from the shell 1. test it through the Python plugin manager 1. load it in a real app config with `allow_bash_tools: true` ### Step 0: package layout Minimal repo layout: ``` my-bash-tool/ agent_plugin.json my_tool.bash pyproject.toml tests/ test_my_tool.py ``` Example `agent_plugin.json`: ``` { "bash_tools": [ { "file": "my_tool.bash" } ] } ``` For single-file tools, you can also skip the repo descriptor and load the file directly via a `bash_tool.file` config spec. ### Step 1: write the bash tool file Each bash tool file is invoked as: ``` bash /abs/path/to/tool.bash [args...] ``` Supported subcommands: - `schema` - `preview` - `run` - `error` (optional) Runtime environment variables injected by the host include: - `AGENT_TOOL_PYTHON` - absolute path to the exact Python interpreter running the bash tool host - useful for bash tools that need to invoke helper Python scripts without rediscovering `python3` through `PATH` - `AGENT_TOOL_TIMED_OUT` - set to `1` when the host invokes the optional `error` subcommand after a timeout - `AGENT_TOOL_TIMEOUT_SECONDS` - timeout value associated with a timed-out invocation Minimal working example: ``` #!/usr/bin/env bash set -euo pipefail subcommand="${1:-}" shift || true case "$subcommand" in schema) cat <<'JSON' { "id": "template_bash_echo", "version": "0.1.0", "args_mode": "positional", "positional": [ {"name": "value", "required": true}, {"name": "uppercase", "default": false} ], "tools": [ { "type": "function", "function": { "name": "template_bash_echo", "description": "Echo a value (optionally uppercase it).", "parameters": { "type": "object", "properties": { "value": {"type": "string"}, "uppercase": {"type": "boolean", "default": false} }, "required": ["value", "uppercase"] } } } ] } JSON ;; preview) value="${1:-}" uppercase="${2:-false}" echo "template_bash_echo value=$(printf %q "$value") uppercase=$uppercase" ;; run) value="${1:-}" uppercase="${2:-false}" if [[ "$uppercase" == "true" ]]; then printf '%s' "$value" | tr '[:lower:]' '[:upper:]' else printf '%s' "$value" fi ;; error) exit_code="${1:-1}" echo "Error: template_bash_echo failed (exit_code=$exit_code)" exit 0 ;; *) echo "Usage: $0 {schema|preview|run|error}" >&2 exit 2 ;; esac ``` This mirrors `plugins/template-bash-tool/template_bash_echo.bash`. ### Step 2: load it in config Single-file form: ``` { "plugins": [ { "bash_tool": { "file": "./tools/my_tool.bash" } } ], "plugin_policy": { "allow_bash_tools": true } } ``` Directory form: ``` { "plugins": [ { "bash_tool": { "path": "./plugins/my-bash-tool" } } ], "plugin_policy": { "allow_bash_tools": true } } ``` Git form: ``` { "plugins": [ { "bash_tool": { "git": "https://github.com/acme/my-bash-tool.git", "ref": "v0.1.0" } } ], "plugin_policy": { "allow_bash_tools": true } } ``` String shortcuts: ``` { "plugins": [ "bash:./plugins/my-bash-tool", "bash+git:https://github.com/acme/my-bash-tool.git#v0.1.0" ], "plugin_policy": { "allow_bash_tools": true } } ``` ______________________________________________________________________ ## Bash tool file contract ### `schema` `schema` must print a JSON object to stdout. Required fields: ``` { "id": "my_tool", "version": "0.1.0", "args_mode": "flags", "tools": [ { "type": "function", "function": { "name": "my_tool", "parameters": { "type": "object", "properties": {} } } } ] } ``` Current v1 constraints: - exactly one tool schema per bash file - `schema.id` must match `tools[0].function.name` Optional fields: - `config_keys` - list of config keys that the host should expose to the bash subprocess - each key is passed as an environment variable named: - `AGENT_TOOL_CONFIG_` - only scalar config values are passed through; lists/dicts are ignored Example: ``` { "id": "send_to_kindle", "version": "0.1.0", "args_mode": "json", "config_keys": [ "send_to_kindle_smtp_user", "send_to_kindle_default_to" ], "tools": [ { "type": "function", "function": { "name": "send_to_kindle", "parameters": {"type": "object", "properties": {}} } } ] } ``` ### `preview` `preview` should print a short single-line preview to stdout and exit 0. Example: ``` preview) path="${1:-}" echo "read_line_range path=$(printf %q "$path")" ;; ``` If `preview` exits non-zero, the host falls back to an empty preview string. ### `run` `run` is the actual tool execution path. Stdout becomes streamed output and contributes to the final tool message. Example: ``` run) path="${1:-}" start="${2:-1}" end="${3:-1}" sed -n "${start},${end}p" "$path" ;; ``` ### `error` (optional) When `run` fails or times out, the host may invoke: ``` bash tool.bash error [args...] ``` If `error` exits 0 and prints non-empty stdout, that text becomes the final tool message content. Otherwise the host falls back to default formatting. On timeout, the host sets: - `AGENT_TOOL_TIMED_OUT=1` - `AGENT_TOOL_TIMEOUT_SECONDS=` In normal `schema` / `preview` / `run` / `error` invocations, the host also sets: - `AGENT_TOOL_PYTHON=` This lets bash tools reliably reuse the same Python environment as the host. Recommended pattern: ``` PYTHON_BIN="${AGENT_TOOL_PYTHON:-$(command -v python3)}" "${PYTHON_BIN}" ./helper.py ``` ______________________________________________________________________ ## Argument passing The contract is selected by `args_mode`. ### `flags` Arguments are converted to CLI flags. Rules: - scalar: `--name ` - boolean `true`: `--name` - boolean `false`: `--no-name` Example schema fragment: ``` { "args_mode": "flags" } ``` ### `positional` Arguments are mapped to ordered argv values using `schema.positional`. Example: ``` { "args_mode": "positional", "positional": [ { "name": "path", "required": true }, { "name": "start_line", "required": true }, { "name": "end_line", "required": true }, { "name": "show_line_numbers", "default": false } ] } ``` This is the mode used by `plugins/bash-read-line-range`. ### `json` For `json` mode, the host calls: - `run --args-json` - `preview --args-json` - `error --args-json` The JSON arguments object is written to stdin. This is useful when: - arguments are awkward to represent as flags/positionals - you want to parse them with `jq` Example pattern: ``` run) if [[ "${1:-}" == "--args-json" ]]; then args_json="$(cat)" value="$(printf '%s' "$args_json" | jq -r '.value')" printf '%s' "$value" exit 0 fi ;; ``` ______________________________________________________________________ ## Runtime behavior ### Working directory The host uses `config["working_directory"]` when present to set the subprocess cwd. If it is absent, the default shell/process cwd is used. ### Selected config propagation When a bash tool schema declares `config_keys`, the host reads those keys from the resolved request config (`state["config"]`) and exposes them as environment variables: - config key: `send_to_kindle_smtp_user` - subprocess env: `AGENT_TOOL_CONFIG_SEND_TO_KINDLE_SMTP_USER` This is the recommended way for bash tools to consume agent/provider/mixin defaults that may themselves come from `${env:...}` placeholders in config. Recommended pattern: ``` SMTP_USER="${AGENT_TOOL_CONFIG_SEND_TO_KINDLE_SMTP_USER:-}" ``` ### Python interpreter propagation The host exports: - `AGENT_TOOL_PYTHON` with the value of the host process's `sys.executable`. This is the preferred interpreter for bash tools that call Python helpers because it preserves: - the same virtual environment or packaged runtime as the host - the same installed dependencies - the same certificate store / interpreter-specific runtime behavior Prefer this over rediscovering `python3` via shell `PATH` when possible. ### Streaming Tool stdout is streamed as partial output. When enabled by policy, stderr may also be streamed. The final tool message is emitted after the process exits and formatting is applied. ### Timeouts The host uses: - `plugin_policy.bash_timeout_seconds` when provided at load time - otherwise the default timeout in `BashToolPlugin` If the process times out, it is reported as a failure and the optional `error` subcommand may still be used to format the final tool message. ### Tool name handling The current bash host assumes one tool per file and enforces that the tool name matches the schema/file identity. This is why `schema.id` and `tools[0].function.name` must match. ______________________________________________________________________ ## Testing Bash tools Recommended layers: 1. direct shell testing of `schema`, `preview`, `run`, and `error` 1. Python plugin-manager tests 1. full app config tests with `allow_bash_tools: true` ### Direct shell checks These are the fastest loop when developing: ``` bash ./my_tool.bash schema bash ./my_tool.bash preview hello true bash ./my_tool.bash run hello true bash ./my_tool.bash error 1 ``` ### Python-side tests Useful pattern: ``` pytest plugins/template-bash-tool/tests -q pytest plugins/bash-read-line-range/tests -q ``` Good things to verify: - schema JSON is valid - `id` matches the function name - argument passing works for the chosen `args_mode` - timeout/failure formatting is reasonable - directory and single-file loading both work - helper scripts use `AGENT_TOOL_PYTHON` correctly when Python is involved - declared `config_keys` arrive as expected in subprocess env ______________________________________________________________________ ## Common pitfalls - Forgetting to enable `plugin_policy.allow_bash_tools` - Loading a bash tool repo with `path:...` instead of `bash:...` or `{"bash_tool": ...}` - Printing invalid JSON from `schema` - Mismatching `schema.id` and `tools[0].function.name` - Returning multiple tools from one file - Relying on shell features or external commands not present in target environments - Forgetting to quote shell arguments safely - Assuming the bash host supports the in-process Python payload-first/freeform API ______________________________________________________________________ ## Reference examples - `plugins/template-bash-tool` - `plugins/bash-read-line-range` - `docs/plugins/application-config.md` # Plugin actions This page describes the plugin action model, including lifecycle-triggered actions, as a completed runtime feature. The core idea is that plugins expose one action concept, and actions may be: - manually triggered by a user or UI - triggered automatically by a lifecycle event The distinction between application plugins and other plugin types is not the action naming. The distinction is where actions run, what they are allowed to return, and whether they are limited to a single session mutation scope. Runtime references for the current codebase: - `core/python/agent_app/app_plugins.py` - `core/python/agent_app/application_future.py` - `core/python/agent_core/types.py` - `core/python/agent_core/core.py` - `application/python/agent_terminal_app/server.py` ## Overview After the update, actions will follow these rules: - Application plugins continue to define actions with `get_actions(...)` and execute them with `execute_action(...)`. - Provider extensions use the same action concept and the same method names for session-scoped actions. - Lifecycle-triggered work is modeled as an ordinary action with a `trigger` field, rather than a second hook naming scheme. - `AgentApplication` decides when lifecycle-triggered actions run. - `AgentCore` executes lifecycle-triggered actions for core-side plugins and returns a modified `Session`. ## Generic display payloads Successful actions may return a generic user-facing display payload in `result.display`. This is the preferred way for plugins to tell frontends to show instructions, status summaries, or rich follow-up text without introducing plugin-specific result keys. Recommended shape: ``` { "display": { "format": "markdown", "title": "ChatGPT Login", "body": "Open [this link](https://example.test) and enter code `ABCD-EFGH`.", "variant": "info", "presentation": "inline", "dismissible": true, "display_id": "chatgpt-auth-login", "actions": [ { "kind": "open_url", "id": "open-login-link", "label": "Open link", "url": "https://example.test" }, { "kind": "copy_text", "id": "copy-login-code", "label": "Copy code", "text": "ABCD-EFGH" }, { "kind": "run_action", "id": "check-login-status", "label": "Check status", "plugin": "chatgpt_auth", "action_id": "check_status", "preserve_display": true } ] } } ``` Fields: - `format`: `"markdown"` or `"text"` - `body`: required display content - `title`: optional heading - `variant`: optional tone hint such as `info`, `success`, `warning`, or `error` - `presentation`: optional frontend hint such as `modal`, `inline`, or `banner` - `dismissible`: optional hint controlling whether the user can dismiss the rendered display - `display_id`: optional stable id so frontends can update or replace an existing inline/banner display instead of always appending a new one - `actions`: optional list of generic follow-up actions Guidance: - keep `display` user-oriented and safe to render directly - keep machine-readable fields alongside it when frontend logic or tests still need structured state - do not overload top-level `message` keys for successful rich content when `display` is more appropriate - keep follow-up actions intentionally narrow and generic; current supported shapes are `open_url`, `copy_text`, and `run_action` - treat `presentation` as a frontend hint, not a guarantee: current frontends ignore `display` for `session_list_action` surfaces and the desktop app currently coerces session-action displays to `inline` - `modal` remains part of the protocol, but richer cross-platform popup support should be added later only when there is a concrete use case and a UI approach that works on both mobile and desktop ## Dynamic lifecycle names Lifecycle names are dynamic strings. That means: - future application implementations may call `AgentApplication` with arbitrary lifecycle names - future plugins may declare arbitrary lifecycle names in `trigger` - the contract should not require lifecycle names to be chosen from a fixed enum baked into the API The system still has a standard built-in lifecycle baseline for normal app flows, but that baseline is not the full allowed set. ## Triggered actions An action definition may include a `trigger` field. Examples: - `"trigger": "session_list"` - `"trigger": "session_create"` - `"trigger": "request_prepare"` - `"trigger": "session_fork"` - `"trigger": ["session_create", "request_prepare"]` - `"trigger": "my_future_custom_lifecycle"` The meaning of common triggers is: - `session_list`: the application/server is building session-list summary data - `session_create`: a new session has just been created and may need initial session-owned settings - `session_save_prepare`: a session is about to be persisted and may need final metadata adjustments - `request_prepare`: an existing session is about to be used for a request and may need repair or fallback initialization - `request_complete`: a request completed successfully and post-request session metadata may need to be updated - `response_finalize`: core-owned post-finalization stage where in-flight final messages and retained native history may still be adjusted before the final payload is emitted - `request_error`: a request failed and failure-related session state may need to be recorded - `session_fork`: a new forked session has been created from an existing session and may need fork-aware adjustments - `agent_switch_prepare`: a session is about to switch from one agent to another and may need pre-switch adjustments - `agent_switch_complete`: a session has completed an agent switch and may need post-switch adjustments - `session_delete_prepare`: a session is about to be deleted and plugins may need to record or clean up related state Actions without a `trigger` are manual actions. ## Standard built-in lifecycle set Even though lifecycle names are dynamic, `AgentApplication` provides a standard built-in set for its mutating flows. The standard built-in lifecycles are: - `session_create` - `session_save_prepare` - `request_prepare` - `request_complete` - `request_error` - `session_fork` - `agent_switch_prepare` - `agent_switch_complete` - `session_delete_prepare` Lifecycle meanings: - `session_create`: run after a brand-new session is created and before first persistence - `session_save_prepare`: run immediately before a session is saved to the session store; intended for final metadata normalization or session-owned bookkeeping that must happen before persistence - `request_prepare`: run after effective config resolution and before the tool loop/provider request begins - `request_complete`: run after a successful request has produced its final session and before the application completes post-request persistence - `request_error`: run after a request fails and before the application exits the request flow; intended for recording failure metadata or clearing partially initialized values - `session_fork`: run on the new forked session after the fork is created; fork-specific context may include the serialized source session - `agent_switch_prepare`: run when a session is about to switch from one agent to another - `agent_switch_complete`: run after the session has been updated to its new agent assignment - `session_delete_prepare`: run before a session is deleted; intended for any last-chance session-scoped cleanup or bookkeeping The built-in lifecycle set is the standard vocabulary used by `AgentApplication`. It intentionally excludes load/read paths, because built-in lifecycle actions are allowed to be mutating and may be long-running. Callers may still use additional lifecycle names when they need application-specific behavior. ## Lifecycle timing and ownership The standard lifecycles are triggered at these points: - `session_create`: owned by `AgentApplication` session creation flows such as `create_session_ephemeral(...)` and any persisted create flow built on top of it - `session_save_prepare`: owned by `AgentApplication.save_session(...)` - `request_prepare`: owned by `AgentApplication.send_request(...)`, after effective config resolution and before the tool loop/provider request starts - `request_complete`: owned by `AgentApplication.send_request(...)`, after the request finishes successfully and a final session exists - `response_finalize`: owned by `AgentCore` request execution, after provider / feature native finalization and native-to-core conversion, but before final messages are emitted or appended to the session - `request_error`: owned by `AgentApplication.send_request(...)`, when request execution raises or returns a terminal error path - `session_fork`: owned by session-fork flows in the application layer and runs on the new forked session - `agent_switch_prepare`: owned by agent-switch flows before the new agent id is written to the session - `agent_switch_complete`: owned by agent-switch flows after the new agent id has been written - `session_delete_prepare`: owned by application-layer delete-session flows `AgentCore` does not decide when any of these lifecycles happen. It only executes matching core-side actions for the lifecycle name that `AgentApplication` passes in. Exception: - `response_finalize` is a core-owned lifecycle executed directly by `AgentCore` during request finalization so core-side plugins can still mutate the current turn's `final_messages` before they are returned. ## Dispatcher ownership Lifecycle dispatch belongs to `AgentApplication`. Reasons: - `AgentApplication` owns session creation and request entrypoints. - `AgentApplication` owns session persistence, locks, checkpoints, and event publication. - `AgentApplication` can coordinate both application plugins and core-side plugins from one place. `AgentApplication` also provides a generic lifecycle-dispatch entry point that accepts: - a lifecycle name - the current session - the effective config - optional extra context This is what allows future application implementations to run arbitrary lifecycle names beyond the built-in set. The intended flow is: 1. `AgentApplication` decides that a lifecycle trigger should run. 1. `AgentApplication` executes matching application-plugin actions. 1. `AgentApplication` calls a high-level `AgentCore` helper for matching provider-extension or feature actions. 1. `AgentApplication` persists any changed session through the normal application-layer save/checkpoint path. For the standard built-in set, the generic flow is: 1. determine the lifecycle name and effective config 1. build serializable execution context 1. run matching application-plugin actions 1. run matching core-side actions through `AgentCore` 1. merge resulting session mutations 1. continue the owning application operation such as save, request, fork, or agent switch For `response_finalize`, the flow is slightly different: 1. `AgentCore` finishes provider / feature native `finalize(...)` 1. `AgentCore` converts the current turn's native finals back into core `final_messages` 1. `AgentCore` runs matching core-side actions with `trigger: "response_finalize"` 1. actions may return replacement `final_messages`, replacement `native_messages`, and `session_metadata` 1. `AgentCore` emits / returns the updated `final_messages` and stores the updated native history ## Execution context Lifecycle-triggered action execution may include extra context in addition to normal action params. This context exists so callers can provide lifecycle-specific information such as the original session used to create a fork. Requirements: - extra context must be serializable - plugin code must treat extra context as language-neutral data - multi-language plugin implementations must not depend on Python object identity or Python-only types in this context channel Recommended examples: - `{"lifecycle": "request_prepare"}` - `{"lifecycle": "session_fork", "original_session": {...}}` - `{"lifecycle": "agent_switch_prepare", "previous_agent_id": "a", "next_agent_id": "b"}` - `{"lifecycle": "request_error", "error": {"type": "RuntimeError", "message": "tool loop failed"}}` - `{"lifecycle": "session_delete_prepare", "delete_reason": "user_request"}` When a fork lifecycle is triggered, the original session is passed in a serialized form such as the result of `Session.to_dict()`, not as a live Python `Session` object. ### Reserved context keys The runtime may populate these commonly used keys in `context`: - `lifecycle`: the lifecycle name being executed - `trigger_source`: string describing who initiated execution, such as `application`, `server`, or `manual` - `original_session`: serialized source session for fork-style flows - `previous_agent_id`: source agent id for agent-switch flows - `next_agent_id`: destination agent id for agent-switch flows - `error`: serialized error payload for failure lifecycles Plugins may also use additional serializable context keys supplied by the caller. Reserved context keys are owned by the core/application builders. Caller context is additive only. If caller-provided context reuses a reserved key, the builder keeps the owned value and emits a warning instead of silently overriding it. ### Layered context for feature/extension plugins Feature plugins and provider extensions receive **layered context** that provides different capabilities depending on where the action is triggered. This allows plugins to access enhanced capabilities when available while gracefully degrading when running in core-only mode. #### Layer 1: Core-level context (always present) When called from `AgentCore.execute_session_action()` or `AgentCore.execute_lifecycle_actions()`: ``` context = { "core": self, # AgentCore instance "config": config, # Current resolved config "trigger_source": "core", # Where action was triggered "session": session.to_dict(), # Serialized session "lifecycle": trigger, # Lifecycle trigger name (if applicable) } ``` | Key | Type | Description | | ----------------- | ----------- | ----------------------------------------------------------------------- | | `core` | `AgentCore` | Core instance for session/message operations | | `config` | `dict` | Current resolved request configuration | | `trigger_source` | `str` | Where action was triggered (`"core"`, `"application"`) | | `session` | `dict` | Serialized session (`session.to_dict()`) | | `lifecycle` | `str` | Lifecycle trigger name (for lifecycle-triggered actions) | | `request_context` | `dict` | Request-initialized plugin context for action-style flows | | `request_runtime` | `dict` | Request runtime helpers (`provider`, `features`) for action-style flows | #### Layer 2: Application-level context (when called from AgentApplication) When called from `AgentApplication.execute_session_action()` or `AgentApplication.run_session_lifecycle()`: ``` context = { # Layer 1 (from core) "core": core, "config": effective_config, "trigger_source": "application", "session": session.to_dict(), "lifecycle": lifecycle, # if applicable # Layer 2 (from application) "app": self, # AgentApplication instance "application": self, # Alias for "app" "base_config": self._config, # Full base config (all agents) "session_asset_store": self._session_asset_store, # Optional } ``` | Key | Type | Description | | --------------------- | ------------------- | ---------------------------------------------- | | `app` / `application` | `AgentApplication` | Application instance for enhanced capabilities | | `base_config` | `dict` | Full application configuration (all agents) | | `session_asset_store` | `SessionAssetStore` | File attachment store (optional) | When application and core both contribute context, ownership is explicit: application contributes application-owned keys such as `app`, `application`, `base_config`, and `session_asset_store`, while the core contributes core-owned keys such as `core`, `config`, `session`, `trigger_source`, `request_context`, and `request_runtime`. #### Layer 3: Terminal/Server-level context (implementation-specific) Terminal or HTTP server implementations may add additional context keys: ``` # TerminalApplication may add: context["terminal"] = self # HTTP server may add: context["server"] = self context["request_id"] = request_id ``` These keys are implementation-specific and should be documented by the respective applications. #### Checking for capabilities Feature and extension plugins should check for capabilities before using them: ``` def execute_action( self, action_id: str, session: Session, native_messages: List[Dict[str, Any]], params: Dict[str, Any], context: Optional[Dict[str, Any]], state: Dict[str, Any], ) -> Dict[str, Any]: # Core is always available core = context["core"] config = context["config"] # Application is optional (check before using) app = context.get("app") base_config = context.get("base_config", config) if app is not None: # Application-level capabilities available # Can switch agents, use full request flow pass else: # Core-only mode # Use core.send_request() for same-agent requests pass ``` **Note on config resolution:** The `config` key is the resolved request configuration for the current agent. When called from `AgentApplication`, `base_config` contains the full flattened agent configuration (from `build_agent_config()`). For agent-level settings like `compaction`, always use `base_config` when available, falling back to `config` for core-only calls. #### Context flow diagram ``` TerminalApplication.execute_action() ↓ adds: {"terminal": self, ...} AgentApplication.run_session_lifecycle() ↓ adds: {"app": self, "base_config": ...} AgentCore.execute_lifecycle_actions() ↓ adds: {"core": self, "config": config} FeaturePlugin.execute_action(session, native_messages, params, context, state) ↓ Plugin checks: app = context.get("app") core = context["core"] ``` ## Application plugin contract Application plugins keep the current action method names and semantics. ``` class ApplicationPlugin(Protocol): name: str version: str def init(self, app_config: dict[str, Any]) -> dict[str, Any]: ... def get_actions(self, state: dict[str, Any]) -> list[dict[str, Any]]: ... def execute_action( self, app: "AgentApplication", action_id: str, params: dict[str, Any], context: dict[str, Any] | None, state: dict[str, Any], ) -> dict[str, Any]: ... ``` Application-plugin action definitions may be manual or lifecycle-triggered. Example: ``` { "id": "ensure_session_defaults", "label": "Ensure session defaults", "inputs": { "session_id": {"type": "string", "required": True}, }, "trigger": ["session_create", "request_prepare"], } ``` `params` remains the plugin-defined action input surface. `context` is execution context supplied by the caller, especially for lifecycle-triggered execution. It may contain arbitrary serializable keys. Application-plugin action results remain application-level. They may include: - `mutations` for created, updated, or deleted sessions - `ui_effects` such as reload requests or navigation hints - additional app-facing payloads used by HTTP, terminal, or mobile flows When application actions are lifecycle-triggered, they still use the same contract. The difference is only how they were selected for execution. This is different from core-side plugin actions, which stay single-session oriented. ## Provider extension contract Provider extensions use the same action naming pattern for this concept. ``` class ProviderExtensionPlugin(BasePlugin, Protocol): name: str version: str def init(self, config: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]: ... def get_actions(self, state: dict[str, Any]) -> list[dict[str, Any]]: ... def execute_action( self, action_id: str, session: Session, native_messages: list[dict[str, Any]], params: dict[str, Any], context: dict[str, Any] | None, state: dict[str, Any], ) -> dict[str, Any]: ... ``` The important difference is the result contract. Provider-extension actions stay single-session oriented. They do not return application-level UI effects or navigation instructions. Instead, they follow the existing core-side mutation style: - return replacement `native_messages` when the visible message list should be rebuilt from provider-native history - return `session_metadata` when session metadata should be patched Because `session_metadata` can contain `"overrides"`, provider-extension actions can initialize or repair session-owned settings without becoming application plugins. `context` follows the same rule here: it is caller-provided execution metadata and must be serializable. ## Feature plugin contract Features that participate in triggered session actions follow the same conceptual pattern as provider extensions: `get_actions(...)` plus `execute_action(...)`, with single-session mutation results rather than application-level UI effects. ## Core-side session mutation model For provider extensions and similar non-application plugins, the core-side action result is intentionally narrow. Expected mutation surfaces: - `native_messages` - `session_metadata` Expected outcomes: - visible messages may change when the core rebuilds the transcript from returned `native_messages` - session-owned settings may change when `session_metadata` patches `session.metadata["overrides"]` The goal is to keep core-side actions limited to one session. They do not own application concerns such as reload-all, switch-session, or multi-session UI coordination. ## Lifecycle action ordering Actions are executed in the same stable plugin/action ordering used elsewhere in the runtime. Practical rules: - application-plugin lifecycle actions run in application-plugin registration order - core-side lifecycle actions run in the resolved provider/plugin order for the effective config - for a single plugin, actions run in the order returned by `get_actions(...)` - later actions observe session changes produced by earlier actions in the same lifecycle run Plugins should therefore make lifecycle actions idempotent when possible. ## Lifecycle examples ### Create-time initialization A provider extension can ensure a session-owned setting exists for a new session: ``` { "id": "ensure_prompt_cache_key", "label": "Ensure prompt cache key", "inputs": {}, "trigger": "session_create", } ``` Its `execute_action(...)` may return: ``` { "native_messages": native_messages, "session_metadata": { "overrides": { "prompt_cache_key": "generated-key" } }, } ``` ### Request-time repair The same action can also be triggered for older sessions that predate the setting: ``` { "id": "ensure_prompt_cache_key", "label": "Ensure prompt cache key", "inputs": {}, "trigger": ["session_create", "request_prepare"], } ``` `AgentApplication` is responsible for deciding that `request_prepare` run before the request/tool loop begins and for persisting any resulting session change. ### Save-time normalization Plugins can normalize session metadata before persistence: ``` { "id": "normalize_before_save", "label": "Normalize before save", "inputs": {}, "trigger": "session_save_prepare", } ``` ### Request completion and error handling Plugins can react after request completion or failure: ``` { "id": "record_request_success", "label": "Record request success", "inputs": {}, "trigger": "request_complete", } ``` ``` { "id": "record_request_failure", "label": "Record request failure", "inputs": {}, "trigger": "request_error", } ``` ### Fork-aware initialization A plugin can also react to a newly created fork: ``` { "id": "adjust_fork_settings", "label": "Adjust fork settings", "inputs": {}, "trigger": "session_fork", } ``` The caller may provide extra context like: ``` { "lifecycle": "session_fork", "original_session": { "session_id": "session-123", "messages": [], "metadata": {"agent_id": "default"}, }, } ``` That lets the action make fork-aware decisions without relying on Python-only objects. ### Agent switch handling Plugins can react before and after an agent switch: ``` { "id": "prepare_for_agent_switch", "label": "Prepare for agent switch", "inputs": {}, "trigger": "agent_switch_prepare", } ``` ``` { "id": "finalize_agent_switch", "label": "Finalize agent switch", "inputs": {}, "trigger": "agent_switch_complete", } ``` Context commonly includes: ``` { "lifecycle": "agent_switch_prepare", "previous_agent_id": "default", "next_agent_id": "research", } ``` ### Delete-time cleanup Plugins can run one final action before deletion: ``` { "id": "cleanup_before_delete", "label": "Cleanup before delete", "inputs": {}, "trigger": "session_delete_prepare", } ``` ## Design guidance Use an application plugin when the action needs application-level powers such as: - creating or deleting sessions - coordinating multiple sessions - returning UI navigation or reload instructions - integrating with server-only or frontend-only flows Use a provider extension or similar core-side plugin when the action is owned by one session and one provider/config context, especially when it needs to: - mutate provider-native history - patch session metadata owned by that provider feature - ensure or repair session-owned settings such as values stored under `session.metadata["overrides"]` ## Relation to existing `session_list` actions The current code already has one server-interpreted triggered-action pattern: application-plugin actions with `trigger: "session_list"`. The runtime generalizes that idea so lifecycle triggers are no longer a server-only special case and can also be used by non-application plugins through shared application/core orchestration. ## Core-side lifecycle vocabulary `AgentCore` supports execution for any lifecycle name that `AgentApplication` passes to it. The standard vocabulary it receives includes: - `session_create` - `session_save_prepare` - `request_prepare` - `request_complete` - `request_error` - `session_fork` - `agent_switch_prepare` - `agent_switch_complete` - `session_delete_prepare` `AgentCore` does not decide when these lifecycles happen. It only executes matching core-side actions for the given lifecycle name, session, config, and serializable execution context. # Packaging & loading plugins This page documents the recommended way to package plugin repos (providers, extensions, features, tools) and how applications load them. It is shared across plugin kinds. ## Recommended repo layout (Python) Minimal layout: ``` my-provider/ pyproject.toml agent_plugin.json src/ my_provider/ __init__.py provider.py tests/ test_provider_smoke.py README.md ``` Use `src/` layout and make your project pip-installable. ## `agent_plugin.json` (descriptor) When loading a plugin repo via `path:...` or `git+...`, the application layer reads `agent_plugin.json` from the installed plugin directory. Minimal schema: ``` { "entries": [ "my_provider.provider.MyProvider", "my_provider.extension.MyExtension", "my_provider.feature.MyFeature" ], "subdirectory": "." } ``` Notes: - `entries` is required. - `subdirectory` is optional (useful for monorepos). - A `kinds` map is optional; the application can also classify plugins by duck-typing. - `local_workspace_dependencies` is optional; use it for sibling local Python packages in the same workspace. Runtime reference: `core/python/agent_app/plugin_sources/descriptor.py`. ### Local workspace dependencies Some local path plugin repos depend on sibling local packages in the same workspace, for example a tool plugin depending on `../tool-compat-shared`. Declare those packages explicitly in `agent_plugin.json`: ``` { "entries": [ "my_tools.my_tool.MyTool" ], "local_workspace_dependencies": [ { "name": "tool-compat-shared", "path": "../tool-compat-shared" } ] } ``` Rules: - `name` is the distribution/project name. - `path` is resolved relative to the plugin descriptor directory. - The dependency target must be pip-installable (`pyproject.toml` or `setup.py`). - Declared local workspace dependencies are fingerprinted with the parent plugin, so a sibling dependency change invalidates the parent plugin cache entry. - The loader installs declared local workspace dependencies explicitly and keeps normal pip cache behavior enabled. If a plugin package declares sibling `file://` dependencies in packaging metadata, it must also declare them in `local_workspace_dependencies`. This keeps local plugin cache invalidation explicit and avoids core hardcoding workspace package names. Release and cloud-runtime bundles also use this metadata. In the full bundled-Python package, the default-config plugin closure is installed into the packaged Python runtime and recorded in the package-owned `preinstalled-plugins.json` manifest. The visible copied plugin source descriptors remain unchanged, so copied/forked plugins behave like normal local path plugins outside the installed package. Optimized PyInstaller and cloud runtime images still use selected preinstalled descriptor/resource metadata with `"preinstalled": true`. The cloud runtime Docker build uses declared local workspace dependencies to copy the selected source closure into the build stage, create Linux-compatible wheels, and install them into the final image. At runtime, `path:${env:BUILTIN_PLUGINS}/` should not trigger a plugin cache install for those bundled/default plugins. ## Installing `agent_plugin.json` as a data file If using setuptools via `pyproject.toml`: ``` [tool.setuptools.data-files] "" = ["agent_plugin.json"] ``` Or with `setup.py`: ``` from setuptools import setup setup( name="my-provider", packages=["my_provider"], data_files=[("", ["agent_plugin.json"])], ) ``` ## Application config: plugin spec forms Applications support multiple ways to reference plugins: ### 1) Dotted class path (no install) ``` { "plugins": ["plugins.openai_provider.OpenAICompatibleProvider"] } ``` ### 2) Local repo (descriptor-driven; recommended) ``` { "plugin_cache_dir": "~/.crystal/cache/plugins", "plugins": ["path:/abs/or/rel/to/my-provider-repo"] } ``` ### 3) Git repo (descriptor-driven; recommended) ``` { "plugin_cache_dir": "~/.crystal/cache/plugins", "plugin_policy": {"allow_remote": true}, "plugins": ["git+https://github.com/acme/my-provider.git#v1.0.0"] } ``` ### 4) Verbose object spec (single explicit entry) ``` { "plugin_cache_dir": "~/.crystal/cache/plugins", "plugins": [ { "path": "/abs/or/rel/to/repo", "entry": "my_provider.provider.MyProvider", "subdirectory": "." } ] } ``` ## Placeholders - `${env:VAR}` can be embedded anywhere within strings. - `${file:...}` is supported as a whole-string placeholder only. ## Policy & security notes - Git installs run arbitrary code via `pip install`. - Prefer pinned refs (tag/commit) and host allowlists. - Remote git URLs are typically disabled by default; enable explicitly with `plugin_policy.allow_remote`. - Python dependency installs use `plugin_policy.pip_cache_dir` as pip's cache. When omitted, it defaults to a sibling `pip` directory next to the plugin install cache, for example `~/.crystal/cache/pip` when `plugin_cache_dir` is `~/.crystal/cache/plugins`. - The bundled-Python runtime package is the supported distribution for plugin-rich installs. It uses a normal packaged Python interpreter for dynamic plugin installation. PyInstaller/frozen artifacts are config-specific optimized outputs and do not support arbitrary dynamic plugin installation by default. - In runtimes that support dynamic plugin installation, set `CRYSTAL_PLUGIN_PYTHON` or `plugin_policy.python_executable` to choose the interpreter used for `python -m pip install --target ...`. ## Reference configs See application examples: - `application/python/agent_terminal_app/config_echo_app.json` - `application/python/agent_terminal_app/config_openrouter_app_layer.json` # Execution order (providers, extensions, features, tools) This page documents the runtime ordering of provider/plugins during a request. It is shared across plugin kinds and matches the implementation in: - `core/python/agent_core/core.py` (`_pre_request`, `send_request`, `send_request_stream`) - `core/python/agent_core/plugin/provider.py` (`ProviderWrapper`) ## Dependency resolution (models + tags) Before running a request, the core resolves which provider extensions, features, and tools are enabled for the **effective config**. This same resolution is also used when computing UI schema (`AgentCore.get_ui_schema`) and tool schemas (`AgentCore.get_tool_schemas`). High-level flow: 1. Select provider for `config`. 1. Compute `models` for the config. 1. Compute `tags` from the provider and enabled plugins. 1. Filter extensions/features/tools using `is_enabled()`, `required_tags()`, and `forbidden_tags()`. 1. Apply `force_enabled_plugins` config override. 1. Repeat steps (3–5) until the enabled set stops changing. ### Plugin enablement methods Plugins can control their enablement through several mechanisms: - **`required_tags()`**: Tags that must ALL be present for the plugin to be enabled. - **`forbidden_tags()`**: Tags that must NOT be present for the plugin to be enabled. - **`is_enabled(config, tags, models, context)`**: Custom method returning `True`/`False`/`None`. - Returns `True`: plugin is enabled (bypasses tag checks) - Returns `False`: plugin is disabled - Returns `None`: fall back to `required_tags()`/`forbidden_tags()` check - **`force_enabled_plugins` config**: List of plugin IDs to always enable, overriding `is_enabled()` results. - **`default_enabled` attribute**: Class attribute (`True`/`False`) for opt-in plugins. Set to `False` for plugins that require explicit enablement via `enabled_plugins` config. Pseudocode (simplified): ``` provider = select_provider(config) models = provider.get_models(config) for ext in provider.extensions: models = ext.get_models(config, models) for feat in features: models = feat.get_models(config, models) enabled_exts = list(provider.extensions) enabled_feats = list(features) enabled_tools = list(tools) force_enabled = set(config.get("force_enabled_plugins", [])) def check_plugin_enabled(plugin, tags, models, context): # force_enabled_plugins overrides everything if plugin.name in force_enabled: return True # Check is_enabled method result = plugin.is_enabled(config, tags, models, context) if result is True: return True if result is False: return False # Fall back to tag-based check if not set(plugin.required_tags()).issubset(tags): return False if set(plugin.forbidden_tags()).intersection(tags): return False return True while True: tags = set() tags |= set(provider.get_tags(config, models)) tags |= set().union(*(ext.get_tags(config, models) for ext in enabled_exts)) tags |= set().union(*(feat.get_tags(config, models) for feat in enabled_feats)) tags |= set().union(*(tool.get_tags(config, models) for tool in enabled_tools)) context = { "enabled_plugin_ids": { "extensions": [e.name for e in enabled_exts], "features": [f.name for f in enabled_feats], "tools": [t.name for t in enabled_tools], }, "disabled_plugins": config.get("disabled_plugins", []), "force_enabled_plugins": list(force_enabled), } next_exts = [ext for ext in enabled_exts if check_plugin_enabled(ext, tags, models, context)] next_feats = [feat for feat in enabled_feats if check_plugin_enabled(feat, tags, models, context)] next_tools = [tool for tool in enabled_tools if check_plugin_enabled(tool, tags, models, context)] if next_exts == enabled_exts and next_feats == enabled_feats and next_tools == enabled_tools: break enabled_exts, enabled_feats, enabled_tools = next_exts, next_feats, next_tools ``` Runtime references: - model caching + validation: `core/python/agent_core/core.py` (`_get_models_for_config`) - tag-based dependency loop: `core/python/agent_core/core.py` (`_resolve_plugins_for_config`) ## Request-time ordering (high level) For a request, the core runs: 1. **Tool schema injection** 1. **Init chain** (shared provider state) 1. **Stateless transforms** (core ↔ native) 1. **Stateful per-turn initialization** (`initialize_request` chain) 1. **I/O** (`call_api` or streaming) 1. **Finalize chain** 1. **Convert finals back to core** ## Concrete order (non-streaming) Pseudocode: ``` # tools tool_states = [tool.init(config) for tool in tools] tool_schemas = sum([tool.get_tool_schemas(st) for tool, st in zip(tools, tool_states)], []) provider_config = {**config, "tools": tool_schemas} # init chain (shared state) state = provider.init(provider_config) state = ext1.init(provider_config, state) state = ext2.init(provider_config, state) state = feat1.init(provider_config, state) # core -> native native = provider.to_native_messages(core_messages, state) native = ext1.to_native_messages(core_messages, native) native = feat1.to_native_messages(core_messages, native) # stateful per-turn setup native, state = provider.initialize_request(native, state) native, state = ext1.initialize_request(native, state) native, state = feat1.initialize_request(native, state) # I/O partials, finals_1, native, state = provider.call_api(native, state) # finalize chain (provider then extensions then features) finals_2, native, state = provider.finalize(native, state) finals = finals_1 + finals_2 finals, native, state = ext1.finalize(finals, native, state) finals, native, state = feat1.finalize(finals, native, state) # native -> core core_finals = provider.from_native_messages(finals, state) core_finals = ext1.from_native_messages(finals, core_finals) core_finals = feat1.from_native_messages(finals, core_finals) ``` Notes: - Provider extensions run next to the provider on the hot path. - Features do **not** run per-chunk; they only run on the cold path. ## Concrete order (streaming) Differences: - Provider does `stream_api` and yields raw chunks. - For each chunk, provider runs `process_chunk` then extensions run `process_chunk`. - Final message is typically emitted in `finalize` from accumulated state. Pseudocode for the streaming part: ``` for chunk in provider.stream_api(native, state): partials, finals, native, state = provider.process_chunk(chunk, native, state) partials, finals, native, state = ext1.process_chunk(chunk, partials, finals, native, state) # core converts partials to core messages for UI streaming finals, native, state = provider.finalize(native, state) finals, native, state = ext1.finalize(finals, native, state) finals, native, state = feat1.finalize(finals, native, state) ``` # Configuration schema (`get_config_schema`) Plugins can declare their configuration keys via `get_config_schema()`. This schema is used for **discovery and documentation** (for example, the terminal app’s `/help config` output and HTTP endpoints that expose schema), not for runtime validation. Runtime reference: - Flattening/aggregation: `core/python/agent_core/core.py` (`AgentCore.get_config_schema`) - Terminal rendering: `application/python/agent_terminal_app/terminal.py` (`/help config`) ## What plugins return `get_config_schema()` returns a **dict mapping top-level config keys → entry metadata**. Minimal example: ``` from typing import Any def get_config_schema(self) -> dict[str, Any]: return { "model": {"type": "string", "required": True, "description": "Model id"}, "timeout": {"type": "number", "default": 60, "description": "Request timeout (seconds)"}, } ``` Each value must be a dict. The core does not enforce a strict schema; it simply preserves the entry dicts. ## What the core exposes `AgentCore.get_config_schema()` returns a **flattened list** combining every registered provider, provider extension, feature, and tool. For each entry, the core adds: - `key`: the config key (from the returned dict’s key) - `plugin`: the contributing plugin id/name (provider/extension/feature/tool) All other fields are passed through unchanged. Because the result is flattened, multiple plugins may contribute entries for the same `key` (they are returned as separate list elements). ## Fields used by built-in apps The terminal app’s `/help config` currently displays: - `key` (from the core) - `plugin` (from the core) - `type` (string; if missing prints `?`) - `default` (if present) - `description` (if present) Everything else is treated as metadata for other clients. ## Recommended entry fields Even though the core does not validate these, using a consistent, JSON-schema-like shape makes schemas easier to understand and consume. Common fields: - `type`: recommended values are `string`, `number`, `integer`, `boolean`, `object`, `array` - `required`: boolean - `default`: JSON-serializable default value - `description`: human-readable text used by `/help config` Notes: - The core and terminal treat `type` as **display metadata** (a string); it is not validated. - Some built-in schemas use informal values like `int` / `bool`. Prefer JSON-schema-like names for consistency, but clients should not rely on a closed set. Optional fields (passed through; may be consumed by other UIs): - `enum`: list of allowed values - `items`: schema dict for array items - `properties`: schema dict for object properties Example with `enum`: ``` def get_config_schema(self) -> dict[str, Any]: return { "reasoning_effort": { "type": "string", "enum": ["low", "medium", "high"], "description": "Control reasoning depth (model-specific)", } } ``` Example with `items`: ``` def get_config_schema(self) -> dict[str, Any]: return { "allowed_paths": { "type": "array", "items": {"type": "string"}, "default": ["."], "description": "Allowlist of base paths", } } ``` ## Relation to UI schema (`get_ui_elements`) `get_config_schema()` describes what keys exist and their defaults. Interactive configuration in the terminal (via `/set`) is driven by `get_ui_elements(config, tags, models)` (see `plugins/docs/ui-elements.md`). It’s common to keep `get_config_schema()` and `get_ui_elements()` aligned (same keys, same defaults), but they are independent. Because `get_ui_elements` receives `tags` and `models`, the set of UI-exposed keys may vary by provider capabilities and the selected model. This is useful for hiding knobs that do not apply, but it also means: - `/help config` may list keys that do not appear in `/set` for a given session, and - keys may appear/disappear when you change the session’s effective config. ## Application-level schema (terminal app) The terminal app also exposes and renders a separate schema for keys under `config["application"]` (settings consumed by the terminal app itself, not by plugins). See `application/python/agent_terminal_app/terminal_app.py` (`TerminalApplication.get_application_config_schema`). # UI elements (`get_ui_elements`) Plugins can optionally expose UI metadata via `get_ui_elements(config, tags, models) -> list[dict]`. The core treats UI element dictionaries as **opaque** and flattens them via `AgentCore.get_ui_schema(config)`. ## Signature variations ### Core plugins (Provider, Extension, Feature, Tool) ``` def get_ui_elements( self, config: Dict[str, Any], tags: List[str], models: List[Dict[str, Any]], ) -> List[Dict[str, Any]]: ... ``` The core passes: - `config`: effective configuration for the current agent/request - `tags`: capability tags computed for this config (provider + enabled plugins) - `models`: model descriptors computed for this config ### Application plugins ``` def get_ui_elements( self, state: Dict[str, Any], config: Optional[Dict[str, Any]] = None, tags: Optional[List[str]] = None, models: Optional[List[Dict[str, Any]]] = None, ) -> List[Dict[str, Any]]: ... ``` Application plugins receive `state` first (their internal state), followed by optional context parameters. When called via session-scoped endpoints, `config` contains the session's effective configuration, enabling config-aware UI filtering. For backward compatibility, plugin adapters will also accept a legacy `get_ui_elements()` implementation with no arguments, but new plugins should prefer the context-aware signature. Runtime references: - Core plugins: `core/python/agent_core/types.py` (`BasePlugin.get_ui_elements`) - Application plugins: `core/python/agent_app/app_plugins.py` (`ApplicationPlugin.get_ui_elements`) - UI schema aggregation: `core/python/agent_core/core.py` (`AgentCore.get_ui_schema`) ______________________________________________________________________ ## UI element aggregation When `AgentCore.get_ui_schema()` is called, the core: 1. Resolves enabled plugins for the given config 1. Calls `get_ui_elements()` on each plugin (provider, extensions, features, tools) 1. Normalizes each element: 1. Sets `ui_type` to `"config"` if missing or falsy 1. Adds `plugin` field with the contributing plugin name 1. For `session_action`/`message_action` elements from extensions/features, sets `action_owner` field automatically 1. **Deduplicates config elements**: Elements with `ui_type == "config"` are collected by `key` and only the last definition for each key is kept 1. Appends deduplicated config elements at the end This deduplication means later plugins can override earlier config elements with the same key. ### Automatic `action_owner` assignment For `session_action` and `message_action` elements, the core automatically sets `action_owner`: | Plugin type | `action_owner` value | | ------------------ | ------------------------------------- | | Provider extension | `"provider_extension"` | | Feature plugin | `"feature"` | | Application plugin | Not set (defaults to `"application"`) | This allows frontends to route actions to the correct handler. ______________________________________________________________________ ## UI element types (`ui_type`) The `ui_type` field determines how frontends interpret and render the element. When `ui_type` is missing or falsy, the element is treated as `"config"`. ### Configuration elements (`ui_type == "config"` or omitted) Used for configuration inputs in settings UIs. Frontends typically render these as text fields, checkboxes, or dropdowns. ``` def get_ui_elements(self, config, tags, models): return [ {"type": "text", "key": "model", "label": "Model"}, {"type": "checkbox", "key": "debug_stream", "label": "Debug stream"}, { "type": "select", "key": "reasoning_effort", "label": "Reasoning effort", "options": ["low", "medium", "high"], }, ] ``` #### Common fields | Field | Type | Description | | ------------- | ------- | -------------------------------------------------------------------------------------- | | `key` | string | Configuration key (required) | | `type` | string | Input type: `text`, `checkbox`, `select`, `dropdown`, `multiline`, `number` | | `label` | string | Human-readable label | | `description` | string | Optional help text | | `options` | array | For `select`/`dropdown`: `["value", ...]` or `[{"value": "...", "label": "..."}, ...]` | | `default` | any | Default value shown when no override or config value exists | | `required` | boolean | Whether the field is required | | `placeholder` | string | Placeholder text for text inputs | | `config_path` | string | Dotted path to nested config value (e.g., `"provider.api_key"`) | | `condition` | object | Optional visibility condition (see below) | #### Effective value resolution Frontends resolve the effective value for a config element using this priority: 1. **Session override**: `session.metadata.overrides[key]` (highest priority) 1. **Base config value**: `base_config[key]` 1. **Nested config path**: `base_config` resolved via `config_path` (e.g., `"provider.api_key"` → `base_config.provider.api_key`) 1. **UI element default**: `element.default` 1. **Schema default**: `config_schema[key].default` (lowest priority) The frontend SDK tags each setting with a source indicator: - `"override"`: Value comes from session overrides - `"config"`: Value comes from base config - `"ui_default"`: Value comes from element's `default` field - `"schema_default"`: Value comes from config schema default - `"none"`: No value found #### Input types | Type | Description | | ----------- | ------------------------------------------ | | `text` | Single-line text input | | `multiline` | Multi-line text area | | `checkbox` | Boolean toggle | | `select` | Chip-style selection (all options visible) | | `dropdown` | Dropdown menu (collapsed until clicked) | | `number` | Numeric input | ### Message footers (`ui_type == "message_footer"`) Per-message footer fields rendered under each message bubble. Frontends extract the specified data path from each message's metadata. ``` def get_ui_elements(self, config, tags, models): return [ { "ui_type": "message_footer", "data": "metadata.total_cost", "template": "Cost: {{data}}", } ] ``` | Field | Type | Description | | ----------- | ------ | ------------------------------------------------------------------------ | | `data` | string | Dotted JSON path into the message object (e.g., `"metadata.total_cost"`) | | `template` | string | Optional template string; `{{data}}` is replaced with the resolved value | | `condition` | object | Optional visibility condition (see below) | ### Status bar fields (`ui_type == "status_bar"`) Persistent fields displayed in the status bar, typically derived from the last assistant message. ``` def get_ui_elements(self, config, tags, models): return [ { "ui_type": "status_bar", "data": "metadata.cached_tokens", "template": "Cached: {{data}}", } ] ``` Fields are the same as `message_footer`. ### Session actions (`ui_type == "session_action"`) Session-scoped actions displayed in session menus or toolbars. Used by application plugins to expose actions like "Compact range", "Export session", etc. ``` def get_ui_elements(self, state, config, tags, models): # Config-aware filtering if config is not None and not self._is_enabled(config): return [] return [ { "ui_type": "session_action", "id": "compact_range", "label": "Compact range", "icon": "archive", "order": 45, "action_id": "compact_range", "fixed_params": {}, "param_map": { "session_id": "$session.session_id", "start": "$dialog.start", "end": "$dialog.end", }, "dialog": { "kind": "form", "title": "Compact range", "message": "Select a range of messages to compact.", "inputs": [ {"name": "start", "type": "integer", "label": "Start"}, {"name": "end", "type": "integer", "label": "End"}, ], }, } ] ``` | Field | Type | Description | | -------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------ | | `id` | string | Unique element identifier | | `label` | string | Human-readable label | | `icon` | string | Optional icon name (frontend-specific) | | `order` | number | Sort order (lower appears first) | | `action_id` | string | Action to execute (from `get_actions`) | | `action_owner` | string | Action handler: `"application"` (default), `"feature"`, or `"provider_extension"`; automatically set by core for extension/feature plugins | | `fixed_params` | object | Parameters passed verbatim | | `param_map` | object | Parameter mappings from context (see below) | | `dialog` | object | Dialog configuration (see below) | | `condition` | object | Optional visibility condition (see below) | #### Parameter mappings (`param_map`) Maps action parameters to context values using `$` prefixes: | Expression | Source | | ---------------------- | ----------------------------------- | | `$session.session_id` | Current session ID | | `$session.agent_id` | Current agent ID | | `$message.index` | Message index (for message actions) | | `$dialog.start` | User input from dialog | | `$dialog.instructions` | User input from dialog | #### Dialog configuration ``` "dialog": { "kind": "form", # Currently only "form" is supported "title": "Compact range", "message": "Optional description shown in dialog.", "inputs": [ { "name": "start", # Parameter name "type": "integer", # Input type "label": "Start index", # Label "required": False, "placeholder": "e.g. 0 or -10", }, ], } ``` ### Message actions (`ui_type == "message_action"`) Message-scoped actions displayed on individual message bubbles. Similar to `session_action` but with additional `$message.*` parameter mappings. ``` { "ui_type": "message_action", "id": "compact_up_to_here", "label": "Compact up to here", "icon": "archive", "order": 25, "action_id": "compact_range", "fixed_params": {"start": 0, "end_inclusive": True}, "param_map": { "session_id": "$session.session_id", "end": "$message.index", }, "dialog": {...}, } ``` ### Session list fields (`ui_type == "session_list_field"`) Fields displayed in session list views. Extracted from session metadata for summary display. ``` { "ui_type": "session_list_field", "key": "message_count", "label": "Messages", "data": "metadata.message_count", "template": "{{data}} messages", "order": 10, } ``` ### Composer attachments (`ui_type == "composer_attachment"`) Attachment UI hints for the message composer. Frontends use these to display attachment options. ``` { "ui_type": "composer_attachment", "key": "myprovider:attachment:image", "attachment_type": "image", "supports_url": True, "supported_file_types": ["png", "jpg", "gif"], "label": "Image", } ``` ### Model picker (`ui_type == "model_picker"`) Model selection UI for providers that support model switching. ``` { "ui_type": "model_picker", "key": "model", "label": "Select Model", "order": 10, } ``` ______________________________________________________________________ ## Conditional visibility (`condition`) UI elements can include a `condition` field that determines visibility based on session or message state. ``` { "type": "checkbox", "key": "enable_reasoning", "label": "Enable reasoning", "condition": { "all": [ {"path": "metadata.supports_reasoning", "eq": True}, ], }, } ``` ### Condition operators | Operator | Description | | -------- | --------------------------------- | | `all` | All child conditions must be true | | `any` | Any child condition must be true | | `not` | Negate child condition | | `path` | JSON path to evaluate | | `eq` | Equality check | | `exists` | Path exists | | `empty` | Path is empty or missing | Example combining operators: ``` "condition": { "all": [ {"path": "metadata.reasoning_available", "eq": True}, {"not": {"path": "metadata.reasoning_disabled", "exists": True}}, ], } ``` ______________________________________________________________________ ## Config-aware filtering Plugins can filter UI elements based on configuration. This is especially useful for application plugins that may need to hide actions when features are disabled. ``` def get_ui_elements(self, state, config, tags, models): # When config is None (global endpoint), return all elements if config is None: return self._get_all_ui_elements() # When config is provided (session-scoped), filter based on enablement if not self._is_enabled(config): return [] return self._get_enabled_ui_elements(config) ``` For session-scoped endpoints, `config` contains the session's effective configuration (base config merged with session overrides), enabling per-session UI customization. ______________________________________________________________________ ## Model-driven inputs The `models` argument is a list of model descriptors computed for the effective config. Use this to build model selection UIs. ``` def get_ui_elements( self, config: dict[str, Any], tags: list[str], models: list[dict[str, Any]], ) -> list[dict[str, Any]]: options: list[dict[str, str]] = [] for m in models: model_id = m.get("id") if not isinstance(model_id, str) or not model_id: continue label = m.get("name") label = label if isinstance(label, str) and label else model_id options.append({"value": model_id, "label": label}) if options: return [ { "type": "select", "key": "model", "label": "Model", "options": options, } ] return [{"type": "text", "key": "model", "label": "Model id"}] ``` Each model descriptor must include `"id": str`. Additional keys like `"name"` or capability metadata are provider-defined. ______________________________________________________________________ ## Capability-aware UI The `tags` argument contains capability/environment tags computed for the effective config. Use tags to gate elements based on provider capabilities. ``` def get_ui_elements( self, config: dict[str, Any], tags: list[str], models: list[dict[str, Any]], ) -> list[dict[str, Any]]: # Only show reasoning control if provider supports it if "supports_reasoning" not in tags: return [] return [ { "type": "checkbox", "key": "enable_reasoning", "label": "Enable reasoning", } ] ``` ______________________________________________________________________ ## Frontend type reference Frontends consume UI elements via TypeScript types defined in the frontend SDK: ``` // packages/frontend-sdk/src/types/uiSchema.ts interface UiSchemaElement { ui_type?: string; key?: string; type?: string; label?: string; description?: string; options?: any; data?: string; template?: string; metadata?: Record; condition?: unknown; plugin?: string; [k: string]: any; } // packages/frontend-sdk/src/types/plugins.ts interface ActionUiElement { ui_type: string; plugin: string; id: string; label: string; icon?: string; order?: number; action_id: string; action_owner?: string; // "application" | "feature" | "provider_extension" fixed_params?: Record; param_map?: Record; dialog?: any; metadata?: Record; } interface SessionListFieldElement { ui_type: string; plugin: string; key?: string; label?: string; data: string; template?: string; order?: number; } // Union type for all application-level UI elements type ApplicationUiElement = ActionUiElement | SessionListFieldElement | Record; ``` ______________________________________________________________________ ## Custom UI types Applications may support additional `ui_type` values not documented here. If an application does not recognize a `ui_type`, it should ignore the element. Example (hypothetical custom type): ``` def get_ui_elements(self, config, tags, models): return [ { "ui_type": "custom_widget", "key": "my_widget", "custom_field": "value", "label": "Custom Widget", } ] ``` Frontends should gracefully handle unknown `ui_type` values by either ignoring them or passing them through for application-specific rendering. ______________________________________________________________________ ## HTTP endpoints Frontends retrieve UI schemas via HTTP endpoints: ### Core plugin UI schema ``` GET /sessions/{session_id}/ui-schema ``` Returns UI elements from core plugins (provider, extensions, features, tools). Filters by `ui_type` for different use cases: - `ui_type == "config"` or omitted: Configuration inputs - `ui_type == "message_footer"`: Message footer fields - `ui_type == "status_bar"`: Status bar fields ### Application plugin UI schema ``` GET /sessions/{session_id}/application/ui-schema ``` Returns UI elements from application plugins. Used for: - `ui_type == "session_action"`: Session-scoped actions - `ui_type == "message_action"`: Message-scoped actions - `ui_type == "session_list_field"`: Session list fields Optional query parameter `?plugin=name` filters elements by plugin. ______________________________________________________________________ ## See also - [Provider plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/provider-plugins/index.md) - Provider implementation guide - [Application plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/application-plugins/index.md) - Application plugin guide - [Feature plugins](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/feature-plugins/index.md) - Feature plugin guide - [Plugin actions](https://docs.cl-static-test.dynamicprogrammingsolutions.com/plugin-development/actions/index.md) - Action definitions and lifecycle # Testing & validation This page is shared across plugin kinds. ## Plugin repo tests (recommended primary workflow) For plugin development, prefer a tight loop: 1. edit plugin code 1. run `pytest` 1. iterate Example (template provider repo): ``` python -m pip install -e core/python python -m pip install -e "plugins/template-python-provider[dev]" pytest plugins/template-python-provider/tests -q ``` ## Repo-level tests From repo root: - Core tests: `pytest core/python/tests -q` - Example tests: `pytest core/python/examples/tests -qs` Markers used across repo tests: - `-m openrouter` (requires `OPENROUTER_API_KEY`) – used by OpenRouter plugin + OpenRouter-marked examples. - `-m integration` – integration tests that may hit real services. - `-m ollama` – tests that hit a local Ollama instance. ## Lint/format/typecheck From repo root: ``` ruff check core/python/agent_core core/python/tests core/python/examples core/python/plugins black core/python/agent_core core/python/tests core/python/examples core/python/plugins pyright ``` See also: `AGENTS.md`. # Specialized Task Agents And Real-Model Validation Specialized task agents are default-config agents designed to complete a narrow project task reliably. They combine a concise developer/system message, a task-specific guide in bundled docs, focused tool access, and a real-model test that proves the agent can complete the task in a realistic fixture. This pattern is useful when a product feature depends on model behavior, not only deterministic code. The goal is not to freeze exact model output. The goal is to verify that the shipped default config gives a capable model enough context, tools, and documentation to complete the intended workflow. ## Recommended Shape Add the specialized agent to the default config, not only to a test-local config. Installed-bundle tests should exercise the same shipped defaults users receive. Keep the developer/system message short. It should say what the agent is for, state important constraints, and point to a bundled guide by absolute path. Put step-by-step instructions, examples, schemas, and troubleshooting details in the guide, not in the developer message. Use config variable substitution for doc paths. For docs shipped in plugins, prefer paths under `${env:BUILTIN_PLUGINS}` so both source checkouts and installed bundles can resolve them. Example structure: ``` { "mixins": { "example_task_message": { "system_message_enabled": true, "system_message": { "template": "Read the task guide at `{{TASK_GUIDE_PATH}}` before changing files. Follow the guide workflow, use project docs to infer project-specific details, run cheap checks before final validation, and do not print secrets.", "variables": { "TASK_GUIDE_PATH": { "text": "${env:BUILTIN_PLUGINS}/example-plugin/docs/task-guide.md" } } } } }, "agents": { "example-task-author": { "mixin_refs": ["codex_developer_message", "codex_mcp_defaults", "example_task_message"], "provider": "openai", "model": "gpt-5.4-mini", "allowed_paths": [".", "..", "${env:BUILTIN_PLUGINS}/example-plugin"], "plugins": ["path:${env:BUILTIN_PLUGINS}/codex-tools"], "shell_tool_mode": "auto" } } } ``` For harder fixtures, use a stronger model only in the specific test scenario. Keep the cheaper model as the shipped default when it is good enough for normal use. ## Authoring Guide The guide should include: - the task goal and expected output files - the minimum schemas or file formats the agent must generate - the intended workflow - fast checks the agent should run before final validation - examples or references to existing standard implementations - constraints around secrets, idempotence, generated files, and project-specific guidance - when plugin source may be read as a fallback For project-specific tasks, tell the agent to read normal project guidance such as `AGENTS.md`, README files, package manifests, and tests. Do not require the project guidance to be task-specific; it should remain the ordinary project setup and testing documentation. ## Installed-Bundle Real-Model Tests Real-model tests for specialized agents should use the installed bundle when the feature must work for installed users. The test should: - extract the bundle archive - start the bundled CLI/server with the default config - create a realistic fixture project - send a generic user request to the specialized agent - allow only the fixture paths and any bundled docs/source paths the agent may need - override auth/model only through normal config overrides - assert final artifacts and run independent validation after the model stops The user prompt should stay generic. Project details should live in the fixture project docs and files. Task mechanics should live in the bundled guide. When ChatGPT login auth is supported in a test, make it explicit. For example, use an env var such as `CHATGPT_CREDENTIALS` and set `auth_mode: "chatgpt"` plus the credentials path in the request overrides. Do not silently fall back from a requested credential mode to API-key mode. ## Process Telemetry Capture process-quality signals separately from final pass/fail: - total tool calls and tool calls by name - whether the agent read the guide - whether it read plugin source - whether it read example tests - generated files - syntax checks or cheap local tests attempted - final validation attempted - command failures observed Use both soft and hard budgets: - soft tool-call limit: warn or report diagnostics, but allow a valid solution - hard tool-call limit: stop or fail the request - soft timeout: warn and continue so downstream validation can still be observed - hard timeout: fail and terminate the run Soft-budget failures are instruction-tuning signals. Hard-budget failures are test failures. ## Fixture Design Fixtures should be small but realistic. Prefer real files, package manifests, git repositories, and tests over mocked internals. For multi-repository or multi-service workflows, initialize fixture repositories during test setup and clean them up through the test temp directory. The assertion should check behavior, not exact text. Good assertions include: - required files exist and are executable - generated JSON matches expected shape - generated scripts pass shell syntax checks - a local no-Docker harness passes when custom transfer/setup behavior is generated - the final public validation command passes Avoid test-only product shortcuts. If the realistic test exposes a product defect, keep the test realistic and fix the product code or document a follow-up task. ## Documentation And Completion Notes When the test exposes model confusion, update the guide first. Keep the developer message concise and generic. Record observed model behavior in task completion notes, including tool counts, timeouts, source reads, and remaining product issues. If a passing test still exceeds a soft budget, treat it as functionally passing but performance-suspect. Do not hide product startup or dependency problems by only raising hard timeouts. # Built-In Plugins # Built-In Plugins Generated from `plugins/*/README.md`. - [bash-read-line-range](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/bash-read-line-range/index.md) - [bash-send-kindle-icloud](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/bash-send-kindle-icloud/index.md) - [chatgpt-auth-app](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/chatgpt-auth-app/index.md) - [claude-tools](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/claude-tools/index.md) - [cloud-agent-app](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/cloud-agent-app/index.md) - [codex-tools](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/codex-tools/index.md) - [feature-file-context](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/feature-file-context/index.md) - [feature-request-options](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/feature-request-options/index.md) - [feature-system-message](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/feature-system-message/index.md) - [feature-web-context](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/feature-web-context/index.md) - [gemini-compaction-app](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/gemini-compaction-app/index.md) - [gemini-compaction-feature](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/gemini-compaction-feature/index.md) - [gemini-tools](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/gemini-tools/index.md) - [generate-title-app](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/generate-title-app/index.md) - [grok-tools](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/grok-tools/index.md) - [js-class-tool](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/js-class-tool/index.md) - [js-echo-tool](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/js-echo-tool/index.md) - [js-fs-tools](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/js-fs-tools/index.md) - [js-multi-tools](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/js-multi-tools/index.md) - [js-single-file-tool](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/js-single-file-tool/index.md) - [kimi-tools](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/kimi-tools/index.md) - [mcp-runtime-app](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/mcp-runtime-app/index.md) - [mcp-shared](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/mcp-shared/index.md) - [message-export-actions](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/message-export-actions/index.md) - [openai_responses](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/openai_responses/index.md) - [openrouter](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/openrouter/index.md) - [openrouter_responses](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/openrouter_responses/index.md) - [qwen-tools](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/qwen-tools/index.md) - [rebuild-native-app](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/rebuild-native-app/index.md) - [reference-openai-compatible-provider](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/reference-openai-compatible-provider/index.md) - [session-actions-app](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/session-actions-app/index.md) - [session-asset-attachments](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/session-asset-attachments/index.md) - [session-message-count-meta-app](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/session-message-count-meta-app/index.md) - [session-ordering-buckets-app](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/session-ordering-buckets-app/index.md) - [session-pinned-app](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/session-pinned-app/index.md) - [session-timestamp-meta-app](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/session-timestamp-meta-app/index.md) - [session-title-app](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/session-title-app/index.md) - [skill-shared](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/skill-shared/index.md) - [summarize-range-app](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/summarize-range-app/index.md) - [template-bash-tool](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/template-bash-tool/index.md) - [template-js-multi-tools](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/template-js-multi-tools/index.md) - [template-python-provider](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/template-python-provider/index.md) - [template-python-tools](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/template-python-tools/index.md) - [timestamp-extension](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/timestamp-extension/index.md) - [tool-compat-shared](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/tool-compat-shared/index.md) - [web-fetch-shared](https://docs.cl-static-test.dynamicprogrammingsolutions.com/built-in-plugins/web-fetch-shared/index.md) # bash-read-line-range Generated from `plugins/bash-read-line-range/README.md`. A minimal bash tool plugin that reads a line range from a file using `sed`, with optional line numbers via `nl`. This is intended as a simple template for writing new bash tools. ## Load the plugin In your app config, add the plugin and enable bash tools: ``` { "plugins": [{"bash_tool": {"path": "plugins/bash-read-line-range"}}], "plugin_policy": {"allow_bash_tools": true} } ``` ## Tool - Tool name: `read_line_range` - Arguments: - `path` (string, required) - `start_line` (int, required; 1-based, inclusive) - `end_line` (int, required; 1-based, inclusive) - `show_line_numbers` (bool, optional; default `false`) ## Implementation notes - Uses `args_mode=positional`. - When `show_line_numbers=true`, runs `nl -ba | sed -n ',p'`. - Otherwise runs `sed -n ',p' `. To create a new tool, copy `read_line_range.bash`, change the `schema` output (`id`, `tools[0].function.name`, and parameters), then update `run`. ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # bash-send-kindle-icloud Generated from `plugins/bash-send-kindle-icloud/README.md`. Bash tool plugin that exposes three small tools: - `build_epub_from_markdown` - `send_to_kindle` - `send_markdown_to_kindle` The tools are intentionally separate: - `build_epub_from_markdown` creates an EPUB from local Markdown files - `send_to_kindle` sends an already-existing local file through iCloud Mail SMTP - `send_markdown_to_kindle` builds and sends in one step using a temporary EPUB The transport is specifically implemented with **iCloud Mail SMTP** on macOS. ## What this plugin does - Tool name: `build_epub_from_markdown` - builds an EPUB from an ordered list of local Markdown files using `pandoc` - defaults to a built-in Lua filter that converts local file links into plain text so EPUB readers do not show broken local links - Tool name: `send_to_kindle` - sends a local file as an email attachment - defaults such as recipient email and Keychain service are read from agent config keys - uses macOS Keychain to retrieve the iCloud app-specific password at send time - Tool name: `send_markdown_to_kindle` - builds an EPUB from an ordered list of Markdown files - sends that EPUB through the existing iCloud Mail SMTP path - removes the temporary EPUB afterward This plugin is a good fit if you want a model-callable EPUB builder, a separate model-callable sender, and a combined one-shot workflow while keeping SMTP secrets out of plain text config. ## Platform and dependency requirements This plugin is **specific to iCloud Mail on macOS** for the sending half. Required dependencies: - macOS - `bash` - `jq` - `pandoc` - `python3` - the `security` command-line tool from macOS - an iCloud Mail account - an Apple app-specific password stored in macOS Keychain ## iCloud and Kindle setup guide This plugin sends mail through iCloud SMTP. The safest setup is: - keep non-secret defaults in config or `.env` - keep the iCloud app-specific password only in macOS Keychain ### 1. Confirm iCloud Mail is enabled Make sure the Apple account you want to use has iCloud Mail enabled and that you can send mail from it normally. Useful Apple docs: - iCloud Mail overview: - iCloud Mail server settings: ### 2. Turn on two-factor authentication for the Apple account Apple requires two-factor authentication before you can create app-specific passwords. Apple docs: - Two-factor authentication: - App-specific passwords: ### 3. Generate an Apple app-specific password 1. Open the Apple account portal: 1. Sign in with the Apple account used for iCloud Mail. 1. Go to `Sign-In and Security`. 1. Open `App-Specific Passwords`. 1. Create a new password with a label such as `kindle-smtp` or `crystal-lattice-kindle`. 1. Copy the generated password immediately. Important: - this is not your normal Apple account password - the generated password is shown once - if you later rotate or revoke it, update the Keychain entry described below ### 4. Find your Kindle email address and approve the sender If you are sending to Kindle, you also need Amazon-side setup. Amazon docs: - Send to Kindle overview and supported formats: - Kindle personal document email help: Typical steps: 1. Find the Kindle `Send to Kindle` email address for the device or account. 1. Add your iCloud sender address to Amazon’s approved sender list. 1. Confirm Amazon accepts `EPUB` for your target workflow. ### 5. Store the app-specific password in macOS Keychain Store the generated password once using the macOS `security` CLI. Example: ``` security add-generic-password \ -U \ -a 'your-icloud-address@icloud.com' \ -s 'kindle-smtp' \ -w 'PASTE_THE_APP_SPECIFIC_PASSWORD_HERE' ``` Meaning of the fields: - `-a`: Keychain account name, usually your iCloud SMTP username - `-s`: Keychain service name, which should match your config - `-w`: the app-specific password value After that, the plugin reads the password from Keychain at send time. The raw password does not need to be stored in repo config, `.env`, or shell history again. ### 6. SMTP settings used by this plugin The plugin uses the standard iCloud SMTP settings documented by Apple: - host: `smtp.mail.me.com` - port: `587` - transport security: `STARTTLS` - username: your full iCloud email address - password: the app-specific password retrieved from Keychain ## Plugin installation Add the plugin to your config and enable bash tools: ``` { "plugin_policy": { "allow_bash_tools": true }, "mixins": { "shared_agent_defaults": { "send_to_kindle_smtp_user": "${env:ICLOUD_KINDLE_SMTP_USER}", "send_to_kindle_from_addr": "${env:ICLOUD_KINDLE_FROM_ADDR}", "send_to_kindle_default_to": "${env:ICLOUD_KINDLE_DEFAULT_TO}", "send_to_kindle_keychain_service": "${env:ICLOUD_KINDLE_KEYCHAIN_SERVICE}" } }, "plugins": [ "bash:${env:BUILTIN_PLUGINS}/bash-send-kindle-icloud" ], "agents": { "default": { "provider": "your-provider-id", "mixin_refs": ["shared_agent_defaults"], "disabled_plugins": ["send_to_kindle"] } } } ``` If your config already has a `plugins` list or `plugin_policy`, merge the new entries into the existing objects instead of replacing them wholesale. ## Agent configuration keys Only `send_to_kindle` reads resolved config keys from the agent config: - `send_to_kindle_smtp_user` - `send_to_kindle_from_addr` - `send_to_kindle_default_to` - `send_to_kindle_keychain_service` Recommended pattern: 1. keep raw values in `CONFIG_DIR/.env` 1. reference them from config using `${env:...}` 1. let the bash tool host pass those resolved config values into the tool Example `.env` values: ``` ICLOUD_KINDLE_SMTP_USER=your-icloud-address@icloud.com ICLOUD_KINDLE_FROM_ADDR=your-icloud-address@icloud.com ICLOUD_KINDLE_DEFAULT_TO=your-kindle-address@kindle.com ICLOUD_KINDLE_KEYCHAIN_SERVICE=kindle-smtp ``` Notes: - `send_to_kindle_from_addr` defaults to `send_to_kindle_smtp_user` if omitted. - `send_to_kindle_keychain_service` defaults to `kindle-smtp` if omitted. - The tool can override the recipient per call using the `to` argument. ## Tool arguments ### `build_epub_from_markdown` - `markdown_files` required: ordered list of Markdown file paths - `output_epub` required: output path for the generated EPUB - `title` optional - `author` optional - `language` optional, defaults to `en-US` - `toc_depth` optional, defaults to `2` - `lua_filter` optional, defaults to the plugin's built-in local-link filter The tool is intentionally simple. It only creates the EPUB. ### `send_to_kindle` - `attachment` required: local path to the file to attach - `subject` optional - `body` optional - `to` optional The tool rejects missing files. It does not enforce the `.epub` suffix itself. This keeps the helper simple and lets the downstream recipient system decide whether the attachment is acceptable. ### `send_markdown_to_kindle` - `markdown_files` required: ordered list of Markdown file paths - `title` optional - `author` optional - `language` optional, defaults to `en-US` - `toc_depth` optional, defaults to `2` - `lua_filter` optional, defaults to the plugin's built-in local-link filter - `output_filename` optional - `to` optional - `subject` optional, defaults to `title` when provided - `body` optional This tool builds a temporary EPUB, sends it, and removes it. It is the combined convenience tool for the same workflow that the other two tools can perform separately. When `title` is omitted, the tool uses the first Markdown heading it finds across `markdown_files` as the effective title. When `output_filename` is omitted, the tool generates a Kindle-visible attachment filename from the effective title by lowercasing it, replacing non-alphanumeric runs with `-`, and appending a local timestamp in `YYYYMMDD-HHMMSS` format plus `.epub`. Example autogenerated filename: - `auth-and-notifications-staged-rollout-20260516-231500.epub` ## Example tool calls ### Build an EPUB from multiple Markdown files ``` { "markdown_files": [ "/absolute/path/000-main.md", "/absolute/path/010-local-notification-polling.md", "/absolute/path/020-bridge-push-delivery.md", "/absolute/path/030-authentik-auth-integration.md" ], "output_epub": "/absolute/path/rollout.epub", "title": "Auth And Notifications Staged Rollout", "author": "OpenAI Codex" } ``` ### Send an already-built EPUB ``` { "attachment": "/absolute/path/rollout.epub" } ``` ### Override the recipient and subject when sending ``` { "attachment": "/absolute/path/to/book.epub", "to": "someone@example.com", "subject": "EPUB delivery test", "body": "Sending this EPUB from the agent tool." } ``` ### Build and send Markdown in one step ``` { "markdown_files": [ "/absolute/path/000-main.md", "/absolute/path/010-local-notification-polling.md", "/absolute/path/020-bridge-push-delivery.md" ], "title": "Auth And Notifications Staged Rollout", "author": "OpenAI Codex", "subject": "Auth And Notifications Staged Rollout" } ``` ### Build and send Markdown with an explicit attachment filename ``` { "markdown_files": [ "/absolute/path/000-main.md", "/absolute/path/010-local-notification-polling.md" ], "output_filename": "rollout-for-kindle.epub" } ``` ## Direct shell testing When invoking `send_to_kindle.bash` directly, there is no Python host to inject agent config values. For direct shell tests, export the expected `AGENT_TOOL_CONFIG_*` variables manually first. Example: ``` export AGENT_TOOL_CONFIG_SEND_TO_KINDLE_SMTP_USER='your-icloud-address@icloud.com' export AGENT_TOOL_CONFIG_SEND_TO_KINDLE_FROM_ADDR='your-icloud-address@icloud.com' export AGENT_TOOL_CONFIG_SEND_TO_KINDLE_DEFAULT_TO='your-kindle-address@kindle.com' export AGENT_TOOL_CONFIG_SEND_TO_KINDLE_KEYCHAIN_SERVICE='kindle-smtp' ``` Schema for the EPUB builder: ``` bash plugins/bash-send-kindle-icloud/build_epub_from_markdown.bash schema ``` Preview for the EPUB builder: ``` printf '%s' '{ "markdown_files": [ "/tmp/a.md", "/tmp/b.md" ], "output_epub": "/tmp/book.epub", "title": "Demo Book" }' | bash plugins/bash-send-kindle-icloud/build_epub_from_markdown.bash preview --args-json ``` Build an EPUB directly: ``` printf '%s' '{ "markdown_files": [ "/tmp/a.md", "/tmp/b.md" ], "output_epub": "/tmp/book.epub", "title": "Demo Book" }' | bash plugins/bash-send-kindle-icloud/build_epub_from_markdown.bash run --args-json ``` Schema for the combined tool: ``` bash plugins/bash-send-kindle-icloud/send_markdown_to_kindle.bash schema ``` Dry run for the combined tool: ``` export SEND_TO_KINDLE_DRY_RUN=1 printf '%s' '{ "markdown_files": [ "/tmp/a.md", "/tmp/b.md" ], "title": "Demo Book" }' | bash plugins/bash-send-kindle-icloud/send_markdown_to_kindle.bash run --args-json ``` Schema for the sender: ``` bash plugins/bash-send-kindle-icloud/send_to_kindle.bash schema ``` Preview for the sender: ``` printf '%s' '{"attachment":"/tmp/test.epub"}' | \ bash plugins/bash-send-kindle-icloud/send_to_kindle.bash preview --args-json ``` Dry run for the sender: ``` export SEND_TO_KINDLE_DRY_RUN=1 printf '%s' '{"attachment":"/tmp/test.epub"}' | \ bash plugins/bash-send-kindle-icloud/send_to_kindle.bash run --args-json ``` ## Why this is iCloud-specific The sending tool always uses: - SMTP host: `smtp.mail.me.com` - SMTP port: `587` - TLS/STARTTLS It expects an Apple app-specific password stored in Keychain, not a generic arbitrary SMTP configuration. If you later want a provider-neutral SMTP tool, that should be a separate plugin. ## Optional interpreter override If the host process uses an unexpected `python3`, you can override the helper interpreter with: ``` export SEND_TO_KINDLE_PYTHON=/absolute/path/to/python3 ``` The sender tool automatically prefers: 1. `SEND_TO_KINDLE_PYTHON` 1. `AGENT_TOOL_PYTHON` supplied by the bash tool host 1. `PYTHON` 1. `${VIRTUAL_ENV}/bin/python3` 1. `python3` from `PATH` ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # chatgpt-auth-app Generated from `plugins/chatgpt-auth-app/README.md`. Minimal application plugin implementing a device-code login flow for ChatGPT-style auth. ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # claude-tools Generated from `plugins/claude-tools/README.md`. Claude Code-compatible tool plugin bundle for Crystal Lattice. This package is being built from behavior-driven investigations of Claude Code's Anthropic API traffic. Raw logs live under `investigation/raw/` and are ignored; normalized fixtures and implementation notes live under `investigation/fixtures/` and `investigation/notes/`. ## Implemented Tools - `Read`: Claude Code-compatible file reader with Anthropic-native schemas and tool-result payloads. - `Write`: Claude Code-compatible file writer with Anthropic-native schemas, parent-directory creation, and the observed read-before-write guard for existing files. - `Edit`: Claude Code-compatible exact string editor with Anthropic-native schemas, read-before-edit guard behavior, and replace-all support. - `Glob`: Claude Code-compatible file matcher with Anthropic-native schemas, relative-vs-absolute output parity, and truncation handling. - `Grep`: Claude Code-compatible regex search tool with Anthropic-native schemas and mode-specific result formatting. - `Bash`: Claude Code-compatible shell execution tool with Anthropic-native schemas, timeout handling, and background command support. - `TaskStop`: Claude Code-compatible background-task stop tool for terminating running Bash tasks by task ID. - `WebFetch`: Claude Code-compatible URL fetch tool with Anthropic-native schemas, prompt-based page analysis, redirect handling, and timeout behavior. - `WebSearch`: Claude Code-compatible web search tool with Anthropic-native schemas and Claude-shaped result formatting backed by Gemini or Tavily providers. - `ClaudeMCPTool`: Claude Code-compatible MCP tool wrapper that exposes discovered MCP server tools with Claude's `mcp__server__tool` naming and Anthropic-native result formatting. ## Implemented Session Actions - `Compact conversation`: Claude Code-compatible compaction session action that captures the observed Claude `/compact` prompt contract, validates the Claude-style numbered summary output, and rewrites session history into the investigated compact-summary shape. `Read` currently supports text files, images, PDF status/page rendering through Poppler, simple Jupyter notebooks, and observed Claude Code error formatting. OpenAI Chat Completions-compatible providers consume the Anthropic-native result through a narrow temporary bridge until first-class tool-result interop lands. `Write` currently supports new-file creation, full overwrites after a prior `Read`, and the observed Claude error payload when attempting to overwrite an unread existing file. `Edit` currently supports exact single replacements, `replace_all`, and the observed Claude error payloads for missing prior reads, missing match strings, and ambiguous multiple matches. `Glob` currently supports default-directory relative results, scoped absolute-path results, the observed no-match string, and the Claude truncation suffix for large result sets. `Grep` currently supports default `files_with_matches`, `content`, and `count` output modes, along with the observed no-match string and missing-path error wrapper. `Bash` currently supports foreground execution, raw-success vs `Exit code N` failure formatting, the observed timeout behavior, and the Claude background-run message format. `TaskStop` currently supports stopping running background Bash tasks by the background ID returned from `Bash`. `Read` remains the primary Claude-shaped way to inspect Bash background output via the returned output path. `WebFetch` currently supports public HTTP(S) fetches, `http` to `https` upgrade, the observed cross-host redirect message shape, and Claude-compatible invalid-URL and timeout strings for the investigated cases. `WebSearch` currently supports the Claude `WebSearch` schema and Claude-shaped text results while letting the app choose either `gemini_api` or `tavily` as the backend provider through config. `ClaudeMCPTool` currently supports stdio/SSE/HTTP MCP servers through the shared `mcp-shared` runtime package. It keeps runtime lifecycle, discovery, and raw tool calls in `mcp-shared`, while this package owns the Claude-facing schema and result shape observed in `investigation/fixtures/mcp/`. Successful text MCP results are returned as Claude-style Anthropic `tool_result` blocks containing a JSON string such as `{"content":"..."}`; MCP errors are returned with `is_error: true`. Claude MCP configuration keys: - `mcp_enabled`: global toggle for MCP-backed tools - `mcp_reuse_runtime`: reuse live MCP runtime connections across requests - `mcp_fail_on_startup_error`: fail preparation when an enabled MCP server cannot start - `mcp_servers`: MCP server definitions keyed by server name `Compact conversation` currently supports the observed Claude `/compact` behavior investigated under `investigation/fixtures/compaction/`, including the appended compaction prompt, Claude-style numbered summary validation, transcript fallback guidance, and session-history rewrite through the core/native rebuild path so provider-native formatting remains owned by the core pipeline. The action now supports full-history compaction, prefix compaction, arbitrary selected ranges, and a message-level "Compact messages up to here" UI action. When a conversation has a leading system message, that system message is preserved in the visible transcript and included in subrange compaction requests as request context. Claude compaction configuration keys: - `compaction_model`: optional model id dedicated to compaction; leave empty to use the normal `model` - `claude_compaction_prompt`: optional full prompt override for the captured Claude-style compaction prompt - `claude_compaction_settings_overrides`: optional advanced request overrides applied only to the internal compaction request ## License Unless otherwise noted in an individual file, original source code in this package that was authored for this repository is licensed under the Apache License, Version 2.0. Files that carry an Apache license header are covered by the following notice: Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. Certain other files in this package contain materials derived from or adapted from Claude Code or other Anthropic-authored materials. Those files are not licensed by us under Apache-2.0 and are instead subject to Anthropic's terms: © Anthropic PBC. All rights reserved. Use is subject to Anthropic's [Commercial Terms of Service](https://www.anthropic.com/legal/commercial-terms). Rights in this package should therefore be determined on a file-by-file basis by checking the notices or headers in the individual files. # cloud-agent-app Generated from `plugins/cloud-agent-app/README.md`. Cloud agent application plugin and managed SSH transfer adapter. This README is the primary reference for the cloud profile schema, bundled script layout, repo-local hook conventions, and cloud-runtime transfer contract. Keep `application/python/README.md` at the quick-start layer and put cloud-profile reference detail here. ## Overview The cloud agent app lets a local server start a cloud runtime for a session, transfer the workspace and session state, connect through the orchestrator, and later sync changes back down. The current profile model is built around: - `transfer_up_profiles` - `sync_down_profiles` - `container_profiles` `transfer_up_profiles` now own the full transfer/setup/install/validation contract directly. There are no separate bundled `cloud_setup_profiles` or `install_profiles`. ## Standard Repo-Local Hooks These three hooks are the standard repo-local extension points: - `.cloud-agent/local-stage` - `.cloud-agent/install-cloud` - `.cloud-agent/validate-cloud` For a model-oriented authoring guide with examples, see [`docs/cloud-script-authoring.md`](https://github.com/dynamicprogrammingsolutions/crystal-lattice/blob/main/docs/cloud-script-authoring.md). The default `cloud-script-author` agent points at this guide through `${env:BUILTIN_PLUGINS}/cloud-agent-app/docs/cloud-script-authoring.md` so the same path works from source checkouts and bundled CLI installs. Keep the guide in sync with this reference when changing container profile fields, since the authoring agent reads it before inspecting plugin source. They are addressed in config through: - `local_stage_script` - `install_script` - `validation_script` Default conventional paths: ``` ${env:WORKING_DIR}/.cloud-agent/local-stage ${env:WORKING_DIR}/.cloud-agent/install-cloud ${env:WORKING_DIR}/.cloud-agent/validate-cloud ``` Behavior: - If one of those fields resolves to its standard conventional path and the file does not exist, the hook is treated as a no-op. - If you point one of those fields at some other path and that file does not exist, resolution fails with a clear error. - `validation_script` is executed by `crystal-lattice validate-cloud` after the shared transfer/setup/install/runtime smoke passes. ## Validation Command Run local cloud validation from the main CLI: ``` crystal-lattice validate-cloud ``` Optional flags: ``` crystal-lattice validate-cloud --workspace-dir /path/to/project crystal-lattice validate-cloud --keep-temp-dir crystal-lattice validate-cloud --cache-dir /path/to/stable/cache crystal-lattice validate-cloud --runtime-build-context /path/to/context --runtime-dockerfile /path/to/Dockerfile ``` Behavior: - resolves the configured standard cloud profiles for the current workspace - runs the normal local-stage and transfer-up flow - writes the portable config/assets/env/runtime script bundle - builds or reuses the configured local-Docker validation runtime without the orchestrator service, using explicit runtime-layout build metadata when a default/local image needs to be built - waits for runtime readiness - runs a deterministic built-in smoke check - runs the optional `validation_script` hook when present - bind-mounts `--cache-dir` at `/cloud-agent/cache` when provided so plugin and pip caches survive between validation runs The command is contributed by `cloud-agent-app` as a CLI binding onto the application action `cloud.validate_cloud`. The action remains the executable contract; the command only defines the CLI shape. ## Bundled Script Layout Bundled scripts live under the default config asset tree and are derivable directly from config section, profile id, and script key. ``` application/python/agent_terminal_app/default_config/cloud-agent/profiles/ transfer_up/ working-tree-current-state/ local_transfer_script cloud_setup_script working-tree-current-state-branch/ local_transfer_script cloud_setup_script clean-head/ local_transfer_script cloud_setup_script sync_down/ working-tree-patch/ cloud_export_script cloud_mark_synced_script local_apply_script branch-import/ cloud_export_script cloud_mark_synced_script local_apply_script ``` Rules: - Every config-addressable bundled script is self-contained. - Bundled wrapper hooks are not used for repo-local staging, install, or validation. - Profile ids should represent distinct behaviors, not aliases. For example, there is one canonical bundled branch-import sync-down profile because the script already handles the optional delegated snapshot path conditionally. ## Complete Example Config ``` { "plugins": [ "path:${env:BUILTIN_PLUGINS}/cloud-agent-app" ], "application": { "cloud_agent": { "orchestrator_url": "${env:CLOUD_ORCHESTRATOR_URL}", "orchestrator_token": "${env:CLOUD_ORCHESTRATOR_TOKEN}", "workspace_dir": ".", "connection_key": "optional-stable-project-or-server-key", "transfer_method": "managed_ssh", "project_config_path": "${env:WORKING_DIR}/.cloud-agent/cloud-agent.json", "default_container_profile": "default-runtime", "default_transfer_up_profile": "working-tree-current-state", "default_sync_down_profile": "working-tree-patch", "container_profiles": { "default-runtime": { "label": "Default Crystal Lattice cloud agent runtime", "mode": "dockerfile", "image": "cloud-agent-runtime:latest", "dockerfile": "Dockerfile", "context": [ { "source": "${env:BUILTIN_PLUGINS}/cloud-agent-app/src/cloud_agent_app/bundled_runtime/python/Dockerfile", "target": "Dockerfile" }, { "source": "${env:BUILTIN_PLUGINS}/cloud-agent-app/src/cloud_agent_app/bundled_runtime/python/runtime.py", "target": "runtime.py" }, { "source": "${env:CLOUD_RUNTIME_BUILD_CONTEXT}/python_lib", "target": "python_lib" }, { "source": "${env:CLOUD_RUNTIME_BUILD_CONTEXT}/cloud-runtime-plugin-bundle", "target": "cloud-runtime-plugin-bundle" }, { "source": "${env:CLOUD_RUNTIME_BUILD_CONTEXT}/plugin_sources", "target": "plugin_sources" } ] } }, "transfer_up_profiles": { "working-tree-current-state": { "label": "Working tree current state", "local_stage_script": "${env:WORKING_DIR}/.cloud-agent/local-stage", "local_transfer_script": "${env:CONFIG_DIR}/cloud-agent/profiles/transfer_up/working-tree-current-state/local_transfer_script", "cloud_setup_script": "${env:CONFIG_DIR}/cloud-agent/profiles/transfer_up/working-tree-current-state/cloud_setup_script", "install_script": "${env:WORKING_DIR}/.cloud-agent/install-cloud", "validation_script": "${env:WORKING_DIR}/.cloud-agent/validate-cloud", "compatible_sync_down_profiles": ["working-tree-patch"] }, "working-tree-current-state-branch": { "label": "Working tree current state with branch export", "local_stage_script": "${env:WORKING_DIR}/.cloud-agent/local-stage", "local_transfer_script": "${env:CONFIG_DIR}/cloud-agent/profiles/transfer_up/working-tree-current-state-branch/local_transfer_script", "cloud_setup_script": "${env:CONFIG_DIR}/cloud-agent/profiles/transfer_up/working-tree-current-state-branch/cloud_setup_script", "install_script": "${env:WORKING_DIR}/.cloud-agent/install-cloud", "validation_script": "${env:WORKING_DIR}/.cloud-agent/validate-cloud", "compatible_sync_down_profiles": ["branch-import"] }, "clean-head": { "label": "Clean committed HEAD", "local_stage_script": "${env:WORKING_DIR}/.cloud-agent/local-stage", "local_transfer_script": "${env:CONFIG_DIR}/cloud-agent/profiles/transfer_up/clean-head/local_transfer_script", "cloud_setup_script": "${env:CONFIG_DIR}/cloud-agent/profiles/transfer_up/clean-head/cloud_setup_script", "install_script": "${env:WORKING_DIR}/.cloud-agent/install-cloud", "validation_script": "${env:WORKING_DIR}/.cloud-agent/validate-cloud", "compatible_sync_down_profiles": ["branch-import"] } }, "sync_down_profiles": { "working-tree-patch": { "label": "Apply cloud patch to current worktree", "cloud_export_script": "${env:CONFIG_DIR}/cloud-agent/profiles/sync_down/working-tree-patch/cloud_export_script", "cloud_mark_synced_script": "${env:CONFIG_DIR}/cloud-agent/profiles/sync_down/working-tree-patch/cloud_mark_synced_script", "local_apply_script": "${env:CONFIG_DIR}/cloud-agent/profiles/sync_down/working-tree-patch/local_apply_script", "compatible_transfer_up_profiles": ["working-tree-current-state"] }, "branch-import": { "label": "Import committed cloud branch with optional safety snapshots", "cloud_export_script": "${env:CONFIG_DIR}/cloud-agent/profiles/sync_down/branch-import/cloud_export_script", "cloud_mark_synced_script": "${env:CONFIG_DIR}/cloud-agent/profiles/sync_down/branch-import/cloud_mark_synced_script", "local_apply_script": "${env:CONFIG_DIR}/cloud-agent/profiles/sync_down/branch-import/local_apply_script", "compatible_transfer_up_profiles": [ "working-tree-current-state-branch", "clean-head" ] } }, "managed_ssh": { "adapter_script": null, "ssh_path": "ssh", "rsync_path": "rsync", "extra_rsync_args": [] }, "disabled_plugins": [ {"$raw": "path:${env:BUILTIN_PLUGINS}/session-asset-attachments"} ], "config_assets": { "include_config_dir": true, "exclude": [".env", ".env.*", ".plugin_cache", "logs", "sessions", "auth", "state"], "include_excluded": [], "max_top_level_entry_bytes": 104857600 }, "runtime_env": { "include_config_env": true, "include": [], "exclude": [ "CLOUD_ORCHESTRATOR_URL", "CLOUD_ORCHESTRATOR_TOKEN", "SERVER_HOST", "SERVER_PORT", "BRIDGE_URL", "BRIDGE_LOCAL_HTTP_BASE", "OPENAI_BASE_URL", "CONFIG_DIR", "BUILTIN_PLUGINS", "PLUGIN_CACHE_DIR" ], "env_files": [] } } } } ``` ## Config Reference ### Top-level cloud settings - `orchestrator_url`: Base URL for the control plane. - `orchestrator_token`: Optional auth token for orchestrator requests. - `workspace_dir`: Default local workspace used by cloud actions when the UI action input does not override it. - `connection_key`: Optional stable opaque key for connection reuse. If omitted, the plugin derives one from local config and workspace context. - `transfer_method`: Currently `managed_ssh`. - `project_config_path`: Optional project-level override file. The default is `${env:WORKING_DIR}/.cloud-agent/cloud-agent.json`. - `default_transfer_up_profile`, `default_sync_down_profile`: Selected bundled or project-defined transfer/sync defaults. - `default_container_profile`: Selected container profile. The canonical default config sets this to `default-runtime` and declares that profile explicitly. ### `transfer_up_profiles` Each profile describes one complete upload/setup/install/validation family. - `label`: Human-readable label. - `local_stage_script`: Optional repo-local staging hook. - `local_transfer_script`: Required local export script. - `cloud_setup_script`: Required cloud workspace materialization script. - `install_script`: Optional repo-local cloud install/bootstrap hook. - `validation_script`: Optional repo-local validation hook contract. - `compatible_sync_down_profiles`: Optional allow-list for matching sync-down profiles. ### `sync_down_profiles` - `label`: Human-readable label. - `cloud_export_script`: Required cloud export script. - `cloud_mark_synced_script`: Optional cloud acknowledgement script that runs only after local apply succeeds. - `local_apply_script`: Required local apply/import script. - `compatible_transfer_up_profiles`: Optional allow-list for matching transfer-up profiles. ### `container_profiles` - `label`: Human-readable label. - `mode`: Optional `image` or `dockerfile`. When omitted, the resolver infers `dockerfile` if `dockerfile` is present, otherwise `image`. - `image`: Required runtime image name. In Dockerfile mode this is the image name the deployment-wired builder produces and the runner starts. - `dockerfile`: Dockerfile path inside the staged context for list-mode `context`, or Dockerfile source path for directory-mode `context`. Required for Dockerfile mode. - `context`: Source directory copied into a sanitized staged build context, or an explicit list of `{source, target}` entries copied into an empty staged context. `source` values follow the usual env-placeholder and relative-path rules. `target` values must be safe relative paths inside the staged context. - `target`: Optional Docker build target. Included in the build digest. - `build_args`: Optional string build arguments. Included in the build digest. Do not use build args for secrets. - `build_assets`: Optional list of file or directory assets copied into the staged build context before digesting/building. Each entry has `source` and `target`. `source` follows the same env-placeholder and relative-path rules. `target` is a safe relative path inside the staged context. Lockfiles and package manifests are ideal assets. Generated files, large directories, frequently changing source trees, and secret-containing paths are allowed by the parser but will cause rebuilds, larger artifact uploads, or unsafe images. - For portable custom runtimes, copy the default Dockerfile from `${env:BUILTIN_PLUGINS}/cloud-agent-app/src/cloud_agent_app/bundled_runtime/python/Dockerfile` and edit the copy. The default final stage is named `cloud-agent-runtime`, so a project can append `FROM cloud-agent-runtime AS project-runtime` and keep Docker build cache reuse without depending on an external `cloud-agent-runtime:latest` image alias. - `cache_mounts`: Optional persistent runtime mounts shared between instances. Each entry has `id`, `target`, and optional `scope` (`profile`, `connection`, `global`, or `instance`; default `profile`). - `mounts`: Optional persistent runtime mounts for large mutable data that should not live in Docker image layers. Entries have the same shape as `cache_mounts`. ### Managed SSH adapter settings - `managed_ssh.adapter_script`: Optional override for unusual transport needs. - `managed_ssh.ssh_path`, `managed_ssh.rsync_path`, `managed_ssh.extra_rsync_args`: Platform-owned transport settings for the managed SSH transfer adapter. ### Portable runtime config settings - `disabled_plugins`: Exact plugin-spec strings to remove only from the cloud runtime config during portable config generation. It works by string comparison, so most of the time you need to use the formula `{"$raw": "path:${env:BUILTIN_PLUGINS}/session-asset-attachments}`. - `config_assets`: Policy for copying the local `CONFIG_DIR` asset tree into the transfer bundle. - `runtime_env`: Policy for copying selected environment variables and env files into `.cloud-agent/env/runtime.env`. `config_assets.exclude` keeps broad local-only config and secret directories out of the transfer by default. Use `config_assets.include_excluded` for narrow, intentional exceptions that should keep their config-relative path in the cloud runtime. For example, to copy only saved ChatGPT credentials without copying the whole `auth` directory: ``` { "config_assets": { "include_excluded": [ "auth/chatgpt-auth.json", "auth/chatgpt-rate-limits.json" ] } } ``` For env files, prefer `runtime_env.env_files` when the file should be loaded as runtime environment variables: ``` { "runtime_env": { "env_files": [".env.cloud"] } } ``` Use `config_assets.include_excluded` for `.env` or `.env.cloud` only when the file itself must exist under the cloud runtime `CONFIG_DIR`; otherwise `runtime_env.env_files` is usually the tighter transfer. ## Project Overrides Projects can override the bundled defaults from an optional workdir file at: ``` .cloud-agent/cloud-agent.json ``` Rules: - That file may override default profile ids or individual profile fields. - Script fields can be a string or an ordered fallback list. - The first existing script path wins. - Relative paths in the project file resolve from `WORKING_DIR`. - If a project file provides a fallback list for a script field, inherited bundled candidates remain available after the listed project candidates. ## Script Environment Contract Scripts receive stable environment variables: - Local stage/transfer/apply: `LOCAL_WORKSPACE_DIR`, `CLOUD_WORKSPACE_DIR`, `TRANSFER_DIR`, `TRANSFER_STAGE_DIR`, `WORKING_DIR`, `SESSION_ID` - Cloud setup/install/export/mark-synced: `CLOUD_WORKSPACE_DIR`, `TRANSFER_DIR`, `TRANSFER_STAGE_DIR`, `WORKING_DIR`, `SESSION_ID` - Profile selection: `CLOUD_AGENT_PROFILE_ID`, `CLOUD_AGENT_TRANSFER_UP_PROFILE_ID`, `CLOUD_AGENT_SYNC_DOWN_PROFILE_ID` - Branch/snapshot flows: `CLOUD_AGENT_IMPORTED_BRANCH_NAME`, `CLOUD_AGENT_DELEGATED_SNAPSHOT_REF`, `CLOUD_AGENT_CLOUD_FULL_STATE_REF`, `CLOUD_AGENT_LOCAL_IMPORT_STATE_REF`, `CLOUD_AGENT_LAST_SYNCED_REF` ## Runtime Bundle Notes When a cloud session starts, the local action: - creates `.cloud-agent/config/config.json` for the cloud runtime - removes the local `application.cloud_agent` control-plane section from that portable config - copies the configured `CONFIG_DIR` asset tree to `.cloud-agent/config/assets/` - writes `.cloud-agent/env/runtime.env` according to `runtime_env` - copies the selected cloud setup/install/export/mark-synced scripts into `.cloud-agent/scripts/` - copies `validation_script` into `.cloud-agent/scripts/validate` when present The current cloud runtime bootstrap executes setup and install during normal cloud startup. The local validation command then calls a runtime validation endpoint that runs: - a built-in smoke check proving the prepared runtime/workspace/session bundle is coherent - the optional packaged `validation_script` ## Runtime Image Parity `crystal-lattice validate-cloud` runs the same resolved `container_profiles.` selected by the cloud profile. In image mode, local validation reuses the selected runtime image. In Dockerfile mode, validation stages the configured build context and declared build assets, computes the digest, builds/reuses the local image, and then runs the same runtime contract. Image-mode validation still supports explicit runtime image build metadata: ``` cloud_runtime_build_context cloud_runtime_dockerfile ``` For the standard default `cloud-agent-runtime:latest`, validation computes a digest from those declared build inputs, compares it with the Docker image label `org.crystal-lattice.cloud-runtime.digest`, and rebuilds only when the image is missing or stale. Builds also tag an immutable digest image such as `cloud-agent-runtime:sha-`. Custom runtime image names are treated as user-owned. If a custom image is already present locally, validation trusts it. If it is missing, validation fails clearly unless you pass explicit `--runtime-build-context` and `--runtime-dockerfile` overrides for that command run. Dockerfile-mode validation stages the selected container profile context and build assets directly. The default packaged runtime Dockerfile and runtime app are owned by this plugin under `cloud_agent_app/bundled_runtime`. ## Testing Guidance Validation coverage is intentionally layered: - unit tests in `plugins/cloud-agent-app/tests` - runtime endpoint tests in `application/python/tests/test_cloud_agent_runtime.py` - Docker-backed command tests in `application/python/tests/test_cloud_validation_command_docker.py` - bundled binary smoke in `application/python/tests/test_binary_distribution_docker.py` - fixture-backed frontend/orchestrator profile e2e in `packages/frontend-sdk/src/integration/__tests__/cloud-orchestrator.profiles.e2e.integration.test.ts` This repository is the first real adopter of the shared validation flow through its repo-local hooks: - `.cloud-agent/local-stage` - `.cloud-agent/install-cloud` - `.cloud-agent/validate-cloud` For the standard working-tree transfer profile, files staged into `TRANSFER_STAGE_DIR` are copied directly into the cloud workspace root by the bundled `cloud_setup_script`. A minimal `.env` handoff can therefore stay as simple as copying `.env` into `TRANSFER_STAGE_DIR/.env`. ## License Crystal Lattice Cloud Agent App is an application plugin for cloud-backed agent sessions. Copyright (C) 2026 Dynamic Programming Solutions Kft. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . # codex-tools Generated from `plugins/codex-tools/README.md`. Codex CLI-compatible tool plugin bundle. Provides shell tools and apply-patch with schemas, parameter names, output formatting, and behavioral semantics matching OpenAI's Codex CLI. ## Exposed Tools ### Shell (Array-based) Execute shell commands using array-style arguments passed directly to execvp. - **Plugin name**: `shell` - **Schema name**: `shell` - **Parameters**: `command` (array, required), `workdir` (string), `timeout_ms` (number) - **Command format**: Array of strings (e.g., `["bash", "-lc", "echo hello"]`) - **Execution**: Direct execvp (no shell wrapper) - **Models**: Baseline GPT-5 shell family (`gpt-5`, `gpt-5-mini`) - **Selection**: Auto-selected when `codex-variant` tag is absent ### Shell Command (String-based) Execute shell scripts using the user's default shell. - **Plugin name**: `shell_command` - **Schema name**: `shell_command` - **Parameters**: `command` (string, required), `workdir` (string), `timeout_ms` (number), `login` (boolean) - **Command format**: String script (e.g., `"echo hello"`) - **Execution**: Wrapped via shell (e.g., `bash -lc "echo hello"`) - **Models**: Codex variants plus the shell-command GPT family (`gpt-5.1`, `gpt-5.2`, `gpt-5.4`, and later) - **Selection**: Auto-selected when `codex-variant` tag is present ### Shell Legacy Legacy shell tool with string-based command execution. - **Plugin name**: `shell_legacy` - **Schema name**: `shell` - **Parameters**: `command` (string), `workdir` (string), `timeout_seconds` (number), `env` (object) - **Status**: Disabled by default (`default_enabled = false`) - **Enable via**: `enabled_plugins` or `force_enabled_plugins` config ### Apply Patch Apply Codex-style `*** Begin Patch` edits to files. - **Plugin name**: `apply_patch` - **Schema name**: `apply_patch` - **Primary schema**: Freeform/custom tool on provider paths that support custom tools (for example, OpenAI/OpenRouter Responses) - **Fallback schema**: Function tool with a single `input` string on OpenAI-compatible chat-completions paths - **Execution**: Shared internal patch runtime used by both the dedicated tool and optional shell interception ### View Image Load a local image file for model-visible vision input. - **Plugin name**: `view_image` - **Schema name**: `view_image` - **Primary schema**: OpenAI Responses function tool with `output_schema` - **Chat fallback**: OpenAI chat-completions function schema plus JSON/text tool result containing the image data URL - **Parameters**: `path` (string, required), `detail` (optional `"original"` when enabled) - **Execution**: Resolves paths against `working_directory`, enforces `allowed_paths`, validates PNG/JPEG/GIF/WebP magic bytes, and returns a data URL ### MCP Tools Discover and execute MCP (Model Context Protocol) tools through Codex-style qualified tool names. - **Plugin name**: `codex_mcp_tool` - **Model-visible naming**: `mcp__server__tool` - **Parameters**: Wrapper-specific MCP tool parameters discovered from the configured server - **Execution**: Uses the shared `mcp-shared` runtime package for transport/session management while keeping Codex-specific naming and result formatting in the wrapper plugin - **Config keys**: `mcp_enabled`, `mcp_fail_on_startup_error`, `mcp_reuse_runtime`, `mcp_servers` - **UI**: One global MCP toggle plus one enable/disable toggle per configured MCP server ## Exposed Features ### CodexVariantFeature Emits the `codex-variant` tag when the model belongs to the shell-command family. - **Plugin name**: `codex_variant` - **Tag emitted**: `codex-variant` (for `*-codex` models and `gpt-5.4` and later plain/minified GPT models) - **Purpose**: Enables automatic shell tool selection based on model type ### CodexSkillsFeature Discovers explicit skill roots, renders the upstream Codex available-skills developer block, and supports manual skill activation by mention. - **Plugin name**: `codex_skills` - **Prompt behavior**: Populates `CODEX_SKILLS_SECTION` for the Codex system-message template - **Manual activation**: Detects mentions like `$skillname` in request user messages - **Injection behavior**: Loads the matching `SKILL.md` and appends an upstream-style `` user message for the current request only ## Shell Tool Selection Tools are automatically selected based on: | Condition | Tool Enabled | | ------------------------------------------------------------------------------ | ------------------------ | | `gpt-5` and `gpt-5-mini` (auto mode) | `shell` | | `gpt-5.1`, `gpt-5.2`, `*-codex`, `gpt-5.4`, and later GPT families (auto mode) | `shell_command` | | `shell_tool_mode: "shell"` | `shell` (forced) | | `shell_tool_mode: "shell_command"` | `shell_command` (forced) | ### Selection Logic ``` shell_tool_mode = "auto" (default) ├── codex-variant tag present -> shell_command enabled └── codex-variant tag absent -> shell enabled shell_tool_mode = "shell" └── shell forced on, shell_command forced off shell_tool_mode = "shell_command" └── shell_command forced on, shell forced off ``` ## Configuration ``` { "plugins": ["codex-tools"], "agents": { "codex-agent": { "provider": "openrouter", "model": "openai/gpt-5.2-codex", "shell_tool_mode": "auto" }, "standard-agent": { "provider": "openrouter", "model": "openai/gpt-5", "shell_tool_mode": "auto" }, "legacy-agent": { "provider": "openrouter", "model": "openai/gpt-4", "enabled_plugins": ["shell_legacy"], "disabled_plugins": ["shell", "shell_command"] } } } ``` ### Config Keys | Key | Level | Description | | ----------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `shell_tool_mode` | Agent | Shell tool selection mode: `"auto"` (default), `"shell"`, `"shell_command"` | | `intercept_apply_patch` | Agent/Provider | When true, shell tools intercept Codex-style `apply_patch` shell invocations and route them to the internal patch runtime | | `enable_view_image_detail_original` | Agent | When true, exposes and honors `view_image.detail = "original"` | | `view_image_max_bytes` | Agent | Maximum local image size accepted by `view_image` | | `mcp_enabled` | Agent/Provider | Enable MCP-backed tools for the active wrapper | | `mcp_fail_on_startup_error` | Agent/Provider | Fail tool preparation if an enabled MCP server cannot be started | | `mcp_reuse_runtime` | Agent/Provider | Reuse live MCP runtime connections across requests | | `mcp_servers` | Agent/Provider | Map of MCP server definitions keyed by server name | | `skills_enabled` | Agent | Enable Codex skill discovery and manual activation | | `skills_roots` | Agent | Ordered explicit roots scanned for `SKILL.md` files | | `skills_follow_symlinks` | Agent | Whether skill discovery follows symlinked directories | | `skills_max_scan_depth` | Agent | Maximum discovery depth under each root | | `skills_max_dirs_per_root` | Agent | Maximum directories scanned per root | | `enabled_plugins` | Agent | List of plugin IDs to enable (re-enables `default_enabled = false` plugins) | | `disabled_plugins` | Agent/Provider/Global | List of plugin IDs to disable | | `force_enabled_plugins` | Agent | List of plugin IDs to force-enable (overrides tag-based selection) | ### Shell Tool Config ``` { "intercept_apply_patch": false, "shell_working_directory": ".", "shell_allowed_paths": ["."], "shell_timeout_seconds": 60, "shell_max_output_lines": 2000, "shell_max_output_chars": 160000 } ``` ## Codex-Specific Behavior Notes 1. **Two shell tool variants**: Array-based (`shell`) and string-based (`shell_command`) 1. **Automatic selection**: Based on `codex-variant` tag from model-family detection 1. **Model detection**: `CodexVariantFeature` keeps `gpt-5` and `gpt-5-mini` on `shell`, and routes `gpt-5.1`, `gpt-5.2`, `*-codex`, `gpt-5.4`, and later GPT families to `shell_command` 1. **Workdir semantics**: Missing or empty `workdir` falls back to the effective turn cwd, and relative `workdir` values are resolved against that cwd before execution 1. **Apply patch fallback**: If `apply_patch` is disabled with `disabled_plugins`, shell tools can still route `apply_patch` heredocs internally when `intercept_apply_patch` is enabled 1. **Legacy tool**: Disabled by default, enable via `enabled_plugins` 1. **Config override**: `shell_tool_mode` forces specific tool regardless of model 1. **Skills developer block**: Uses the upstream Codex wording for the available-skills instructions section 1. **Skills activation**: Explicit mentions such as `$skillname` inject the selected `SKILL.md` into the request as a synthetic user message ## Dependencies - `tool-compat-shared` (shared shell execution helpers) - `mcp-shared` (shared MCP transport/runtime helpers) - `skill-shared` (shared skill discovery and parsing helpers) - `agent-core` (plugin framework) ## Development ``` pip install -e ".[dev]" pytest tests -q ``` The Python package name is `codex_tools` and uses a `src/` layout. Plugin metadata lives at `agent_plugin.json` in the repo root. ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # feature-file-context Generated from `plugins/feature-file-context/README.md`. Feature plugin providing `@file:` marker expansion and file completions. This plugin depends on `GitPython` to respect `.gitignore` rules precisely when discovering files. ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # feature-request-options Generated from `plugins/feature-request-options/README.md`. Feature plugin that merges `request_options` from config into the provider payload, with support for declarative UI configuration using flat override keys. ## Overview This plugin allows you to inject arbitrary request parameters into the provider's API call. It's useful for setting parameters like `temperature`, `max_tokens`, `top_p`, etc. The plugin supports two configuration patterns: 1. Direct `request_options` for simple cases and defaults 1. `request_options_ui` for UI-driven configuration with flat override keys like `request_options__temperature` ## Configuration ### `request_options` A dictionary of options to merge into the request payload. These override any existing values. ``` { "request_options": { "temperature": 0.7, "max_tokens": 2048, "top_p": 0.9 } } ``` ### `request_options_ui` Declarative UI element definitions for `request_options` keys. This generates **flat override keys** with the prefix `request_options__`, while still resolving defaults from the nested `request_options` object. | Config Value | UI Element Type | Generated Override Key | | -------------------------- | ----------------- | ------------------------------------ | | `["opt1", "opt2"]` | `select` dropdown | `request_options__{path}` | | `"string"` | text input | `request_options__{path}` | | `"number"` | number input | `request_options__{path}` | | `"boolean"` / `"checkbox"` | checkbox | `request_options__{path}` | | nested object | recursive subtree | `request_options__{parent}__{child}` | Keys are automatically converted to labels (for example, `max_tokens` → "Max Tokens", `thinking.type` → "Thinking Type"). Generated UI elements also include: - `config_path`: nested source path such as `request_options.thinking.type` - `default`: nested default copied from `request_options` when present in config ## How Flat Keys Work When you define `request_options_ui`, the plugin: 1. **Generates UI elements** with flat keys like `request_options__temperature` 1. **Keeps nested source metadata** via `config_path` and `default` 1. **Merges flat override keys into `request_options`** during `init()`, then removes them from config ### Example Flow **Config input:** ``` { "request_options": { "temperature": 0.7 }, "request_options_ui": { "temperature": ["0.0", "0.5", "0.7", "1.0"], "max_tokens": ["512", "1024", "2048"] }, "request_options__temperature": "0.9" } ``` **After `init()`:** ``` { "request_options": { "temperature": "0.9" }, "request_options_ui": { ... } } ``` The flat key `request_options__temperature` is merged into `request_options.temperature` and then removed. ### Nested Example You can also define nested provider-specific controls: ``` { "request_options": { "thinking": { "type": "enabled", "clear_thinking": false } }, "request_options_ui": { "thinking": { "type": ["disabled", "enabled"], "clear_thinking": "boolean" } } } ``` This generates UI elements with keys: - `request_options__thinking__type` - `request_options__thinking__clear_thinking` and nested source metadata: - `config_path: request_options.thinking.type` - `config_path: request_options.thinking.clear_thinking` If the UI overrides only `request_options__thinking__clear_thinking`, the plugin deep-merges it back into `request_options.thinking` so sibling keys like `type` are preserved. ## Complete Examples ### Basic Usage (No UI) ``` { "agents": { "default": { "provider": "openai", "model": "gpt-4o", "request_options": { "temperature": 0.7, "max_tokens": 2048 } } } } ``` ### With UI Configuration ``` { "agents": { "creative_writer": { "provider": "openai", "model": "gpt-4o", "request_options": { "temperature": 0.9, "max_tokens": 4096 }, "request_options_ui": { "temperature": ["0.0", "0.5", "0.7", "0.9", "1.0"], "max_tokens": ["1024", "2048", "4096", "8192"], "frequency_penalty": "number" } } } } ``` This generates: - A **select dropdown** with key `request_options__temperature` - A **select dropdown** with key `request_options__max_tokens` - A **number input** with key `request_options__frequency_penalty` ### Provider-Specific Nested UI Example ``` { "agents": { "zai": { "provider": "zai", "model": "glm-4.5-air", "request_options": { "thinking": { "type": "enabled", "clear_thinking": false } }, "request_options_ui": { "thinking": { "type": ["disabled", "enabled"], "clear_thinking": "boolean" } } } } } ``` This lets the UI expose nested request settings while keeping the persisted defaults in the natural nested `request_options` structure. ### Flat Override With Nested Defaults Example ``` { "agents": { "fireworks": { "provider": "fireworks", "model": "fireworks/qwen3-8b", "request_options": { "reasoning_history": "preserved" }, "request_options_ui": { "reasoning_history": ["disabled", "interleaved", "preserved"] } } } } ``` The UI element still uses the flat override key `request_options__reasoning_history`, but its default/current value is resolved from `request_options.reasoning_history`. ### Runtime Override Example When the UI sets a value, it updates the flat key which gets merged: ``` { "request_options": { "temperature": 0.5, "max_tokens": 2048 }, "request_options_ui": { "temperature": ["0.0", "0.5", "0.7", "0.9"] }, // UI sets this, overriding request_options.temperature "request_options__temperature": "0.9" } ``` Result after `init()`: `request_options.temperature` becomes `0.9` For nested keys, the merge is deep. For example, overriding only `request_options__thinking__clear_thinking` updates `request_options.thinking.clear_thinking` without discarding `request_options.thinking.type`. ## Benefits - **Zero code required** - Define UI elements declaratively with `request_options_ui` - **Flat override keys** - UI-friendly session override keys like `request_options__temperature` - **Nested defaults** - Base config can stay in natural nested `request_options` objects - **Auto-merge** - Flat override keys automatically deep-merge into nested `request_options` - **Provider agnostic** - Works with any OpenAI-compatible provider - **Clean state** - Flat keys are removed from config after merging - **Auto-generated labels** - Clean UI labels from your config keys and nested paths ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # feature-system-message Generated from `plugins/feature-system-message/README.md`. Feature plugin providing configurable system-message templating for request-time prompt composition. ## Overview This plugin lets application config define a default system-message template plus named variables. The template is rendered for each request without mutating the stored core session history. Earlier features may also contribute request-scoped template variables by writing `state["system_message_variables"] = {"NAME": "value"}` during `initialize_request()`. Those values are merged just before template rendering and override config-defined variables with the same name for that request only. Supported variable sources: - string: direct replacement text - `{"text": "..."}`: explicit inline text - `{"files": ["path1.md", "path2.md"]}`: concatenated file contents - `{"code": "path/to/helper.py"}`: execute a Python helper file - `{"inline_code": "...python..."}`: evaluate or execute inline Python Any object-form variable may also include: - `{"condition": {"code": "path/to/check.py"}}` - `{"condition": {"inline_code": "is_git_repo()"}}` - `{"runtime_code": "path/to/variable_runtime.py"}` When a condition is falsey, the variable resolves to an empty string and the main value is not read or executed. Optionally, the `system_message` object may also include: - `"runtime_code": "path/to/runtime.py"` That file is executed once per request, and all exported non-underscore globals from it become the runtime namespace for `inline_code`, file-backed `code`, and their condition variants. Individual variables may also define their own `runtime_code`. When present, that helper is loaded after the top-level runtime and augments the namespace for just that variable. This makes it possible to reuse focused helpers such as a shared `TASKS` toggle across multiple prompt templates without forcing every template to share the same top-level runtime file. The runtime namespace always includes: - `CONFIG`: the effective resolved config dict for the current request When the application asks the feature for UI schema, the runtime namespace also includes: - `TAGS`: resolved capability tags for the effective config - `MODELS`: resolved model descriptors for the effective config If `runtime_code` exports a zero-argument `get_ui_elements()` function, the feature calls it and merges the returned list into the UI schema. ## Example ``` { "plugins": [ "path:${env:BUILTIN_PLUGINS}/feature-system-message" ], "agents": { "gemini": { "provider": "openrouter_gemini_tools", "system_message": { "template": "${file:${env:CONFIG_DIR}/system_messages/gemini/template.md}", "runtime_code": "${env:CONFIG_DIR}/system_messages/helpers/runtime.py", "variables": { "PREAMBLE": { "files": [ "${env:CONFIG_DIR}/system_messages/gemini/preamble.md" ] }, "GIT_SECTION": { "files": [ "${env:CONFIG_DIR}/system_messages/gemini/git-section.md" ], "condition": { "inline_code": "is_git_repo()" } }, "WORKING_DIRECTORY_LINE": { "inline_code": "f'Current working directory: {cwd}'" } } } } } } ``` ## `inline_code` `inline_code` supports two lightweight forms: - Single expression: the result is used directly. - Block execution: the plugin executes the code and reads `VALUE` from the resulting namespace. Examples: ``` { "WORKING_DIRECTORY_LINE": { "inline_code": "f'Current working directory: {cwd}'" } } ``` ``` { "GIT_SECTION": { "inline_code": "if is_git_repo():\n VALUE = '# Git Repository\\n\\n- Review changes with git status before committing.'\nelse:\n VALUE = ''" } } ``` If an inline block does not set `VALUE`, the rendered value becomes an empty string. `inline_code` only sees builtins by default. If you want convenience names such as `cwd` or `is_git_repo()`, define them in `runtime_code`. Because `CONFIG` is always present, inline snippets and helper files can also branch on ordinary config values such as `provider`, `model`, or custom keys. ## File-Backed `code` The existing `code` behavior is preserved. The plugin executes the referenced Python file with `runpy.run_path(...)` and then looks for: - `SYSTEM_MESSAGE_VALUE` - or a callable `get_value()` If neither produces a value, the rendered result becomes an empty string. Example helper file: ``` import os from pathlib import Path def get_value() -> str: agents = Path(os.getcwd()) / "AGENTS.md" if not agents.is_file(): return "" return "# Contextual Instructions\n\n" + agents.read_text(encoding="utf-8") ``` ## `runtime_code` Use `runtime_code` when you want a reusable, config-defined namespace for dynamic sections. Example runtime file: ``` import subprocess from pathlib import Path cwd = str(Path.cwd().resolve()) def git_root(path: str | None = None) -> str | None: target = Path(path or cwd) proc = subprocess.run( ["git", "-C", str(target), "rev-parse", "--show-toplevel"], capture_output=True, text=True, check=False, ) if proc.returncode != 0: return None return proc.stdout.strip() or None def is_git_repo(path: str | None = None) -> bool: return git_root(path) is not None ``` All exported non-underscore globals become available to dynamic code. That means `inline_code` or `condition.inline_code` can use names such as: - `CONFIG`: effective resolved config for the current request - `cwd`: current working directory as an absolute string - `is_git_repo(path: str | None = None) -> bool` - `git_root(path: str | None = None) -> str | None` but those names now come from your config-defined runtime file rather than from the feature plugin itself. `runtime_code` may also contribute config UI. If it exports a zero-argument `get_ui_elements()` function, the feature evaluates that helper with `CONFIG`, `TAGS`, and `MODELS` already present in the runtime namespace. Variable-level `runtime_code` helpers may also export `get_ui_elements()`. The feature merges UI entries from the top-level helper and any variable-level helpers, deduplicating exact duplicates. ## Recommended Pattern - Keep large static instruction text in markdown files. - Use `files` or `${file:...}` to load those files into the template. - Put reusable imports, helpers, and convenience values in `runtime_code`. - Use `condition` for whole-section inclusion. - Use `inline_code` for short dynamic strings. - Use `code` for moderately complex dynamic sections such as optional local-instructions blocks. - When another feature needs request-scoped prompt sections, write `state["system_message_variables"]` and, if needed, pre-populate `state["request"]["messages"]`; this feature preserves existing request message overrides while rendering the final system message. See [application/python/agent_terminal_app/default_config/config.json](https://docs.cl-static-test.dynamicprogrammingsolutions.com/reference/default-config/index.md) for a multi-vendor example that follows this pattern. ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # feature-web-context Generated from `plugins/feature-web-context/README.md`. Feature plugin providing `@web:` marker expansion and URL completions. This plugin depends on `trafilatura` for fetching and extracting web page content. ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # gemini-compaction-app Generated from `plugins/gemini-compaction-app/README.md`. Gemini-style context compaction application plugin for the AI Agent Platform. ## Overview This plugin provides manual context compaction using Gemini's structured XML `` prompt format. It exposes session and message actions for compacting conversation history, with an optional verification pass that asks the model to self-critique and improve its summary. **Important**: This plugin is **disabled by default** and must be explicitly enabled per-agent. ## Features - **Session Actions**: "Compact range", "Compact full history" - **Message Action**: "Compact messages up to here" - **Gemini `` prompt**: Structured XML output with sections for goals, constraints, knowledge, artifacts, file state, actions, and task state - **Optional verification pass**: Second LLM call for self-correction - **Configurable summarizer agent**: Use a dedicated agent for compaction - **Explicit enablement**: Only active when `enabled: true` and `type: "gemini"` ## Install From the repo root: ``` python -m pip install -e core/python python -m pip install -e "plugins/gemini-compaction-app[dev]" ``` ## Environment - `OPENROUTER_API_KEY` is required for OpenRouter-based configs. - `BUILTIN_PLUGINS` should point to the `plugins` directory for `path:` plugin loading. - For local use in this repo, keeping `OPENROUTER_API_KEY` in the repo root `.env` is the simplest setup. - Do not commit real API keys into configs or docs. ## Quickstart This is the smallest useful terminal/app-layer config when you want Gemini-style compaction with an OpenRouter-backed agent. ``` { "plugin_cache_dir": "${env:CONFIG_DIR}/.plugin_cache/plugins", "plugins": [ "path:${env:BUILTIN_PLUGINS}/openrouter", "path:${env:BUILTIN_PLUGINS}/gemini-compaction-app" ], "providers": { "openrouter_gemini": { "provider": "openrouter", "model": "google/gemini-2.5-flash-lite", "api_key": "${env:OPENROUTER_API_KEY}", "base_url": "https://openrouter.ai/api/v1", "timeout": 180 } }, "agents": { "default": { "provider": "openrouter_gemini", "compaction": { "enabled": true, "type": "gemini" } } } } ``` Run the terminal app: ``` cd application/python python -m agent_terminal_app --console --config /path/to/config_gemini_compaction.json ``` ## Configuration Configuration is **agent-level** with explicit enablement control. The plugin is only active when both `enabled` is `true` and `type` is `"gemini"`. ``` { "agents": { "default": { "provider": "openrouter_gemini", "compaction": { "enabled": true, "type": "gemini", "agent_id": null, "prompt": null, "settings_overrides": {}, "verification_pass": true } } } } ``` ### Configuration Options | Option | Type | Default | Description | | -------------------- | ------- | ------------- | -------------------------------------------------------------------------------------------------------------------- | | `enabled` | boolean | `false` | Must be `true` to enable compaction for this agent | | `type` | string | `null` | Must be `"gemini"` for this plugin. Other values (e.g., `"qwen"`, `"kimi"`) are for other compaction implementations | | `agent_id` | string | `null` | Optional dedicated summarizer agent ID | | `prompt` | string | Gemini prompt | Custom compaction prompt | | `settings_overrides` | object | `{}` | Request settings overrides (e.g., `model`, `reasoningEffort`) | | `verification_pass` | boolean | `true` | Enable/disable verification pass | ### Enablement Logic The plugin is enabled **only when**: 1. `compaction.enabled` is `true` **AND** 1. `compaction.type` is `"gemini"` In all other cases: - `get_ui_elements()` returns `[]` (no UI elements) - `execute_action()` returns an error with `error_type: "disabled"` ## Usage ### Session Action: "Compact range" Select a range of messages to compact using Python-style slice indices (start:end). The selected messages will be replaced with a structured state snapshot. ### Message Action: "Compact messages up to here" Click on a message and select this action to compact all messages before it. ### Session Action: "Compact full history" Compact the entire conversation history into a single state snapshot. ## History Injection Shape After compaction, the history becomes: ``` [system messages...] user: ... assistant: Got it. Thanks for the additional context! [preserved tail messages...] ``` ## Example Configurations ### With dedicated summarizer agent ``` { "plugin_cache_dir": "${env:CONFIG_DIR}/.plugin_cache/plugins", "plugins": [ "path:${env:BUILTIN_PLUGINS}/openrouter", "path:${env:BUILTIN_PLUGINS}/gemini-compaction-app" ], "providers": { "main": { "provider": "openrouter", "model": "anthropic/claude-3.5-sonnet", "api_key": "${env:OPENROUTER_API_KEY}", "base_url": "https://openrouter.ai/api/v1", "timeout": 180 }, "summarizer": { "provider": "openrouter", "model": "openai/gpt-4o-mini", "api_key": "${env:OPENROUTER_API_KEY}", "base_url": "https://openrouter.ai/api/v1", "timeout": 120 } }, "agents": { "default": { "provider": "main", "compaction": { "enabled": true, "type": "gemini", "agent_id": "summarizer", "verification_pass": false } }, "summarizer": { "provider": "summarizer" } } } ``` ### With model override for compaction ``` { "plugin_cache_dir": "${env:CONFIG_DIR}/.plugin_cache/plugins", "plugins": [ "path:${env:BUILTIN_PLUGINS}/openrouter", "path:${env:BUILTIN_PLUGINS}/gemini-compaction-app" ], "providers": { "openrouter_claude": { "provider": "openrouter", "model": "anthropic/claude-3.5-sonnet", "api_key": "${env:OPENROUTER_API_KEY}", "base_url": "https://openrouter.ai/api/v1", "timeout": 180 } }, "agents": { "default": { "provider": "openrouter_claude", "compaction": { "enabled": true, "type": "gemini", "settings_overrides": { "model": "openai/gpt-4o-mini" } } } } } ``` ## Testing ### Unit Tests ``` cd plugins/gemini-compaction-app pytest tests/test_gemini_compaction_app_unit.py -v ``` ### Integration Tests (requires OpenRouter API key) ``` export OPENROUTER_API_KEY=your_key_here cd plugins/gemini-compaction-app pytest tests/test_gemini_compaction_app_openrouter_integration.py -v -m openrouter ``` ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # gemini-compaction-feature Generated from `plugins/gemini-compaction-feature/README.md`. A Feature-level plugin that provides Gemini-style context compaction using the layered context approach. ## Overview This plugin implements the same compaction logic as `GeminiCompactionAppPlugin` but operates at the Feature plugin level, using the layered context to access LLM capabilities. ## Key Differences from AppPlugin | Feature | AppPlugin | FeaturePlugin | | ------------------- | ----------------------------------------- | -------------------------------------- | | Session persistence | Built-in (via `app.save_session`) | Returns `session_metadata` patch | | Agent switching | Full support via `app.update_agent()` | Limited (requires app context) | | Session locking | Built-in (via `app.acquire_session_lock`) | Not available | | Operation level | Application layer | Core layer | | Return value | Mutations dict | `native_messages` + `session_metadata` | ## When to Use - **Use AppPlugin when**: You need full session management, persistence, and UI integration - **Use FeaturePlugin when**: Operating at the native-message level, provider-specific optimizations, or core-level workflows ## Configuration ``` compaction: enabled: true # REQUIRED for plugin to be active type: gemini # REQUIRED for this plugin agent_id: null # Optional: dedicated summarizer agent prompt: null # Optional: custom prompt override settings_overrides: {} # Optional: model/params for compaction verification_pass: true # Optional: enable verification pass (default: true) ``` ## Layered Context This plugin uses the layered context approach: - **Layer 1 (Core)**: Uses `context["core"]` for session/message operations - **Layer 2 (Application)**: Optionally uses `context.get("app")` for enhanced features like agent switching ## Installation ``` pip install gemini-compaction-feature ``` ## Usage Add to your configuration: ``` { "plugins": [ "gemini_compaction_feature.GeminiCompactionFeaturePlugin" ] } ``` ## Dependencies This plugin depends on `gemini-compaction-app` for shared prompts and helpers. ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # gemini-tools Generated from `plugins/gemini-tools/README.md`. Compatibility-oriented Gemini-style filesystem tools for the AI Agent Platform. This package aims to match Gemini CLI model-facing tool schemas, parameter names, major parameter semantics, important truncation and error wording, and LLM-visible tool result content closely while reusing shared Python filesystem helpers underneath. Structured media payloads are preserved in raw tool results. In the current text-first tool message pipeline, those payloads are still rendered to the model as text/JSON. ## Exposed tools Current public tools in this package: - `read_file` - `list_directory` - `grep_search` - `glob` - `write_file` - `replace` - `run_shell_command` - `web_fetch` - `google_web_search` Write/edit surface summary: - `write_file(file_path, content)` writes the full file and overwrites existing content. - `replace(file_path, instruction, old_string, new_string, allow_multiple=false)` performs literal replacement and expects exactly one match unless `allow_multiple` is enabled. The plugin repo exposes these classes through `agent_plugin.json`: - `gemini_tools.gemini_read_file_tool.GeminiReadFileTool` - `gemini_tools.gemini_list_directory_tool.GeminiListDirectoryTool` - `gemini_tools.gemini_grep_search_tool.GeminiGrepSearchTool` - `gemini_tools.gemini_glob_tool.GeminiGlobTool` - `gemini_tools.gemini_write_file_tool.GeminiWriteFileTool` - `gemini_tools.gemini_replace_tool.GeminiReplaceTool` - `gemini_tools.gemini_shell_tool.GeminiShellTool` - `gemini_tools.gemini_web_fetch_tool.GeminiWebFetchTool` - `gemini_tools.gemini_web_search_tool.GeminiWebSearchTool` ## Quickstart Minimal global plugin loading with the application plugin manager: ``` { "plugin_cache_dir": "~/.crystal/plugins", "plugin_policy": { "base_dir": ".", "install_deps": true }, "plugins": [ "path:./plugins/gemini-tools" ] } ``` Combine it with a provider bundle such as OpenRouter: ``` { "plugin_cache_dir": "~/.crystal/plugins", "plugin_policy": { "base_dir": ".", "install_deps": true }, "plugins": [ "path:./plugins/openrouter", "path:./plugins/gemini-tools" ], "agents": { "default": { "provider": "openrouter", "model": "google/gemini-2.5-flash-lite" } } } ``` `install_deps` matters for this package because it depends on the sibling shared package under `plugins/tool-compat-shared`. For now, the simplest setup is to load this package in the global `plugins` list. Once subtask `040-agent-and-provider-plugin-package-config` is completed, agent-level and provider-level `plugins` placement will also be documented and available for this package family. ## Writer behavior notes The Gemini-compatible writer entrypoint is `write_file`. - `file_path` may be relative to `working_directory`. - `content` is the full file body and overwrites existing content. - Missing parent directories are created automatically. - `.geminiignore` patterns are honored relative to `working_directory`. - Writes are restricted to `allowed_paths` plus `project_temp_dir`. Representative `write_file` call shape: ``` { "type": "function", "function": { "name": "write_file", "arguments": { "file_path": "src/note.txt", "content": "alpha\n" } } } ``` Representative outcomes: ``` Successfully created and wrote to new file: /workspace/src/note.txt. Successfully overwrote file: /workspace/src/note.txt. ``` ## Edit behavior notes The Gemini-compatible edit entrypoint is `replace`. - `replace` uses literal text matching, not regex. - `instruction` is required and mirrors the Gemini model-facing schema. - By default, `replace` expects exactly one match. - Set `allow_multiple` to `true` to replace every occurrence of `old_string`. - If `old_string` is empty and the target file does not already exist, `replace` creates a new file with `new_string` as its content. Representative `replace` call shape: ``` { "type": "function", "function": { "name": "replace", "arguments": { "file_path": "src/note.txt", "instruction": "Rename alpha to beta", "old_string": "alpha", "new_string": "beta" } } } ``` Representative outcomes: ``` Successfully modified file: /workspace/src/note.txt (1 replacements). Failed to edit, 0 occurrences found for old_string in /workspace/src/note.txt. Ensure you're not escaping content incorrectly and check whitespace, indentation, and context. Use read_file tool to verify. Failed to edit. Expected 1 occurrence but found 2 for old_string in file: /workspace/src/note.txt. If you intended to replace multiple occurrences, set 'allow_multiple' to true. Created new file: /workspace/src/note.txt with provided content. ``` ## Reader behavior notes The Gemini-compatible reader entrypoint is `read_file`. - `file_path` may be relative to `working_directory`. - `start_line` and `end_line` are 1-based and inclusive. - If no range is provided, text reads default to a 2000-line window. - Very long individual lines are shortened to 2000 characters. - Truncated text reads use the Gemini-style banner beginning with: `IMPORTANT: The file content has been truncated.` - `.geminiignore` patterns are honored relative to `working_directory`. - Images, audio, video, and PDFs are detected and returned as inline-data tool payloads. Representative `read_file` examples: Successful text read: ``` def greet(name): return f"hello {name}" ``` Truncated text read: ``` IMPORTANT: The file content has been truncated. Status: Showing lines 1-2000 of 2451 total lines. Action: To read more of the file, you can use the 'start_line' and 'end_line' parameters in a subsequent 'read_file' call. For example, to read the next section of the file, use start_line: 2001. --- FILE CONTENT (truncated) --- ... ``` Representative error cases: ``` Error: The 'file_path' parameter must be non-empty. Error: start_line cannot be greater than end_line Error: File path '/workspace/secret.txt' is ignored by configured ignore patterns. ``` ## Search behavior notes The Gemini-compatible search entrypoints are `list_directory`, `grep_search`, and `glob`. - `list_directory(dir_path, ignore?, file_filtering_options?)` lists only the direct children of a directory. - `grep_search(pattern, dir_path?, include_pattern?, exclude_pattern?, names_only?, max_matches_per_file?, total_max_matches?)` keeps the fuller Gemini search shape, including names-only mode and per-file/total match caps. - `glob(pattern, dir_path?, case_sensitive?, respect_git_ignore?, respect_gemini_ignore?)` returns absolute paths sorted by modification time with recent files first. - Relative search paths are resolved from `working_directory`. - `.geminiignore` and optional `.gitignore` filtering are applied in the same compatibility layer used by the reader and edit tools. Representative `list_directory` output: ``` Directory listing for /workspace/src: [DIR] nested alpha.py (128 bytes) (1 ignored) ``` Representative `glob` output: ``` Found 2 file(s) matching "*.py" within /workspace/src, sorted by modification time (newest first): /workspace/src/main.py /workspace/src/util.py ``` Representative `grep_search` output: ``` Found 2 matches for pattern "TODO" in path "src": File: /workspace/src/main.py L12: # TODO: tighten validation --- File: /workspace/src/util.py L7: # TODO: remove debug path ``` ## Shell behavior notes The Gemini-compatible shell entrypoint is `run_shell_command`. - `command` is the shell command to execute (required). - `description` is an optional brief description of the command purpose. - `dir_path` sets the working directory for execution. - `is_background` runs the command in background mode (useful for dev servers). - Timeout defaults to 120 seconds (configurable via `shell_timeout_seconds`). - Output is truncated at 40,000 characters with 20% head / 80% tail split. - Truncated output is saved to `project_temp_dir` with a reference path. Representative `run_shell_command` call shape: ``` { "type": "function", "function": { "name": "run_shell_command", "arguments": { "command": "npm run build", "description": "Build the project", "dir_path": "/workspace" } } } ``` Representative outcomes: Success: ``` Output: Build completed successfully. ``` Failure: ``` Output: Error: Build failed Error: Command failed with exit code 1 Exit Code: 1 ``` Background: ``` Command moved to background (PID: 12345). Output hidden. ``` Timeout: ``` Command was automatically cancelled because it exceeded the timeout of 2.0 minutes without output. Below is the output before it was cancelled: ... ``` Truncation: ``` Output too large. Showing first 8,000 and last 32,000 characters. For full output see: /workspace/.temp/shell_output_abc123.txt ... ``` ## Web Fetch behavior notes The Gemini-compatible web fetch entrypoint is `web_fetch`. ### Modes The tool supports two modes controlled by configuration: 1. **Prompt mode (default)**: URLs embedded in a prompt, processed via Gemini API server-side grounding. Returns content with inline citations `[N]` and a `Sources:` footer. 1. **Direct mode**: Single URL per call, fetched directly without Gemini API. Useful when Gemini API is unavailable or for private network URLs. ### Parameters **Prompt mode:** - `prompt` (required): A comprehensive prompt that includes the URL(s) (up to 20) to fetch and specific instructions on how to process their content. **Direct mode:** - `url` (required): The URL to fetch. Must be a valid http or https URL. ### Behavior 1. **Primary path (prompt mode with Gemini API)**: Uses Gemini API's server-side URL grounding capability. 1. Returns content with inline citations `[N]` and a `Sources:` footer. 1. Requires a Gemini API key and `web_fetch_use_gemini_api=true`. 1. **Fallback path**: Local HTTP fetch + LLM summarization. 1. Triggered when: - `web_fetch_use_gemini_api=false` (explicitly disabled) - No Gemini API key available - Private IP URLs (localhost, 10.x, 172.16-31.x, 192.168.x) - Primary path fails 1. Uses the current agent for summarization, or a dedicated fallback agent if configured. 1. Settings can be overridden via `web_fetch_fallback_settings_overrides`. 1. **Rate limiting**: 10 requests per 60 seconds per hostname (configurable). 1. **GitHub URL normalization**: Automatically converts `github.com/.../blob/...` URLs to `raw.githubusercontent.com` URLs. ### Configuration All configuration keys are flat with `web_fetch_` prefix: | Key | Type | Default | UI | Description | | --------------------------------------- | ------- | ----------- | ------- | ------------------------------------------------------------ | | `web_fetch_enabled` | boolean | true | Yes | Enable the web_fetch tool | | `web_fetch_use_gemini_api` | boolean | true | Yes\* | Use Gemini API for primary path | | `web_fetch_direct_mode` | boolean | false | Yes | Use direct URL mode instead of prompt mode | | `web_fetch_primary_model` | string | "web-fetch" | Yes\*\* | Gemini model for primary path | | `web_fetch_fallback_agent_id` | string | null | No | Optional dedicated agent for fallback | | `web_fetch_fallback_settings_overrides` | object | {} | No | Settings to override for fallback (e.g., model, temperature) | | `web_fetch_timeout_seconds` | integer | 10 | Yes | Fetch timeout in seconds | | `web_fetch_max_content_chars` | integer | 100000 | No | Maximum content length in characters | | `web_fetch_rate_limit_requests` | integer | 10 | No | Max requests per window per host | | `web_fetch_rate_limit_window` | integer | 60 | No | Rate limit window in seconds | \* Only visible when Gemini API key is available. \*\* Only visible when API key available AND `web_fetch_use_gemini_api=true`. Additional configuration: - `gemini_api_key`: Gemini API key (or `GEMINI_API_KEY` environment variable). Required for primary path when `use_gemini_api=true`. ### Fallback Settings Overrides The `web_fetch_fallback_settings_overrides` config allows overriding settings for the fallback path. This works similar to the compaction plugin's settings overrides: ``` web_fetch_fallback_settings_overrides: model: "gpt-4" temperature: 0.3 ``` When fallback is triggered: 1. If `fallback_agent_id` is set: switch to that agent, apply settings overrides 1. If `fallback_agent_id` is null: apply settings overrides to current agent ### Representative call shapes Prompt mode: ``` { "type": "function", "function": { "name": "web_fetch", "arguments": { "prompt": "Summarize the main points from https://example.com/article" } } } ``` Direct mode (when `web_fetch_direct_mode: true`): ``` { "type": "function", "function": { "name": "web_fetch", "arguments": { "url": "https://example.com/article" } } } ``` ### Representative outcomes Primary path success: ``` The article discusses three main topics[1] related to web development[2]. Sources: [1] Article Title (https://example.com/article) [2] Related Content (https://example.com/related) ``` Fallback success: ``` [LLM-summarized content based on raw fetch] ``` Error cases: ``` Error: The 'prompt' parameter cannot be empty and must contain URL(s) and instructions. Error: The 'prompt' must contain at least one valid URL (starting with http:// or https://). Error: Rate limit exceeded for host. Please wait 45 seconds before trying again. Error: Failed to fetch https://example.com: HTTP 404 ``` ### Known Limitations - No browser rendering for JavaScript-heavy pages - No IDE integration for approval flows - Requires Gemini API key for primary path when `use_gemini_api=true` - Private IP URLs always use fallback (local fetch) - Direct mode does not use Gemini API for URL processing ## Web Search behavior notes The Gemini-compatible web search entrypoint is `google_web_search`. ### Parameters - `query` (required): The search query to find information on the web. ### Behavior 1. **Primary backend (`gemini_api`)**: Uses the Gemini API's server-side Google Search grounding via `GeminiApiProvider`. 1. Returns content with inline citation markers `[N]` inserted at **UTF-8 byte offsets** (matching Gemini CLI's `TextEncoder`/`TextDecoder` approach). 1. Appends a `Sources:` footer with `[N] Title (url)` entries. 1. Requires a Gemini API key (`gemini_api_key` config or `GEMINI_API_KEY` environment variable). 1. **Alternative backend (`tavily`)**: Uses the Tavily search API via `TavilyProvider`. 1. Returns results in the same Gemini-style wrapper (`Web search results for "query":\n\n...`). 1. Includes a `Sources:` footer built from result URLs. 1. No inline citation markers (only the Gemini API provides grounding metadata). 1. If Tavily returns an AI-generated answer, it is included before the result list. 1. Requires a Tavily API key (`tavily_api_key` config or `TAVILY_API_KEY` environment variable). 1. **Disabled by default**: The tool is not enabled unless `web_search_enabled` is set to `true`. If enabled without any configured provider, the tool returns an error. ### UTF-8 byte-offset citations The Gemini API's `groundingSupports` use **UTF-8 byte offsets** for `startIndex` and `endIndex`, not character offsets. This matters for multi-byte text (e.g., 日本語, emoji 🎉). The tool encodes the response text to UTF-8 bytes, inserts citation markers at the byte positions, then decodes back — matching the Gemini CLI's `web-search.ts` implementation exactly. ### Configuration All configuration keys are flat with `web_search_` prefix: | Key | Type | Default | UI | Description | | --------------------- | ------- | ------------ | ----- | -------------------------------------------- | | `web_search_enabled` | boolean | false | Yes | Enable the google_web_search tool | | `web_search_provider` | string | "gemini_api" | Yes\* | Search backend: `"gemini_api"` or `"tavily"` | | `tavily_api_key` | string | null | No | Tavily API key (for tavily backend) | \* Only visible when more than one provider is available. Additional configuration: - `gemini_api_key`: Gemini API key (or `GEMINI_API_KEY` environment variable). Required for `gemini_api` backend. ### Representative call shape ``` { "type": "function", "function": { "name": "google_web_search", "arguments": { "query": "Python programming language history" } } } ``` ### Representative outcomes Gemini API backend (with citations): ``` Web search results for "Python programming language history": Python was created by Guido van Rossum[1] and first released in 1991[2]. Sources: [1] Python (https://en.wikipedia.org/wiki/Python_(programming_language)) [2] Python History (https://python.org/about/history) ``` Tavily backend (without inline citations): ``` Web search results for "Python programming language history": Python is a high-level programming language. 1. Python (programming language) Python is a high-level, general-purpose programming language. Sources: [1] Python (https://en.wikipedia.org/wiki/Python_(programming_language)) ``` No results: ``` No search results or information found for query: "obscure non-existent topic xyz" ``` Error: ``` Error during web search for query "test": [Gemini API] API request failed: ... ``` ### Known Limitations - Disabled by default; must be explicitly enabled - Requires at least one API key (Gemini or Tavily) to function - Only the `gemini_api` backend provides inline citation markers - No browser rendering or JavaScript execution for search results ## Compatibility contract Parity for this package means: - Gemini-facing tool names and parameter names stay stable. - Important parameter semantics match Gemini expectations. Relative `file_path`, 1-based line ranges, and `replace` semantics are part of that contract. - Important truncation and validation wording stays close to Gemini CLI. - The LLM-visible result content stays aligned with Gemini-style tool usage, including truncation wrappers and major read/write/edit success and error messages. This package does not attempt to mirror Gemini CLI's internal service graph or application-side orchestration. ## Cross-Package Edit Comparison The three compatibility packages intentionally preserve different public edit tool names and flags: - Gemini uses `replace(..., allow_multiple=false)` and keeps the Gemini name `replace`. - Qwen uses `edit(..., replace_all=false)` and keeps the Qwen name `edit`. - Grok uses `str_replace_editor(..., replace_all=false)` and keeps the Grok name `str_replace_editor`. If you are choosing between packages, the shared behavior is literal text replacement inside workspace-safe paths. The public name, argument names, path rules, and some validation wording stay vendor-specific. ## Configuration reference Shared package-level config keys used by the current Gemini tools: - `working_directory` Default: current working directory. Used as the base for relative paths and ignore-file lookup. - `allowed_paths` Default: `[working_directory]`. Paths must resolve inside one of these directories or the project temp dir. - `project_temp_dir` Default: `/.temp`. Additional allowed location for reads and writes. Also used to store full output from truncated shell commands. - `shell_timeout_seconds` Default: `120`. Default timeout for shell command execution in seconds. - `shell_max_output_chars` Default: `40000`. Maximum characters to return in shell output before truncation. - `shell_truncation_head_ratio` Default: `0.2` (20% head, 80% tail). Ratio for head/tail split when truncating shell output. - `list_directory`, `grep_search`, and `glob` also resolve relative paths from `working_directory` and restrict access to `allowed_paths` plus `project_temp_dir`. Web search config keys: - `web_search_enabled` Default: `false`. Must be set to `true` to enable the `google_web_search` tool. - `web_search_provider` Default: `"gemini_api"`. Search backend: `"gemini_api"` (requires `gemini_api_key`) or `"tavily"` (requires `tavily_api_key`). - `tavily_api_key` Default: none. Tavily API key. Also read from `TAVILY_API_KEY` environment variable. - `gemini_api_key` Default: none. Gemini API key. Also read from `GEMINI_API_KEY` environment variable. Required for the `gemini_api` web search backend and the primary `web_fetch` path. Write/edit safety notes: - `write_file` and `replace` both allow relative `file_path` values, resolved from `working_directory`. - Writes and edits are restricted to `allowed_paths` plus `project_temp_dir`. - `.geminiignore` can block both reads and edits. - `replace` is literal, not regex-based. - `allow_multiple` defaults to `false`, so repeated text requires an explicit opt-in. Ignore behavior: - `.geminiignore` is read from `working_directory` when present. - `list_directory` can also report ignored child counts. - `glob` and `grep_search` both reuse the same `.geminiignore` filtering and accept optional `.gitignore` participation flags in the Gemini-compatible schema. ## Compatibility and Limitations In scope for this package: - Gemini-style tool names and OpenAI chat-completions function schemas - Gemini-style path handling, validation, and major success/error wording - Gemini-style model-visible behavior for file reads, listing, globbing, grep-style search, full-file writes, literal replacements, and web search Intentionally out of scope for this tool-plugin package: - host approval and confirmation UX - IDE integration and diff handoff flows - telemetry and internal service behavior from Gemini CLI - exact host-managed application prompting and orchestration That means schema parity and tool behavior parity are the goal here. CLI confirmation screens, editor handoff, and approval workflows remain host-application concerns rather than tool-plugin concerns. ## Notes The current package scope includes writer/editor tools in addition to the first reader subtask because later subtasks extended the same compatibility bundle. ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # generate-title-app Generated from `plugins/generate-title-app/README.md`. Application-level plugin for generating session titles (and descriptions). ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # grok-tools Generated from `plugins/grok-tools/README.md`. Compatibility-oriented Grok-style filesystem tools for the AI Agent Platform. This package aims to match Grok CLI model-facing tool schemas, parameter names, major parameter semantics, important truncation and error wording, and LLM-visible tool result content closely while reusing shared Python filesystem helpers underneath. ## Exposed tools Current public tools in this package: - `view_file` - `create_file` - `str_replace_editor` - `bash` Search/list note: - Grok-compatible unified `search` is intentionally deferred. This package does not expose Gemini/Qwen-style `list_directory`, `grep_search`, or `glob` wrappers because Grok's search surface is structurally different and will be handled in a later dedicated task. Write/edit surface summary: - `create_file(path, content)` is creation-only and fails if the target already exists. - `str_replace_editor(path, old_str, new_str, replace_all=false)` performs literal replacement and expects exactly one match unless `replace_all` is enabled. - Grok keeps the shortest write surface of the three packages: new-file creation plus targeted replacement. The plugin repo exposes these classes through `agent_plugin.json`: - `grok_tools.grok_view_file_tool.GrokViewFileTool` - `grok_tools.grok_create_file_tool.GrokCreateFileTool` - `grok_tools.grok_str_replace_editor_tool.GrokStrReplaceEditorTool` - `grok_tools.grok_shell_tool.GrokShellTool` ## Quickstart Minimal global plugin loading with the application plugin manager: ``` { "plugin_cache_dir": "~/.crystal/plugins", "plugin_policy": { "base_dir": ".", "install_deps": true }, "plugins": [ "path:./plugins/grok-tools" ] } ``` Combine it with a provider bundle such as OpenRouter: ``` { "plugin_cache_dir": "~/.crystal/plugins", "plugin_policy": { "base_dir": ".", "install_deps": true }, "plugins": [ "path:./plugins/openrouter", "path:./plugins/grok-tools" ], "agents": { "default": { "provider": "openrouter", "model": "x-ai/grok-4.1-fast" } } } ``` `install_deps` matters for this package because it depends on the sibling shared package under `plugins/tool-compat-shared`. For now, the simplest setup is to load this package in the global `plugins` list. Once subtask `040-agent-and-provider-plugin-package-config` is completed, agent-level and provider-level `plugins` placement will also be documented and available for this package family. ## Writer behavior notes The Grok-compatible writer entrypoint is `create_file`. - `path` may be relative to `working_directory`. - `create_file` is creation-only and fails if the target already exists. - Missing parent directories are created automatically. - Writes are restricted to `allowed_paths` plus `project_temp_dir`. Representative `create_file` call shape: ``` { "type": "function", "function": { "name": "create_file", "arguments": { "path": "src/note.txt", "content": "alpha\n" } } } ``` Representative outcomes: ``` Created new file: /workspace/src/note.txt File already exists, cannot create: /workspace/src/note.txt ``` ## Edit behavior notes The Grok-compatible edit entrypoint is `str_replace_editor`. - `str_replace_editor` uses literal text matching, not regex. - `path` may be relative to `working_directory`. - By default, `str_replace_editor` expects exactly one match. - Set `replace_all` to `true` to replace every occurrence of `old_str`. - Unlike Gemini and Qwen, this edit tool does not create files when the target is missing. Representative `str_replace_editor` call shape: ``` { "type": "function", "function": { "name": "str_replace_editor", "arguments": { "path": "src/note.txt", "old_str": "alpha", "new_str": "beta" } } } ``` Representative outcomes: ``` Updated file: /workspace/src/note.txt Found 2 matches for old_str in /workspace/src/note.txt. Set replace_all to true to replace every occurrence. File not found: /workspace/src/note.txt ``` ## Reader behavior notes The Grok-compatible reader entrypoint is `view_file`. - `path` may point to either a file or a directory. - For files, `start_line` and `end_line` are 1-based and inclusive. - For directories, the tool returns a directory listing instead of a read-file error. - File reads currently reuse the shared Gemini-style slicing/truncation engine, so large text reads use the same truncation banner style. Representative `view_file` examples: Successful file read: ``` function greet(name) { return `hello ${name}`; } ``` Directory listing: ``` Directory listing for src: agent/ main.ts tools.ts ``` Representative error cases: ``` Error: The 'path' parameter must be non-empty. Error: start_line cannot be greater than end_line Error: Path not in workspace: /tmp/outside.txt ``` ## Shell behavior notes The Grok-compatible shell entrypoint is `bash`. - `command` is the shell command to execute (required). - Grok has a **minimal schema** - no timeout, no working directory, no background mode. - Output is returned raw without special formatting. - No truncation is applied. Representative `bash` call shape: ``` { "type": "function", "function": { "name": "bash", "arguments": { "command": "echo hello" } } } ``` Representative outcomes: Success: ``` hello ``` Failure: ``` command not found: invalid_cmd ``` Note: Unlike Gemini and Qwen, Grok does not include timeout, background mode, or output truncation features in its shell tool. ## Compatibility contract Parity for this package means: - Grok-facing tool names and parameter names stay stable. - Important parameter semantics match Grok expectations. That includes `view_file` serving both file reads and directory listings. - Important validation wording stays close to the Grok tool surface. - The LLM-visible result content stays aligned with Grok-style tool usage, including directory listings and major file-read success and error messages. This package does not attempt to mirror Grok CLI's surrounding agent guidance, confirmation flow, or other application-side orchestration. ## Cross-Package Edit Comparison The three compatibility packages intentionally preserve different public edit tool names and flags: - Gemini uses `replace(..., allow_multiple=false)` and keeps the Gemini name `replace`. - Qwen uses `edit(..., replace_all=false)` and keeps the Qwen name `edit`. - Grok uses `str_replace_editor(..., replace_all=false)` and keeps the Grok name `str_replace_editor`. If you are choosing between packages, the shared behavior is literal text replacement inside workspace-safe paths. The public name, argument names, path rules, and some validation wording stay vendor-specific. ## Configuration reference Shared package-level config keys used by the current Grok tools: - `working_directory` Default: current working directory. Used as the base for relative paths and relative display output. - `allowed_paths` Default: `[working_directory]`. Paths must resolve inside one of these directories or the project temp dir. - `project_temp_dir` Default: `/.temp`. Additional allowed location for reads and writes. Write/edit safety notes: - `create_file` and `str_replace_editor` accept relative `path` values, resolved from `working_directory`. - Writes and edits are restricted to `allowed_paths` plus `project_temp_dir`. - `create_file` is intentionally creation-only and does not overwrite existing files. - `str_replace_editor` is literal, not regex-based. - `replace_all` defaults to `false`, so repeated text requires an explicit opt-in. ## Compatibility and Limitations In scope for this package: - Grok-style tool names and OpenAI chat-completions function schemas - Grok-style path handling, directory listing support, and major success/error wording - Grok-style model-visible behavior for view, create, and literal replacement operations Intentionally out of scope for this tool-plugin package: - host approval and confirmation UX - IDE integration and diff handoff flows - telemetry and internal service behavior from Grok CLI - exact host-managed application prompting and orchestration - the future Grok unified `search` tool discussed in the Gemini/Qwen search subtask That means schema parity and tool behavior parity are the goal here. CLI confirmation screens, editor handoff, and approval workflows remain host-application concerns rather than tool-plugin concerns. ## Notes The current package scope includes writer/editor tools in addition to the first reader subtask because later subtasks extended the same compatibility bundle. ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # js-class-tool Generated from `plugins/js-class-tool/README.md`. Example tool plugin implemented as a **TypeScript class** (instead of exporting a plain object). - Plugin id: `js_class_tool` - Tool function(s): `class_echo` The Node tool host supports exporting either: - a plain object (existing semantics), or - a class (the host instantiates it with `new`), or - a factory function returning an object. ## Build ``` esbuild src/index.ts --bundle --platform=node --format=esm --outdir=dist ``` ## Configure (local path) ``` { "plugins": [ { "node_tool": { "path": "./plugins/js-class-tool" } } ] } ``` Or with the string shortcut: ``` { "plugins": [ "node:./plugins/js-class-tool" ] } ``` Because `dist/` is gitignored, the application plugin manager will install dependencies and run the plugin's build script in its cache automatically. For safety/hardening, you can disable Node installs/builds globally via `plugin_policy` (applies to all Node tools): ``` { "plugin_policy": { "node_install_deps": false, "node_build": false, "node_allow_install_scripts": false } } ``` ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # js-echo-tool Generated from `plugins/js-echo-tool/README.md`. Example JavaScript/TypeScript tool plugin for the AI Agent Platform. - Plugin id: `js_echo_tool` - Tool function(s): `echo` The tool is authored in TypeScript under `src/` and built to `dist/index.js`. ## Build This repo uses `esbuild` to compile TypeScript: ``` esbuild src/index.ts --bundle --platform=node --format=esm --outdir=dist ``` ## Configure (local path) Two equivalent ways to reference the plugin from an application config: Note: these examples assume you are using the application plugin manager (`AgentApplication`) and have set `plugin_cache_dir` plus `plugin_policy.base_dir` so relative paths resolve. - Verbose form: ``` { "plugins": [ { "node_tool": { "path": "./plugins/js-echo-tool" } } ] } ``` - String shortcut: ``` { "plugins": [ "node:./plugins/js-echo-tool" ] } ``` ### TypeScript build options Because `dist/` is gitignored, the application plugin manager will install dependencies and run the plugin's build script in its cache automatically. For safety/hardening, you can disable Node installs/builds globally via `plugin_policy` (applies to all Node tools): ``` { "plugin_policy": { "node_install_deps": false, "node_build": false, "node_allow_install_scripts": false } } ``` Alternatively, you can build the plugin directory yourself (and ensure the plugin cache contains the built `dist/` output). ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # js-fs-tools Generated from `plugins/js-fs-tools/README.md`. Example filesystem tools implemented in Node.js/TypeScript. - Plugin id: `js_fs_tools` - Tool function(s): `write_file`, `read_file` The primary implementation lives in `src/FsToolsTool.ts`. ## Security / config This tool enforces a simple allowlist: - `allowed_paths`: required list of directories that the tool can access. - `working_directory`: base directory for relative paths. It also supports a streaming toggle: - `node_tool_stream_parts` (default `true`): when false, the tool does not emit tool-host partial events. ## Build ``` esbuild src/index.ts --bundle --platform=node --format=esm --outdir=dist ``` ## Configure (local path) Note: these examples assume you are using the application plugin manager (`AgentApplication`) and have set `plugin_cache_dir` plus `plugin_policy.base_dir` so relative paths resolve. - Verbose form: ``` { "plugins": [ { "node_tool": { "path": "./plugins/js-fs-tools" } } ] } ``` - String shortcut: ``` { "plugins": [ "node:./plugins/js-fs-tools" ] } ``` ### TypeScript build Because `dist/` is gitignored, the application plugin manager will install dependencies and run the plugin's build script in its cache automatically. For safety/hardening, you can disable Node installs/builds globally via `plugin_policy` (applies to all Node tools): ``` { "plugin_policy": { "node_install_deps": false, "node_build": false, "node_allow_install_scripts": false } } ``` ## Example config values ``` { "allowed_paths": [".", "./tmp"], "working_directory": "./tmp", "node_tool_stream_parts": true } ``` ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # js-multi-tools Generated from `plugins/js-multi-tools/README.md`. Example multi-tool Node.js/TypeScript package. This package demonstrates **named export selection** via `package.json#agent.tools[].export`. - Plugin ids: `js_echo`, `js_reverse` - Tool function(s): `echo`, `reverse` Tool implementations live in: - `src/EchoTool.ts` - `src/ReverseTool.ts` ## Build ``` esbuild src/index.ts --bundle --platform=node --format=esm --outdir=dist ``` ## Configure (local path) Note: these examples assume you are using the application plugin manager (`AgentApplication`) and have set `plugin_cache_dir` plus `plugin_policy.base_dir` so relative paths resolve. - Verbose form: ``` { "plugins": [ { "node_tool": { "path": "./plugins/js-multi-tools" } } ] } ``` - String shortcut: ``` { "plugins": [ "node:./plugins/js-multi-tools" ] } ``` ### TypeScript build Because `dist/` is gitignored, the application plugin manager will install dependencies and run the package build script in its cache automatically. For safety/hardening, you can disable Node installs/builds globally via `plugin_policy` (applies to all Node tools): ``` { "plugin_policy": { "node_install_deps": false, "node_build": false, "node_allow_install_scripts": false } } ``` ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # js-single-file-tool Generated from `plugins/js-single-file-tool/README.md`. Single-file TypeScript tool example. - Tool file: `UpperTool.ts` - Plugin id: defaults to the file stem unless overridden (`UpperTool` -> `UpperTool`). - Tool function(s): `upper` This example is intended for quick local development without a full package directory. ## Configure (local file) Note: these examples assume you are using the application plugin manager (`AgentApplication`) and have set `plugin_cache_dir` plus `plugin_policy.base_dir` so relative paths resolve. - Verbose form: ``` { "plugins": [ { "node_tool": { "file": "./plugins/js-single-file-tool/UpperTool.ts", "id": "js_single_file_tool" } } ] } ``` - String shortcut: ``` { "plugins": [ "node:./plugins/js-single-file-tool/UpperTool.ts" ] } ``` Note: the string shortcut cannot carry an explicit `id`; in that form the plugin id defaults to the file stem. ### TypeScript build The application plugin manager compiles `.ts` files by generating a tiny cached package (with `esbuild` in `devDependencies`), then running install + build in that cache. For safety/hardening, you can disable Node installs/builds globally via `plugin_policy` (applies to all Node tools): ``` { "plugin_policy": { "node_install_deps": false, "node_build": false, "node_allow_install_scripts": false } } ``` ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # kimi-tools Generated from `plugins/kimi-tools/README.md`. Kimi CLI-compatible tool plugin bundle. Provides file tools (ReadFile, ReadMediaFile, WriteFile, StrReplaceFile, Glob, Grep, FetchURL, Shell) with schemas, parameter names, output formatting, and behavioral semantics matching MoonshotAI's `kimi-cli`. ## Exposed Tools ### ReadFile Read text content from a file with line-numbered output (cat -n format). - **Parameters**: `path` (required), `line_offset` (default 1), `n_lines` (default 1000) - **Limits**: 1000 lines, 2000 chars/line, 100KB total output - **Output**: 6-digit right-aligned line numbers with tab separator - **Truncation**: Per-line truncation with `"..."` marker; Kimi-style truncation messages ### ReadMediaFile Read image media content from a local file and return it in model-visible form. - **Parameters**: `path` (required) - **Capabilities**: Exposed when `kimi_read_media_capabilities` includes `image_in` or `video_in` - **Image support**: PNG, JPEG, GIF, and WebP are returned as data URLs plus structured image output - **Video support**: Detected and capability-gated, but model-visible video output is currently deferred - **Limits**: 100MB by default via `kimi_read_media_max_bytes` - **Output**: Kimi-style `...` fallback text, `multipartContent` for image-capable chat providers, and structured image output for providers that support it ### FetchURL Fetch a web page from a URL and extract main text content from it. - **Parameters**: `url` (required) - **Primary path**: Moonshot fetch service (when configured) - **Fallback path**: Local HTTP fetch + trafilatura extraction - **No LLM summarization**: Returns raw extracted text (unlike Gemini and Qwen) - **Content types**: - `text/plain` and `text/markdown`: passthrough (no extraction) - HTML: trafilatura extraction with Kimi-specific options - **Limits**: 50000 characters max - **Extraction options**: `include_comments=True`, `include_tables=True`, `include_formatting=False`, `with_metadata=True` #### Moonshot Service Configuration The Moonshot fetch service is **opt-in**. By default, FetchURL uses local HTTP fetch. To enable the service: ``` { "moonshot_enabled": true, "moonshot_api_key": "${MOONSHOT_API_KEY}" } ``` **Required when enabled:** - `moonshot_enabled`: Set to `true` to enable the service - `moonshot_api_key`: API key (can use `${ENV_VAR}` placeholder) **Optional:** - `moonshot_base_url`: Service URL (default: `https://api.kimi.com/coding/v1/fetch`) - `moonshot_custom_headers`: Additional headers for requests When enabled and configured, FetchURL will attempt the Moonshot service first, falling back to local fetch on failure. ### Shell Execute shell commands with clean environment support. - **Parameters**: `command` (required), `timeout` (default 60, max 300) - **Timeout**: In seconds; default 60s, max 300s - **Clean environment**: Removes PyInstaller/frozen-executable variables when running as frozen - **Output format**: Raw output + success/failure message - **Truncation**: Per-line truncation with `"[...truncated]"` marker, capped to 2000 chars/line and 50000 chars total ### WriteFile Write content to a file with overwrite or append mode. - **Parameters**: `path` (required), `content` (required), `mode` (default `"overwrite"`) - **Modes**: `"overwrite"` replaces entire file; `"append"` adds to end ### StrReplaceFile Replace specific strings within a file. Supports single or multiple edits per call. - **Parameters**: `path` (required), `edit` (required — single Edit or list of Edit objects) - **Edit struct**: `{old: str, new: str, replace_all: bool}` - **Multi-edit**: Applies edits sequentially to file content ### Glob Find files and directories using glob patterns. - **Parameters**: `pattern` (required), `directory` (optional), `include_dirs` (default true) - **Safety**: Rejects patterns starting with `**` with directory listing - **Limits**: 1000 matches maximum; alphabetical sort order ### Grep Search for patterns in file contents using regular expressions. - **Parameters**: `pattern` (required), `path` (default `"."`), `glob`, `output_mode`, `-B`, `-A`, `-C`, `-n`, `-i`, `type`, `head_limit`, `multiline` - **Output modes**: `content`, `files_with_matches`, `count_matches` - **Backend**: ripgrep (system `rg` binary) with Python fallback ## Kimi-Specific Behavior Notes 1. **PascalCase tool names**: `ReadFile`, `ReadMediaFile`, `WriteFile`, `StrReplaceFile`, `Glob`, `Grep`, `FetchURL`, `Shell` 1. **Line-numbered output**: ReadFile returns `cat -n` style line numbers 1. **Append mode**: WriteFile supports `mode: "append"` for appending content 1. **Multi-edit**: StrReplaceFile accepts single Edit or list of Edit structs 1. **`**`-pattern blocking**: Glob rejects patterns starting with `**` 1. **Multi-mode grep**: Three output modes with ripgrep-style flags 1. **Different content limits**: 1000 lines, 2000 chars/line, 100KB total 1. **Clean environment**: Shell removes PyInstaller variables when running as frozen 1. **FetchURL no summarization**: Returns raw extracted text without LLM summarization 1. **Trafilatura with metadata**: FetchURL uses `with_metadata=True` for richer extraction ## Configuration ``` { "working_directory": "/path/to/project", "allowed_paths": ["/path/to/project"], "project_temp_dir": "/path/to/project/.temp", "kimi_read_media_capabilities": ["image_in"], "kimi_read_media_max_bytes": 104857600, "shell_timeout_seconds": 60, "shell_max_timeout_seconds": 300, "shell_clean_env": true, "web_fetch_timeout_seconds": 10, "web_fetch_max_chars": 50000, "moonshot_enabled": false, "moonshot_api_key": "${MOONSHOT_API_KEY}" } ``` ### Config keys: **File tools:** - `working_directory`: Base for relative paths - `allowed_paths`: Allowed directories for file access - `project_temp_dir`: Default: `/.temp` - `kimi_read_media_capabilities`: Media capabilities for exposing `ReadMediaFile`; defaults to `["image_in"]`. Use `[]` to hide the tool. - `kimi_read_media_max_bytes`: Maximum file size for `ReadMediaFile`; defaults to `104857600` bytes. **Shell tool:** - `shell_timeout_seconds`: Default timeout for shell commands (default: 60) - `shell_max_timeout_seconds`: Maximum allowed timeout (default: 300) - `shell_clean_env`: Use clean environment for subprocess (default: true) **FetchURL tool:** - `web_fetch_timeout_seconds`: HTTP fetch timeout in seconds (default: 10) - `web_fetch_max_chars`: Maximum content length in characters (default: 50000) - `moonshot_enabled`: Enable Moonshot fetch service (default: false, opt-in) - `moonshot_api_key`: API key for Moonshot service, can use `${ENV_VAR}` placeholder - `moonshot_base_url`: Moonshot service URL (default: `https://api.kimi.com/coding/v1/fetch`) - `moonshot_custom_headers`: Additional headers for service requests (optional) ## Dependencies - `tool-compat-shared` (shared filesystem/search helpers) - `web-fetch-shared` (shared HTTP fetch and trafilatura extraction) - System `rg` (ripgrep) binary recommended for Grep tool performance ## Development ``` pip install -e ".[dev]" pytest tests -q ``` ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # mcp-runtime-app Generated from `plugins/mcp-runtime-app/README.md`. Application-level observability plugin for MCP runtimes. This plugin exposes session actions for: - showing MCP runtime status - clearing stored MCP runtimes - forcing reconnect on the next request by clearing runtimes It is intentionally application-scoped rather than tool-scoped so that the UI can inspect runtime state independent of any one wrapper plugin. ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # mcp-shared Generated from `plugins/mcp-shared/README.md`. Internal helper package for MCP-backed tool wrappers. This package is intentionally not a plugin bundle and does not expose `agent_plugin.json`. It exists only to share vendor-neutral MCP runtime logic across wrapper plugins such as Codex, Kimi, and Gemini MCP tool adapters. ## Shared package boundary This package owns only: - transport/runtime config dataclasses - application-scoped MCP runtime registry - MCP client lifecycle management - raw tool discovery - raw tool execution - runtime reuse helpers - server status snapshots It does not own: - tool naming visible to the model - wrapper-specific config parsing - schema shaping or sanitization policies - provider-native or wrapper-specific result formatting - UI elements Wrapper plugins should parse their own config and convert it to the canonical runtime config types exported here. `MCPRegistry` is the MCP-specific object intended to be stored inside the application-owned generic `runtime_store`. The store itself remains generic and MCP-agnostic; only the registry knows how to cache and reuse live MCP runtimes. ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # message-export-actions Generated from `plugins/message-export-actions/README.md`. Application plugins that add two message actions to the chat UI: - `Send to Kindle` - `Share as PDF` - `Share as Markdown` These plugins now expose both: - message actions for exporting a single selected message - session actions for exporting a filtered session transcript ## Features - `Send to Kindle` - prompts for an optional title - supports an optional recipient email override - generates an EPUB with `pandoc` - derives the attachment filename from the effective title - sends through iCloud SMTP using the same config-key contract as the existing Kindle tool - `Share as PDF` - prompts for optional title and optional filename - generates a PDF from the selected message - stores the PDF as a session asset - returns a follow-up action so clients can share on mobile or save on desktop - includes its own download action, so it does not rely on a separate attachment-download plugin being loaded - defaults to a more web-like PDF style with sans-serif fonts, cleaner heading spacing, softer code blocks, and tighter margins - `Share as Markdown` - prompts for optional title and optional filename - writes the same rendered export document used before PDF conversion directly to a `.md` file - stores the Markdown file as a session asset - returns the same generated-file follow-up action used by PDF exports Session export behavior: - includes all `user` messages - includes `assistant` messages that have non-empty text content - excludes `system` and `tool` messages - adds an `H1` header at the top with the session id and session title when available - adds an `H2` header for each exported user or assistant message - demotes headings inside message content so message-local headings start at `H3` ## Configuration keys This plugin reads resolved agent/session config keys at runtime: - `send_to_kindle_smtp_user` - `send_to_kindle_from_addr` - `send_to_kindle_default_to` - `send_to_kindle_keychain_service` - `message_export_pdf_engine` - `message_export_pdf_style` Recommended `message_export_pdf_engine` values: - `auto` - `pandoc` - `playwright` - `xhtml2pdf` Behavior: - `auto`: use `xhtml2pdf` - `pandoc`: require `pandoc` - `playwright`: require Playwright and its Chromium browser - `xhtml2pdf`: always use the Python fallback engine Recommended `message_export_pdf_style` values: - `web` - `classic` Behavior: - `web`: default. Uses a cleaner exported-web-page style, including sans-serif typography, lighter dividers, improved heading rhythm, and softer code/table styling. - `classic`: keeps the older simpler styling. The PDF engine controls the conversion backend. The PDF style controls appearance. Kindle title behavior: - message export default title: `Session Message ` - session export default title: - session metadata title when present - otherwise `Session ` - both Kindle actions also accept an optional `to` email override - when omitted, `send_to_kindle_default_to` is used ## Runtime dependencies Python dependencies installed with the plugin: - `markdown` - `playwright` - `xhtml2pdf` Optional system dependencies: - `pandoc` Optional `pandoc` PDF engine dependencies: - `xelatex` or `pdflatex` Optional `playwright` PDF engine dependency: - Chromium browser installed for the Python environment: `bash python -m playwright install chromium` Kindle sending is specifically implemented for macOS + iCloud Mail and expects: - the `security` CLI - an iCloud Mail app-specific password stored in Keychain ## iCloud and Kindle setup guide This plugin’s `Send to Kindle` action uses iCloud SMTP on macOS. The safest setup is: - keep non-secret defaults in config or `.env` - keep the iCloud app-specific password only in macOS Keychain ### 1. Confirm iCloud Mail is enabled Make sure the Apple account you want to use has iCloud Mail enabled and that you can send mail from it normally. Useful Apple docs: - iCloud Mail overview: - iCloud Mail server settings: ### 2. Turn on two-factor authentication for the Apple account Apple requires two-factor authentication before you can create app-specific passwords. Apple docs: - Two-factor authentication: - App-specific passwords: ### 3. Generate an Apple app-specific password 1. Open the Apple account portal: 1. Sign in with the Apple account used for iCloud Mail. 1. Go to `Sign-In and Security`. 1. Open `App-Specific Passwords`. 1. Create a new password with a label such as `kindle-smtp` or `crystal-lattice-kindle`. 1. Copy the generated password immediately. Important: - this is not your normal Apple account password - the generated password is shown once - if you later rotate or revoke it, update the Keychain entry described below ### 4. Find your Kindle email address and approve the sender If you are sending to Kindle, you also need Amazon-side setup. Amazon docs: - Send to Kindle overview and supported formats: - Kindle personal document email help: Typical steps: 1. Find the Kindle `Send to Kindle` email address for the device or account. 1. Add your iCloud sender address to Amazon’s approved sender list. 1. Confirm Amazon accepts `EPUB` for your target workflow. ### 5. Store the app-specific password in macOS Keychain Store the generated password once using the macOS `security` CLI. Example: ``` security add-generic-password \ -U \ -a 'your-icloud-address@icloud.com' \ -s 'kindle-smtp' \ -w 'PASTE_THE_APP_SPECIFIC_PASSWORD_HERE' ``` Meaning of the fields: - `-a`: Keychain account name, usually your iCloud SMTP username - `-s`: Keychain service name, which should match your config - `-w`: the app-specific password value After that, the plugin reads the password from Keychain at send time. The raw password does not need to be stored in repo config, `.env`, or shell history again. ### 6. SMTP settings used by this plugin The plugin uses the standard iCloud SMTP settings documented by Apple: - host: `smtp.mail.me.com` - port: `587` - transport security: `STARTTLS` - username: your full iCloud email address - password: the app-specific password retrieved from Keychain ## Installation Add the plugin repo to your config: ``` { "mixins": { "shared_agent_defaults": { "send_to_kindle_smtp_user": "${env:ICLOUD_KINDLE_SMTP_USER}", "send_to_kindle_from_addr": "${env:ICLOUD_KINDLE_FROM_ADDR}", "send_to_kindle_default_to": "${env:ICLOUD_KINDLE_DEFAULT_TO}", "send_to_kindle_keychain_service": "${env:ICLOUD_KINDLE_KEYCHAIN_SERVICE}", "message_export_pdf_engine": "${env:MESSAGE_EXPORT_PDF_ENGINE}", "message_export_pdf_style": "${env:MESSAGE_EXPORT_PDF_STYLE}" } }, "plugins": [ {"path": "/absolute/path/to/plugins/message-export-actions"} ], "agents": { "default": { "provider": "your-provider-id", "mixin_refs": ["shared_agent_defaults"] } } } ``` Example `.env` values: ``` ICLOUD_KINDLE_SMTP_USER=your-icloud-address@icloud.com ICLOUD_KINDLE_FROM_ADDR=your-icloud-address@icloud.com ICLOUD_KINDLE_DEFAULT_TO=your-kindle-address@kindle.com ICLOUD_KINDLE_KEYCHAIN_SERVICE=kindle-smtp MESSAGE_EXPORT_PDF_ENGINE=auto MESSAGE_EXPORT_PDF_STYLE=web ``` If you prefer a fully inline config instead of `.env` placeholders: ``` { "mixins": { "shared_agent_defaults": { "send_to_kindle_smtp_user": "your-icloud-address@icloud.com", "send_to_kindle_from_addr": "your-icloud-address@icloud.com", "send_to_kindle_default_to": "your-kindle-address@kindle.com", "send_to_kindle_keychain_service": "kindle-smtp", "message_export_pdf_engine": "auto", "message_export_pdf_style": "web" } }, "plugins": [ {"path": "/absolute/path/to/plugins/message-export-actions"} ], "agents": { "default": { "provider": "your-provider-id", "mixin_refs": ["shared_agent_defaults"] } } } ``` You can also load it from the built-in plugins directory if your install layout exposes it there. ## Notes - `Send to Kindle` does not currently fall back when `pandoc` is unavailable. It returns a clear error instead. - Export scope is intentionally limited to message text plus lightweight metadata. - Multipart attachments are not rendered into the exported document in this version. - Markdown exports reuse the same message/session rendering pipeline as PDF exports, but skip the PDF conversion step. - EPUB generation uses the same general metadata defaults as the bash Kindle tools: - language defaults to `en-US` - TOC is enabled - TOC depth defaults to `2` - the built-in local-link Lua filter is applied by default ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # openai_responses Generated from `plugins/openai_responses/README.md`. OpenAI Responses API bundle for the AI Agent Platform. Use it when you want an OpenAI-backed agent with: - plain chat over the Responses API - OpenAI API-key auth, ChatGPT auth, or automatic fallback between them - streaming and non-streaming replies - direct-send image attachments - upload-before-send image/PDF attachments - tool calling - reasoning controls and surfaced reasoning metadata - usage and cost metadata in message/UI output - verbosity controls for supported models - flex processing controls with configurable timeout and retries - prompt caching controls with a session-owned cache key - native conversation compaction for longer sessions Most users can think of this as a single plugin bundle that adds OpenAI Responses support to the app. The package name for installation is `openai_responses`. It can also be paired with the `chatgpt-auth-app` application plugin so Responses requests can use a saved ChatGPT login against the ChatGPT-hosted endpoint. ## Install From the repo root: ``` python -m pip install -e core/python python -m pip install -e "plugins/openai_responses[dev]" ``` ## Environment - `OPENAI_API_KEY` is required unless you pass `api_key` directly in provider config and use `auth_mode: "api"`, or use `auth_mode: "auto"` with a valid saved ChatGPT login. - For local use in this repo, keeping `OPENAI_API_KEY` in the repo root `.env` is the simplest setup. - Do not commit real API keys into configs or docs. The provider defaults to `OPENAI_API_KEY` automatically, so `api_key` can be omitted from config when the env var is present. When `auth_mode: "chatgpt"` is selected, or when `auth_mode: "auto"` resolves to ChatGPT because a valid saved login exists, the provider reads a saved credential file instead of requiring `OPENAI_API_KEY`. When credentials and network access are available, API-key mode fetches the real OpenAI model list through the OpenAI Python SDK. For ChatGPT auth, the provider merges three Codex-backed sources for model discovery: - checked-in manual fallback models - a checked-in Codex models snapshot - the latest live Codex models catalog from GitHub If discovery is unavailable, the UI falls back to manual text entry for `model`. ## Quickstart This is the smallest useful terminal/app-layer config when you want basic Responses chat with the whole `openai_responses` bundle loaded from its plugin descriptor. ``` { "plugin_cache_dir": "~/.crystal/cache/plugins", "plugins": [ "path:/absolute/path/to/plugins/openai_responses" ], "providers": { "openai": { "provider": "openai_responses", "model": "gpt-5-mini", "timeout": 60 } }, "agents": { "default": { "provider": "openai" } } } ``` Run the terminal app: ``` cd application/python python -m agent_terminal_app --console --config /path/to/config_openai_responses.json ``` In config-aware UIs, the provider will try to populate the `model` selector from the appropriate live catalog. If that lookup fails, you can still type a model id manually. ## ChatGPT auth quickstart This is the smallest useful config for OpenAI Responses with ChatGPT login support. It assumes: - the `chatgpt-auth-app` application plugin is loaded - the system-message feature is loaded - top-level `instructions` should come from the system-message feature rather than a provider-only `instructions` config key ``` { "plugin_cache_dir": "${env:CONFIG_DIR}/.plugin_cache/plugins", "plugins": [ "path:/absolute/path/to/plugins/openai_responses", "path:/absolute/path/to/plugins/chatgpt-auth-app", "path:/absolute/path/to/plugins/feature-system-message" ], "providers": { "openai": { "provider": "openai_responses", "model": "gpt-5", "auth_mode": "auto", "system_message_as_instructions": true, "strip_leading_system_or_developer_message": true, "system_message": "You are a precise assistant.", "timeout": 120 } }, "application": { "chatgpt_auth": {} }, "agents": { "default": { "provider": "openai" } } } ``` Then: 1. Run the app or terminal client. 1. Use the `chatgpt_auth` application actions: 1. `Sign in with ChatGPT` 1. `Show ChatGPT login status` 1. `Cancel ChatGPT login` 1. `Log out of ChatGPT` 1. After login succeeds, `auth_mode: "auto"` will use ChatGPT-backed auth when available and fall back to API-key mode otherwise. ## Full bundle example Reasoning request shaping depends on the request-options feature, and tool usage depends on loading one or more tool plugins. This example shows the intended combination. ``` { "plugin_cache_dir": "~/.crystal/cache/plugins", "plugins": [ "path:/absolute/path/to/plugins/openai_responses", "plugins.request_options_feature.RequestOptionsFeature", "plugins.math_tools.MathTools" ], "providers": { "openai": { "provider": "openai_responses", "model": "gpt-5-mini", "api_key": "${env:OPENAI_API_KEY}", "base_url": "https://api.openai.com/v1", "auth_mode": "api", "timeout": 120, "enable_flex_processing": false, "enable_fast_mode": false, "enable_fast_mode_for_api_requests": false, "flex_timeout_seconds": 300, "enable_flex_retries": true, "flex_max_retries": 2, "attachment_upload_mode": "upload_before_send", "enable_prompt_cache_key": true, "enable_prompt_cache_retention_24h": false, "reasoning_effort": "minimal", "reasoning_summary": "concise", "verbosity": "high" } }, "agents": { "default": { "provider": "openai" } } } ``` What this enables: - OpenAI Responses chat - reasoning controls and reasoning metadata on assistant messages - tool schema injection and tool-call handling - usage and cost metadata - GPT-5 verbosity controls - flex processing controls for supported OpenAI accounts - Fast mode controls for ChatGPT-backed requests, with optional API opt-in - prompt caching settings plus a regenerate-key session action - a native compaction session action for longer conversations ## ChatGPT auth mode The bundle supports three auth modes: - `api` - use `api_key` or `OPENAI_API_KEY` - use the normal OpenAI API base URL - `chatgpt` - use saved ChatGPT login credentials - use the ChatGPT-hosted Responses endpoint - `auto` - use ChatGPT when a valid saved login exists - otherwise fall back to API-key mode Relevant keys: - `auth_mode` - `chatgpt_credentials_path` - `chatgpt_base_url` - `src/openai_responses_plugins/data/openai_chatgpt_manual_models.json` - checked-in ChatGPT-only fallback models and estimate-only pricing - pricing entries may define `pricing`, `service_tiers`, or both - used only when a model is missing from upstream sources - `src/openai_responses_plugins/data/openai_codex_models.json` - checked-in offline snapshot of the public Codex model catalog - refresh it with `python scripts/refresh_openai_responses_models.py` - used when live Codex fetching fails or when you want offline coverage from a recent repo update - `chatgpt_account_id` - `chatgpt_client_version` - `strip_leading_system_or_developer_message` ChatGPT model-discovery precedence: - live Codex catalog overrides the checked-in Codex snapshot - checked-in Codex snapshot overrides manual fallback models - manual fallback models only fill gaps Defaults: - ChatGPT base URL defaults to `https://chatgpt.com/backend-api/codex` - saved credentials default to `CONFIG_DIR/auth/chatgpt-auth.json` when the auth feature can discover `CONFIG_DIR` from runtime env ### Instructions path for ChatGPT mode For the ChatGPT endpoint, the recommended shape is: - set `system_message_as_instructions: true` on the provider config - let the system-message feature compile the prompt into `state["request"]["instructions"]` - avoid a provider-only `instructions` config key This mirrors how Codex-style requests send top-level `instructions`. ### Migrating older sessions If older session histories contain a leading provider-native `system` or `developer` message from before top-level `instructions` were used, enable: - `strip_leading_system_or_developer_message: true` When enabled, the provider drops the first native message only if its role is `system` or `developer` before sending the request. ## Features ### Tools Load one or more tool plugins, and the bundle will pass their schemas to the Responses API and surface tool calls/results in the normal tool loop. ### Image attachments The bundle supports two attachment paths for images: - direct send through Responses `input_image` items - upload-before-send through the OpenAI Files API, followed by Responses `file_id` references Direct-send image shapes: - `multipartContent[].content.url` with `http://` or `https://` image URLs - `multipartContent[].content.url` with `data:image/...` URLs - `multipartContent[].content.data_base64` plus `mime_type`, which the provider converts into a `data:` URL before sending When `attachment_upload_mode: "upload_before_send"` is enabled, inline image attachments are uploaded first and then sent as `input_image` items using OpenAI file references. ### PDF attachments PDF attachments are supported only when `attachment_upload_mode: "upload_before_send"` is enabled. Supported PDF input shapes: - `multipartContent[].content.url` with `data:application/pdf;base64,...` - `multipartContent[].content.data_base64` plus `mime_type: "application/pdf"` In this mode, the provider uploads the PDF through the OpenAI Files API and sends the Responses request using an `input_file` item with a `file_id` reference. Explicit upload actions are still follow-up work for Stage 3. When the package is loaded through its plugin path, it also exposes the `openai_responses_attachments` provider extension. That extension: - advertises upload UI for supported attachment types - exposes `store_attachment`, `delete_attachment`, and `download_attachment` actions backed by the application-owned session asset store - lets clients upload an attachment once, send it later via `asset_ref`, and download the same stored asset again from message history ### Reasoning Reasoning controls are available through: - `reasoning_effort` - `reasoning_summary` To use those options, also load `plugins.request_options_feature.RequestOptionsFeature`. When the reasoning extension is active, it also requests `include: ["reasoning.encrypted_content"]` so OpenAI reasoning items can be preserved in raw metadata for future native replay. ### Usage Usage metadata is attached to assistant messages, including formatted prompt, completion, cached-token, and cost fields. This is useful in the terminal UI and other clients that render message footers or status bars. ### Prompt caching Prompt caching controls are available through: - `enable_prompt_cache_key` - `prompt_cache_key` - `enable_prompt_cache_retention_24h` When prompt cache key support is enabled: - new sessions automatically receive a generated session-owned key - older sessions missing a key are repaired on first request - forked sessions keep the same key by default - users can manually edit the key through normal session settings - users can regenerate the key through the `regenerate_prompt_cache_key` session action By default, prompt cache key support is enabled when this extension is loaded. Set `enable_prompt_cache_key: false` if you want to turn off automatic session-owned key generation and request injection. When the effective auth mode selects the ChatGPT endpoint: - `prompt_cache_key` behavior remains available - `prompt_cache_retention: "24h"` is suppressed automatically even if `enable_prompt_cache_retention_24h` is `true` ### Flex processing Flex processing controls are available through: - `enable_flex_processing` - `flex_timeout_seconds` - `enable_flex_retries` - `flex_max_retries` When flex processing is enabled, the extension writes request overrides so the provider can apply `service_tier: "flex"`, the configured timeout, and the configured retry policy on both streaming and non-streaming Responses calls. When the effective auth mode selects the ChatGPT endpoint, flex processing is disabled automatically even if the config flags are set. The ChatGPT-hosted endpoint does not support this OpenAI API feature. ### Verbosity `verbosity` controls the Responses `text.verbosity` option for supported models. In the current implementation, that is enabled for models whose id contains `gpt-5`. ### Native compaction The bundle includes a session action that compacts provider-native history for future Responses turns. This is intended for context maintenance, not for producing a human-readable summary message. ## Configuration reference Configuration is a flat dict shared across the provider and enabled extensions. ### Provider keys - `provider`: must be `openai_responses` in app-layer provider config - `model`: required model id - `auth_mode`: optional, one of `api`, `chatgpt`, `auto`; defaults to `api` - `api_key`: optional if `OPENAI_API_KEY` is set or ChatGPT auth is used - `base_url`: optional, defaults to `https://api.openai.com/v1` - `api_base_url`: optional explicit base URL for API-key mode - `chatgpt_base_url`: optional, defaults to `https://chatgpt.com/backend-api/codex` - `chatgpt_credentials_path`: optional path to saved ChatGPT credentials - `chatgpt_account_id`: optional explicit ChatGPT account id override - `chatgpt_client_version`: optional, used for ChatGPT `/models` discovery - `strip_leading_system_or_developer_message`: optional compatibility flag for migrated sessions - `compaction_model`: optional model id for native compaction; leave empty or omit it to use `model` - `use_chatgpt_auth_for_compaction`: optional, defaults to `true`; set to `false` to force native compaction onto the API endpoint - `use_compaction_placeholder_transcript`: optional, defaults to `false`; set to `true` to show compacted native ranges as a single visible placeholder message instead of rebuilding the compacted native output directly - `timeout`: optional request timeout in seconds, defaults to `60` ### Attachment keys - `attachment_upload_mode`: `direct` or `upload_before_send`; defaults to `direct` Use `upload_before_send` when you want the provider to upload inline image or PDF attachment data through the OpenAI Files API before the Responses request. PDF support is only available in this mode. ### Flex processing keys - `enable_flex_processing`: enable `service_tier: "flex"` for Responses calls - `flex_timeout_seconds`: per-request timeout to apply when flex is enabled; defaults to `300` - `enable_flex_retries`: enable request-scoped retries for flex calls; defaults to `true` - `flex_max_retries`: max retry count when flex retries are enabled; defaults to `2` Note: flex processing is ignored automatically when the effective auth mode selects the ChatGPT endpoint. ### Fast mode Fast mode controls are available through: - `enable_fast_mode`: enable `service_tier: "priority"` for ChatGPT-backed Responses calls - `enable_fast_mode_for_api_requests`: config-only opt-in that lets `enable_fast_mode` also apply `service_tier: "priority"` to API-backed Responses calls By default, Fast mode applies only to ChatGPT-backed requests. When `enable_fast_mode_for_api_requests: true` is set in config, the same Fast mode toggle also applies `service_tier: "priority"` to API-backed requests. The usage footer renders `tier: priority` when OpenAI reports that tier, and cost estimation uses priority-tier pricing when the checked-in or manual pricing data defines it. ## Benchmark script The repo includes a benchmark helper at `scripts/run_openai_responses_tier_matrix.py`. Useful options: - `--model` or `--models` - `--repeat` - `--execution-mode sequential|parallel` - `--run-scope api|chatgpt|both` - `--desired-input-tokens` - `--desired-output-tokens` The script saves a text artifact under `artifacts/` containing: - the benchmark configuration - one section per model/tier/repeat run - actual usage and duration metrics - aggregate summaries per model/tier row ### Reasoning keys These require `plugins.request_options_feature.RequestOptionsFeature` to be loaded. - `reasoning_effort`: model-specific effort value. Current UI options are: - GPT-5 models: `minimal`, `low`, `medium`, `high` - GPT-5.1 models: `none`, `low`, `medium`, `high` - GPT-5.2 and GPT-5.4 models: `none`, `low`, `medium`, `high`, `xhigh` - GPT-5 pro: `high` - unknown models: fallback UI shows all documented values - `reasoning_summary`: one of `auto`, `concise`, `detailed` Assistant messages always expose the frontend-friendly field `metadata.reasoning`. OpenAI-specific raw reasoning data is preserved under `metadata.openai_responses_reasoning`. ### Verbosity keys - `verbosity`: one of `low`, `medium`, `high` - `show_verbosity_ui`: set to `false` to hide the verbosity selector in UI Verbosity only activates when the selected model is tagged with `supports_verbosity`. In the current implementation, that tag is added for models whose id contains `gpt-5`. ### Usage keys - `pricing_snapshot_path`: optional override for the checked-in pricing snapshot - `show_usage`: set to `false` to hide usage footer/status UI elements - `show_estimated_cached_tokens`: set to `true` to show ChatGPT estimated cached-token footers - `show_openai_auth_mode`: set to `true` to show the effective auth mode (`API` or `ChatGPT`) in usage footers ChatGPT-backed Responses usage currently estimates cached tokens using turn-aware heuristics. That estimate is kept separate from the raw provider usage payload and is used for ChatGPT-mode pricing instead of any backend-reported cached-token field. By default it is not displayed; enable `show_estimated_cached_tokens` if you want the footer. ### Prompt caching keys - `enable_prompt_cache_key`: defaults to `true`; set to `false` to disable automatic session-owned prompt cache keys - `prompt_cache_key`: optional manual cache key override shown in normal session settings UI - `enable_prompt_cache_retention_24h`: set to `true` to send `prompt_cache_retention: "24h"` Note: `enable_prompt_cache_retention_24h` is ignored automatically when the effective auth mode selects the ChatGPT endpoint. ### Tools Tool schemas come from loaded tool plugins such as `plugins.math_tools.MathTools`. No extra config key is required beyond loading tools in the application. ### Native compaction It exposes a session action with id `compact_native_history` that uses the native compaction API, rewrites provider-native history, and then rebuilds the visible transcript directly from the compacted native output. If you prefer the older placeholder-style transcript behavior, enable: - `use_compaction_placeholder_transcript: true` When enabled, compacted native segments are shown as a single visible placeholder message and are expanded back into native compaction artifacts on future requests. The bundle also exposes a session action that toggles this view mode: - `Collapse compacted messages` - `Expand compacted messages` The action changes the visible projection only. Retained native history stays canonical and is rebuilt using the updated session override. By default, native compaction follows the same effective auth mode as normal Responses requests. If `use_chatgpt_auth_for_compaction` is enabled, a session running in ChatGPT mode, or `auto` mode that selected ChatGPT, will attempt native compaction against the ChatGPT-backed endpoint too. If that backend does not support native compaction, the action fails with a clear endpoint-specific error. Set `use_chatgpt_auth_for_compaction: false` to force compaction onto the normal API endpoint instead. By default, compaction uses the same `model` as normal requests. Set `compaction_model` to use a different model for the compaction API call, for example: ``` { "model": "gpt-5.5", "compaction_model": "gpt-5.4-mini" } ``` The compacted native output shape is model-dependent. Some models return visible compacted assistant messages alongside opaque compaction artifacts; others return only the retained visible user messages plus opaque compaction state. Both shapes are valid as long as future requests continue from the compacted native history. The action no longer accepts a freeform `instructions` field. When the backend requires top-level instructions, native compaction now uses the same config-driven system-message pipeline as normal requests: - enable `system_message_as_instructions: true` - set `system_message` as usual - enable `strip_leading_system_or_developer_message: true` if older sessions still contain a persisted leading `system` or `developer` message When `system_message_as_instructions: false`, native compaction also follows the same request-time message-injection path as a normal request: - if `state["request"]["messages"]` is the effective outbound message set, compaction uses that rendered message list as its compact input - `_metadata` is stripped from outbound compact input - if the compacted output preserves the leading `system` or `developer` message, the stored native history restores the original retained version of that message, including template-form content and `_metadata` - when a retained `compaction_summary` item is present, the visible rebuilt transcript surfaces it as a clear assistant placeholder message instead of letting that native item drift into unrelated later message ownership ### Prompt caching session action It exposes a session action with id `regenerate_prompt_cache_key` that replaces the current session-owned prompt cache key without changing the visible transcript. ## Capabilities and caveats - API-key mode supports both non-streaming and streaming Responses requests. - ChatGPT mode is streaming-only in the current implementation. Non-streaming requests fail with a clear error. - The provider retains Responses-native history so future turns can replay native items rather than flattening everything to plain chat text. - Reasoning requests include `reasoning.encrypted_content` so raw reasoning can be preserved even when the model does not expose readable reasoning text. - Verbosity support is model-gated and currently follows the provider tag check for `gpt-5` models. - ChatGPT-backed requests use top-level `instructions` and `store: false`. - Flex processing and prompt-cache retention are automatically disabled when the ChatGPT endpoint is selected. - ChatGPT `/models` discovery may still require `client_version`; if discovery is unavailable, pin `model` explicitly in config. - Native compaction is intentionally different from human-readable summary insertion. It preserves provider-native compacted artifacts for future Responses requests instead of replacing history with a plain assistant summary. ## Native compaction behavior Native compaction is meant for Responses-native context maintenance, not transcript summarization. - It can compact the full native history or a visible prefix. - It uses the OpenAI Python SDK rather than application-layer summary logic. - It uses `compaction_model` when configured, otherwise it uses the normal request `model`. - It rebuilds visible messages after compaction directly from the compacted native output, whose visible assistant/user shape may vary by model. - If `use_compaction_placeholder_transcript: true` is enabled, it instead shows the compacted region as a single placeholder message in the visible transcript while still preserving the underlying retained native compaction artifacts. - Future requests continue from the compacted native history. ## Bundle layout If you are extending or debugging the bundle, the responsibilities are split like this: - provider: transport, SDK calls, Responses-native conversion, and native history replay - attachments: attachment upload policy/UI plus PDF composer UI - tools: tool schema injection and tool-call reconstruction - reasoning: reasoning request shaping and reasoning metadata - usage: usage formatting and pricing metadata - verbosity: `text.verbosity` request shaping - prompt caching: `prompt_cache_key` / retention request shaping and session-key lifecycle - ChatGPT auth feature: saved-credential loading plus `api` / `chatgpt` / `auto` auth-mode selection - native compaction: session action for provider-native compaction Implementation classes registered by the bundle: - `openai_responses_plugins.openai_responses_provider.OpenAIResponsesProvider` - `openai_responses_plugins.openai_responses_attachments_extension.OpenAIResponsesAttachmentsExtension` - `openai_responses_plugins.openai_responses_chatgpt_auth_feature.OpenAIResponsesChatGPTAuthFeature` - `openai_responses_plugins.openai_responses_flex_processing_extension.OpenAIResponsesFlexProcessingExtension` - `openai_responses_plugins.openai_responses_fast_mode_extension.OpenAIResponsesFastModeExtension` - `openai_responses_plugins.openai_responses_native_compaction_extension.OpenAIResponsesNativeCompactionExtension` - `openai_responses_plugins.openai_responses_prompt_caching_extension.OpenAIResponsesPromptCachingExtension` - `openai_responses_plugins.openai_responses_tools_extension.OpenAIResponsesToolsExtension` - `openai_responses_plugins.openai_responses_reasoning_extension.OpenAIResponsesReasoningExtension` - `openai_responses_plugins.openai_responses_usage_extension.OpenAIResponsesUsageExtension` - `openai_responses_plugins.openai_responses_verbosity_extension.OpenAIResponsesVerbosityExtension` ## Troubleshooting - Authentication errors: check `OPENAI_API_KEY` in the repo root `.env`, set `api_key` in config, or verify the saved ChatGPT credential file exists when using `chatgpt` / `auto`. - ChatGPT mode says streaming is required: use the streaming request path, or switch `auth_mode` to `api` / `auto`. - ChatGPT requests behave oddly on older sessions: enable `system_message_as_instructions: true` and `strip_leading_system_or_developer_message: true`. - Reasoning controls do nothing: make sure `plugins.request_options_feature.RequestOptionsFeature` is loaded. - Verbosity control does not appear or has no effect: verify the model is tagged with `supports_verbosity`; today that means a `gpt-5` model id. - Tool calls never appear: load at least one tool plugin and use a model/prompt that will actually call tools. - Native compaction action is missing: make sure the active provider is `openai_responses` and the bundle was loaded from `path:/absolute/path/to/plugins/openai_responses` or equivalent. ## Tests For contributors and local validation: Run the fast default package suite: ``` pytest plugins/openai_responses/tests -q ``` This package defaults to `-m 'not integration and not manual_integration'`. Run hosted API integration tests: ``` pytest plugins/openai_responses/tests -m 'integration and api and not slow_integration and openai' -q ``` Run manual ChatGPT-auth integration tests separately: ``` pytest plugins/openai_responses/tests -m 'manual_integration' -q ``` The OpenAI Responses test suite bootstraps the repo root `.env` during local use in this repo and does not overwrite already-exported environment variables. Hosted API tests typically require `OPENAI_API_KEY`. Manual integration tests also require saved ChatGPT auth state. ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # openrouter Generated from `plugins/openrouter/README.md`. Plugin bundle containing: - `OpenRouterProvider` (provider) - `OpenRouterImageGenerationProvider` (provider) - OpenRouter extensions: - tools - reasoning - web search (citations) - image-generation attachments (`store_attachment`, `delete_attachment`, `download_attachment`) Load via module-only spec so the descriptor can register multiple classes. ## Image generation The bundle now includes a separate `openrouter_image_generation` provider for OpenRouter image-generation models. V1 intentionally supports a single user turn with optional one input image and normalizes generated output into ordinary assistant messages with `multipartContent` image parts backed by session-asset `asset_ref` descriptors. The provider itself is intentionally non-streaming in V1. Applications may request streaming by default, but the current application layer detects the provider's `NotImplementedError` for streaming and transparently retries the request in non-streaming mode. The companion `openrouter_image_generation_attachments` extension exposes: - `store_attachment` - `delete_attachment` - `download_attachment` These actions are session-asset-backed so uploaded input images and generated output images can both be previewed and downloaded by clients without storing large base64 payloads in session JSON. ## Testing For contributors and local validation: - Fast default package run: `bash pytest plugins/openrouter/tests -q` This package defaults to `-m 'not integration'`. - Hosted OpenRouter integration: `bash pytest plugins/openrouter/tests -m 'integration and api and not slow_integration and openrouter' -q` - Slow hosted integration: `bash pytest plugins/openrouter/tests -m 'slow_integration and openrouter' -q` The OpenRouter test suite bootstraps the repo root `.env` for local use and does not overwrite already-exported environment variables. ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # openrouter_responses Generated from `plugins/openrouter_responses/README.md`. Plugin bundle containing an OpenRouter provider that targets the `/responses` endpoint, plus `/responses`-compatible extensions. This package is intentionally separate from `openrouter-plugins` (the `/chat/completions` provider bundle) so applications can load one or both without extension/provider name collisions. ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # qwen-tools Generated from `plugins/qwen-tools/README.md`. Compatibility-oriented Qwen-style filesystem tools for the AI Agent Platform. This package aims to match Qwen Code model-facing tool schemas, parameter names, major parameter semantics, important truncation and error wording, and LLM-visible tool result content closely while reusing shared Python filesystem helpers underneath. Structured media payloads are preserved in raw tool results. In the current text-first tool message pipeline, those payloads are still rendered to the model as text/JSON. ## Exposed tools Current public tools in this package: - `read_file` - `list_directory` - `grep_search` - `glob` - `write_file` - `edit` - `bash` - `web_fetch` Write/edit surface summary: - `write_file(file_path, content)` writes the full file at an absolute path and overwrites existing content. - `edit(file_path, old_string, new_string, replace_all=false)` performs literal replacement and expects exactly one match unless `replace_all` is enabled. - `edit` also supports file creation when `old_string` is the empty string and the target file does not yet exist. The plugin repo exposes these classes through `agent_plugin.json`: - `qwen_tools.qwen_read_file_tool.QwenReadFileTool` - `qwen_tools.qwen_list_directory_tool.QwenListDirectoryTool` - `qwen_tools.qwen_grep_search_tool.QwenGrepSearchTool` - `qwen_tools.qwen_glob_tool.QwenGlobTool` - `qwen_tools.qwen_write_file_tool.QwenWriteFileTool` - `qwen_tools.qwen_edit_tool.QwenEditTool` - `qwen_tools.qwen_shell_tool.QwenShellTool` - `qwen_tools.qwen_web_fetch_tool.QwenWebFetchTool` ## Quickstart Minimal global plugin loading with the application plugin manager: ``` { "plugin_cache_dir": "~/.crystal/plugins", "plugin_policy": { "base_dir": ".", "install_deps": true }, "plugins": [ "path:./plugins/qwen-tools" ] } ``` Combine it with a provider bundle such as OpenRouter: ``` { "plugin_cache_dir": "~/.crystal/plugins", "plugin_policy": { "base_dir": ".", "install_deps": true }, "plugins": [ "path:./plugins/openrouter", "path:./plugins/qwen-tools" ], "agents": { "default": { "provider": "openrouter", "model": "qwen/qwen3.5-flash-02-23" } } } ``` `install_deps` matters for this package because it depends on the sibling shared package under `plugins/tool-compat-shared`. For now, the simplest setup is to load this package in the global `plugins` list. Once subtask `040-agent-and-provider-plugin-package-config` is completed, agent-level and provider-level `plugins` placement will also be documented and available for this package family. ## Writer behavior notes The Qwen-compatible writer entrypoint is `write_file`. - `file_path` must be absolute. - `content` is the full file body and overwrites existing content. - Missing parent directories are created automatically. - `.qwenignore` patterns are honored relative to `working_directory`. - Writes are restricted to `workspace_dirs`, `project_temp_dir`, `global_temp_dir`, and `user_skills_dir`. Representative `write_file` call shape: ``` { "type": "function", "function": { "name": "write_file", "arguments": { "file_path": "/workspace/src/note.txt", "content": "alpha\n" } } } ``` Representative outcomes: ``` Successfully created and wrote to new file: /workspace/src/note.txt. Successfully overwrote file: /workspace/src/note.txt. File path must be absolute: src/note.txt ``` ## Edit behavior notes The Qwen-compatible edit entrypoint is `edit`. - `edit` uses literal text matching, not regex. - `file_path` must be absolute. - By default, `edit` expects exactly one match. - Set `replace_all` to `true` to replace every occurrence of `old_string`. - If `old_string` is empty and the target file does not already exist, `edit` creates a new file with `new_string` as its content. Representative `edit` call shape: ``` { "type": "function", "function": { "name": "edit", "arguments": { "file_path": "/workspace/src/note.txt", "old_string": "alpha", "new_string": "beta" } } } ``` Representative outcomes: ``` The file: /workspace/src/note.txt has been updated. Failed to edit, 0 occurrences found for old_string in /workspace/src/note.txt. No edits made. The exact text in old_string was not found. Ensure you're not escaping content incorrectly and check whitespace, indentation, and context. Use read_file tool to verify. Failed to edit. Found 2 occurrences for old_string in /workspace/src/note.txt but replace_all was not enabled. Created new file: /workspace/src/note.txt with provided content. ``` ## Reader behavior notes The Qwen-compatible reader entrypoint is `read_file`. - `absolute_path` must be absolute. - `offset` is 0-based. - `limit` is a line count. - Output lines are right-trimmed to match Qwen-style text rendering. - Truncation is controlled by package config and uses the Qwen-style wrapper beginning with `Showing lines X-Y of Z total lines.` - `.qwenignore` patterns are honored relative to `working_directory`. - Allowed read/write roots include workspace directories, `project_temp_dir`, `global_temp_dir`, and `user_skills_dir`. Representative `read_file` examples: Successful text read: ``` line one line two line three ``` Truncated text read: ``` Showing lines 1-500 of 932 total lines. --- line one line two ... ``` Representative error cases: ``` Error: The 'absolute_path' parameter must be non-empty. Error: File path must be absolute, but was relative: src/app.py. You must provide an absolute path. Error: File path '/workspace/secret.txt' is ignored by .qwenignore pattern(s). ``` ## Search behavior notes The Qwen-compatible search entrypoints are `list_directory`, `grep_search`, and `glob`. - `list_directory(path, ignore?, file_filtering_options?)` requires an absolute directory path. - `grep_search(pattern, glob?, path?, limit?)` keeps the simpler Qwen shape and is case-insensitive by default. - `glob(pattern, path?)` follows the Qwen-style path parameter and truncates long results based on package config. - `list_directory` reports separate `git-ignored` and `qwen-ignored` counts when those filters hide entries. - `grep_search` and `glob` both reuse the workspace/temp path allowlist used by the rest of the Qwen bundle. Representative `list_directory` output: ``` Directory listing for /workspace/src: [DIR] nested alpha.txt (1 git-ignored, 1 qwen-ignored) ``` Representative `glob` output: ``` Found 3 file(s) matching "*.ts" in the workspace directory, sorted by modification time (newest first): --- /workspace/src/app.ts /workspace/src/api.ts --- [1 file truncated] ... ``` Representative `grep_search` output: ``` Found 2 matches for pattern "todo" in the workspace directory (filter: "*.py"): --- File: /workspace/src/main.py L4: # TODO: tighten validation --- ``` ## Shell behavior notes The Qwen-compatible shell entrypoint is `bash`. - `command` is the shell command to execute (required). - `timeout` is in milliseconds (default: 120000, max: 600000). - `description` is an optional brief description of the command purpose. - `directory` sets the working directory for execution. - `is_background` runs the command in background mode. - Output is truncated by lines first (1000 lines), then by characters (25000). - Background output includes platform-specific kill hint (`kill` or `taskkill`). Representative `bash` call shape: ``` { "type": "function", "function": { "name": "bash", "arguments": { "command": "npm run build", "description": "Build the project", "directory": "/workspace", "timeout": 300000 } } } ``` Representative outcomes: Success (no stderr): ``` Build completed successfully. ``` Failure: ``` Stdout: (empty) Stderr: Build failed Error: (none) Exit Code: 1 Signal: (none) ``` Background (Unix): ``` Background command started. PID: 12345 (Use kill 12345 to stop) ``` Background (Windows): ``` Background command started. PID: 12345 (Use taskkill /F /T /PID 12345 to stop) ``` Timeout: ``` Stdout: (empty) Stderr: (empty) Error: Command timed out after 2.0 seconds Exit Code: (none) Signal: (none) ``` Truncation: ``` Build output line 1... Build output line 2... [500 lines truncated] ``` ## Web Fetch behavior notes The Qwen-compatible web fetch entrypoint is `web_fetch`. - `url` is the URL to fetch content from (required). - `prompt` is the prompt to run on the fetched content (required). - Content is fetched directly using HTTP GET. - HTML content is converted to plain text using trafilatura. - Content is truncated to `web_fetch_max_content_chars` characters. - The configured provider is used to summarize content based on the prompt. - Supports both public and private/localhost URLs. - Rate limited per-host (default: 10 requests per 60 seconds). Representative `web_fetch` call shape: ``` { "type": "function", "function": { "name": "web_fetch", "arguments": { "url": "https://example.com/article", "prompt": "Summarize the main points of this article" } } } ``` Representative outcomes: Success: ``` [LLM-summarized content based on the prompt] ``` Error: ``` Error: Error during fetch for https://example.com: HTTP 404 ``` Rate limited: ``` Error: Rate limit exceeded. Please wait 30 seconds. ``` ### Configuration | Key | Default | Description | | ---------------------------------- | -------- | ------------------------------------------ | | `web_fetch_enabled` | `true` | Enable the web_fetch tool | | `web_fetch_timeout_seconds` | `10` | Fetch timeout in seconds | | `web_fetch_max_content_chars` | `100000` | Max content length in characters | | `web_fetch_rate_limit_requests` | `10` | Max requests per window per host | | `web_fetch_rate_limit_window` | `60` | Rate limit window in seconds | | `web_fetch_summarization_agent_id` | `null` | Optional dedicated agent for summarization | | `web_fetch_summarization_model` | `null` | Optional model override for summarization | ### Known Limitations - No browser rendering for JavaScript-heavy pages - Requires a configured provider for summarization - No special handling for private IPs (fetches all URLs directly) ## Compatibility contract Parity for this package means: - Qwen-facing tool names and parameter names stay stable. - Important parameter semantics match Qwen expectations. Absolute paths, 0-based `offset`, `limit` as line count, and `edit` replacement behavior are part of that contract. - Important truncation and validation wording stays close to Qwen Code. - The LLM-visible result content stays aligned with Qwen-style tool usage, including truncation wrappers and major read/write/edit success and error messages. This package does not attempt to mirror Qwen Code's internal service graph or application-side orchestration. ## Cross-Package Edit Comparison The three compatibility packages intentionally preserve different public edit tool names and flags: - Gemini uses `replace(..., allow_multiple=false)` and keeps the Gemini name `replace`. - Qwen uses `edit(..., replace_all=false)` and keeps the Qwen name `edit`. - Grok uses `str_replace_editor(..., replace_all=false)` and keeps the Grok name `str_replace_editor`. If you are choosing between packages, the shared behavior is literal text replacement inside workspace-safe paths. The public name, argument names, path rules, and some validation wording stay vendor-specific. ## Configuration reference Shared package-level config keys used by the current Qwen tools: - `working_directory` Default: current working directory. Used as the display root for relative output paths and ignore-file lookup. - `workspace_dirs` Default: falls back to `allowed_paths`, then `[working_directory]`. Primary allowlist for file access. - `allowed_paths` Optional compatibility alias for `workspace_dirs`. - `project_temp_dir` Default: `/.temp`. Additional allowed location for reads and writes. - `global_temp_dir` Default: system temp directory. Additional allowed location for reads and writes. - `user_skills_dir` Default: `~/.qwen/skills`. Additional allowed location for reads and writes. - `shell_timeout_seconds` Default: `120`. Default timeout for shell command execution in seconds. - `shell_max_timeout_seconds` Default: `600`. Maximum allowed timeout for shell commands. - `shell_max_output_lines` Default: `1000`. Maximum lines to return in shell output before line truncation. - `shell_max_output_chars` Default: `25000`. Maximum characters to return in shell output before char truncation. - `truncate_tool_output_lines` Default: `500`. Default line window for `read_file` when `limit` is omitted. - `truncate_tool_output_threshold` Default: `2500`. Character budget used by Qwen-style truncation. - `list_directory` requires an absolute `path` value. - `glob.path` and `grep_search.path` may be omitted, in which case the current `working_directory` is used. Write/edit safety notes: - `write_file` and `edit` require absolute `file_path` values. - File access is restricted to `workspace_dirs`, `project_temp_dir`, `global_temp_dir`, and `user_skills_dir`. - `.qwenignore` can block both reads and edits. - `edit` is literal, not regex-based. - `replace_all` defaults to `false`, so repeated text requires an explicit opt-in. - Using `old_string: ""` is the package-level creation path for `edit` when the file does not already exist. Ignore behavior: - `.qwenignore` is read from `working_directory` when present. - `list_directory` can report separate `git-ignored` and `qwen-ignored` counts. - `glob` and `grep_search` reuse `.qwenignore` filtering and the same workspace/temp allowlist as the Qwen reader and edit tools. ## Compatibility and Limitations In scope for this package: - Qwen-style tool names and OpenAI chat-completions function schemas - Qwen-style absolute-path validation, truncation behavior, and major success/error wording - Qwen-style model-visible behavior for file reads, listing, globbing, grep-style search, full-file writes, and literal replacements Intentionally out of scope for this tool-plugin package: - host approval and confirmation UX - IDE integration and diff handoff flows - telemetry and internal service behavior from Qwen Code - exact host-managed application prompting and orchestration That means schema parity and tool behavior parity are the goal here. CLI confirmation screens, editor handoff, and approval workflows remain host-application concerns rather than tool-plugin concerns. ## Notes The current package scope includes writer/editor tools in addition to the first reader subtask because later subtasks extended the same compatibility bundle. ## License qwen-tools is a Qwen-compatible tool plugin bundle for Crystal Lattice. Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # rebuild-native-app Generated from `plugins/rebuild-native-app/README.md`. Application-level plugin to rebuild provider-native history from core messages. ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # reference-openai-compatible-provider Generated from `plugins/reference-openai-compatible-provider/README.md`. Generic OpenAI-compatible Chat Completions provider package for the AI Agent Platform. This package is intended as a reusable baseline for OpenAI-like backends, including local servers such as Ollama and hosted providers that expose `/v1/chat/completions`-style APIs. Supports: - non-streaming and streaming chat completions - OpenAI-style tool calling - generic reasoning metadata extraction and preservation when providers return reasoning fields - generic usage metadata extraction when providers return `usage` - image attachments for OpenAI-compatible chat completions - remote image URLs - inline/data-URL images - session-owned uploaded images stored locally by the application and inlined at send time Reasoning, tools, and usage are provider- and model-dependent. ## Quickstart (Ollama example) Prerequisites: - Ollama is running (`ollama serve`) - a compatible model is available (for example `ollama pull qwen3:0.6b`) Create a terminal app config (example: `config_ollama.json`): ``` { "plugin_cache_dir": "~/.crystal/cache/plugins", "providers": { "ollama": { "provider": "reference_openai_compatible", "base_url": "http://localhost:11434/v1", "model": "qwen3:0.6b", "timeout": 180, "request_options": { "think": true } } }, "plugins": [ "path:/absolute/path/to/plugins/reference-openai-compatible-provider", "path:/absolute/path/to/plugins/feature-request-options" ], "agents": { "default": {"provider": "ollama"} } } ``` Run the terminal app: ``` cd application/python python -m agent_terminal_app --console --config /path/to/config_ollama.json ``` `request_options.think` is only an Ollama-compatible example. Other providers may use different request options, nested reasoning controls, or no reasoning toggle at all. ## Configuration Configuration is a single flat dict passed to the provider and all enabled plugins. In application configs, provider settings live under `providers.` and may be overridden per-agent under `agents.`. ### Provider config keys These keys are read by `reference_openai_compatible_provider.provider.OpenAICompatibleProvider`: - `provider` (string): provider selector used by the application config - value: `reference_openai_compatible` - `model` (string, required): model id to send in the request payload - `base_url` (string): OpenAI-compatible base URL; the provider appends `/chat/completions` - default: `https://api.openai.com/v1` - `endpoint_options` (object, optional): labeled base URL choices that render an endpoint selector bound to `base_url` - example: `{ "General": "https://api.z.ai/api/paas/v4", "Coding": "https://api.z.ai/api/coding/paas/v4" }` - `api_key` (string, optional): bearer token for hosted OpenAI-compatible APIs - `timeout` (number, optional): request timeout in seconds - default: `60` - `debug_stream` (boolean, optional): log decoded streaming chunks as they are received - default: `false` - `min_request_interval_seconds` (number, optional): minimum spacing between requests to the same `base_url` scope - default: `0.0` - `rate_limit_retry_delays_seconds` (list or comma-separated string, optional): retry delays used for HTTP 429 responses - default: `[]` - `respect_retry_after_header` (boolean, optional): when retrying HTTP 429 responses, honor the response `Retry-After` header - default: `true` - `retry_on_status` (object, optional): maps HTTP status codes to retry delay lists for pre-stream retries - default: `{}` - `retry_on_timeout` (list or comma-separated string, optional): retry delays used for request timeout exceptions before streaming begins - default: `[]` - `stream_recovery` (object, optional): mid-stream recovery policy for SSE connection failures - default: `{ "mode": "disabled", "max_retries": 3 }` - keys: - `mode`: `"disabled"`, `"early_only"`, or `"aggressive"` - `max_retries`: maximum number of stream recovery attempts when recovery is enabled - `allow_image_attachment_base64` (boolean, optional): allow inline/data-URL image attachments - default: `true` - also controls client-uploaded image attachments, because stored session assets are sent inline - `allow_image_attachment_url` (boolean, optional): allow remote image URL attachments - default: `true` When `endpoint_options` is provided, the provider emits a config UI dropdown for `base_url`. Selecting an option updates `base_url` directly, so model discovery, request URLs, and rate-limit scoping automatically follow the selected endpoint. ### Complete provider config example This example shows all provider-owned config keys together. `request_options` is intentionally omitted here because it is provided by the separate `feature-request-options` plugin. ``` { "providers": { "zai": { "provider": "reference_openai_compatible", "base_url": "https://api.z.ai/api/paas/v4", "endpoint_options": { "General": "https://api.z.ai/api/paas/v4", "Coding": "https://api.z.ai/api/coding/paas/v4" }, "api_key": "${env:ZAI_API_KEY}", "model": "glm-4.5-air", "timeout": 180, "debug_stream": false, "min_request_interval_seconds": 0.5, "rate_limit_retry_delays_seconds": [1, 3, 5], "respect_retry_after_header": true, "retry_on_status": { "500": [2, 5, 10], "502": [1, 3] }, "retry_on_timeout": [1, 3, 5], "stream_recovery": { "mode": "early_only", "max_retries": 3 } } } } ``` ### Request options To pass additional OpenAI-compatible request parameters such as `temperature`, `max_tokens`, `stop`, or provider-specific reasoning toggles, load the `request_options` feature plugin and set `request_options` in config. Example: ``` { "plugins": [ "path:/absolute/path/to/plugins/reference-openai-compatible-provider", "path:/absolute/path/to/plugins/feature-request-options" ], "providers": { "demo": { "provider": "reference_openai_compatible", "base_url": "https://example.com/v1", "model": "demo-model", "request_options": { "temperature": 0.2, "max_tokens": 256 } } } } ``` ### Tool calling Tool support is enabled automatically when tool plugins are loaded: - tool schemas are injected into `config["tools"]` - requests include OpenAI-style `tools` and `tool_choice` when tools are present - assistant tool calls are surfaced as `metadata["tool_calls"]` To actually run tools, load one or more tool plugins. Example with built-in math tools: ``` { "plugins": [ "path:/absolute/path/to/plugins/reference-openai-compatible-provider", "plugins.math_tools.MathTools" ] } ``` ### Reasoning metadata The reasoning extension is now a generic parse/preserve layer. It does not own a package-specific `think` config key. When provider-native assistant messages include these fields, they are surfaced into core message metadata: - `reasoning` - `reasoning_content` When these fields are attached to assistant metadata during a tool loop, the extension writes them back into provider-native assistant messages so they survive native-history reconstruction for follow-up requests. ### Usage metadata When the provider returns `usage`, the usage extension surfaces a minimal shared metadata shape: - `metadata.usage` - `metadata.usage_prompt_tokens` - `metadata.usage_completion_tokens` - `metadata.usage_total_tokens` - `metadata.usage_reasoning_tokens` when present - formatted token-count variants such as `metadata.usage_total_tokens_formatted` Provider-specific or extra usage fields stay inside raw `metadata.usage`. ### Image attachments When the package is loaded through its plugin path, it also exposes the `reference_openai_compatible_attachments` provider extension. That extension: - advertises image attachment UI to the frontend - exposes `store_attachment` / `delete_attachment` / `download_attachment` actions backed by the application-owned session asset store - allows the frontend to upload an image to the local application first, then send it later as a session-owned attachment - lets clients download a stored session-owned image again from message history Stored uploaded images are not uploaded to a provider-side file API. Instead, they are resolved from the local session asset store and converted to inline `data:` URLs when the chat-completions request is built. ## Retries and error recovery The provider has three retry/recovery mechanisms. All pre-stream retries share a **global attempt counter**, so the total retry budget is bounded by the longest configured delay list across mechanisms. ### Rate-limit retries (HTTP 429) Retries on HTTP 429 responses with configurable backoff and `Retry-After` header support. - `rate_limit_retry_delays_seconds` (list or comma-separated string): delay in seconds before each retry attempt. List length = maximum retries. - default: `[]` (no retries) - example: `[1, 3, 5]` — retry up to 3 times with 1s, 3s, 5s delays - `respect_retry_after_header` (boolean): when `true`, waits at least the number of seconds specified by the response's `Retry-After` header (uses the larger of the header value and the configured delay). - default: `true` - `min_request_interval_seconds` (number): minimum time between consecutive requests to the same `base_url` scope, used for request pacing. - default: `0.0` (no pacing) ``` { "rate_limit_retry_delays_seconds": [1, 3, 5], "respect_retry_after_header": true, "min_request_interval_seconds": 0.5 } ``` ### Status code retries (pre-stream) Retries on configurable HTTP status codes (for example 500, 502, 503) before the response body is consumed. - `retry_on_status` (object): maps HTTP status codes to delay lists. Keys are status code numbers (as strings in JSON). Values follow the same format as `rate_limit_retry_delays_seconds`. List length = maximum retries for that code. - default: `{}` (no retries) - example: `{"500": [2, 5, 10], "502": [1, 3]}` ``` { "retry_on_status": { "500": [2, 5, 10], "502": [1, 3] } } ``` ### Timeout retries (pre-stream) Retries when the HTTP request times out (`requests.exceptions.Timeout` — covers both connect and read timeouts). - `retry_on_timeout` (list or comma-separated string): delay in seconds before each retry attempt. List length = maximum retries. - default: `[]` (no retries — timeout exceptions propagate immediately) - example: `[1, 3, 5]` ``` { "retry_on_timeout": [1, 3, 5] } ``` ### Stream recovery (mid-stream) Recovers from connection errors that occur *during* an active SSE stream (for example connection drops, chunked encoding errors). The provider re-issues the full HTTP request and starts a new stream. - `stream_recovery` (object): - `mode` (string): recovery strategy. - `"disabled"` — no recovery, connection errors propagate immediately. (default) - `"early_only"` — recover only if no visible text content was emitted before the error. Safe for most cases since the consumer hasn't seen partial output yet. - `"aggressive"` — recover even after partial content was already streamed. The new request replays from the beginning, so the consumer may see duplicate content. - `max_retries` (integer): maximum number of recovery attempts. - default: `3` (when mode is not `"disabled"`) ``` { "stream_recovery": { "mode": "early_only", "max_retries": 3 } } ``` ### Retry and recovery example ``` { "providers": { "my_provider": { "provider": "reference_openai_compatible", "base_url": "https://api.example.com/v1", "model": "gpt-4", "rate_limit_retry_delays_seconds": [1, 3, 5], "retry_on_status": { "500": [2, 5, 10], "502": [1, 3] }, "retry_on_timeout": [1, 3, 5], "stream_recovery": { "mode": "early_only", "max_retries": 3 } } } } ``` ### Global attempt counter Pre-stream retries (429, status codes, timeouts) share a single global attempt counter. For example, with `retry_on_status: {"500": [2, 5, 8]}` and `retry_on_timeout: [1, 3, 5]`: | Attempt | Error | Delay source | Delay | | ------- | -------- | ------------------------- | ---------- | | 0 | HTTP 500 | `retry_on_status[500][0]` | 2s | | 1 | Timeout | `retry_on_timeout[1]` | 3s | | 2 | HTTP 500 | `retry_on_status[500][2]` | 8s | | 3 | Timeout | no entry at index 3 | **raises** | Stream recovery uses its own separate counter. ## Troubleshooting - `404` or connection errors: confirm `base_url` includes the `/v1` prefix, because the provider appends `/chat/completions` - missing tool calls: tool calling is model-dependent; use a model that supports tools and a prompt that strongly requests tool usage - missing reasoning metadata: enable the appropriate provider/model request option through `request_options` if needed, and confirm the model actually emits reasoning fields - missing usage metadata: some providers or local servers do not return `usage` for all endpoints or modes ## Developer Notes ### Install From the repo root: ``` python -m pip install -e core/python python -m pip install -e "plugins/reference-openai-compatible-provider[dev]" ``` ### Tests Fast default package run: ``` pytest plugins/reference-openai-compatible-provider/tests -q ``` This package defaults to `-m 'not integration'`. Local Ollama integration: ``` pytest plugins/reference-openai-compatible-provider/tests -m 'integration and ollama and not api and not slow_integration' -q ``` Hosted Fireworks integration: ``` pytest plugins/reference-openai-compatible-provider/tests -m 'integration and api and not slow_integration and fireworks' -q ``` Hosted Z.ai integration: ``` pytest plugins/reference-openai-compatible-provider/tests -m 'integration and api and not slow_integration and zai' -q ``` Environment overrides used by the Ollama integration tests: - `OLLAMA_OPENAI_BASE_URL` (default `http://localhost:11434/v1`) - `OLLAMA_OPENAI_MODEL` (default `qwen3:0.6b`) The reference-provider test suite bootstraps the repo root `.env` during local use and does not overwrite already-exported environment variables. Hosted API tests typically require `FIREWORKS_API_KEY` or `ZAI_API_KEY`. ### Debugging streaming Set `debug_stream: true` in provider config to log decoded streaming chunks. Avoid enabling this when sending secrets because responses may include sensitive data. ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # session-actions-app Generated from `plugins/session-actions-app/README.md`. Application-level plugin providing copy/fork/delete session actions. This repo is installed and loaded via the application plugin manager. ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # session-asset-attachments Generated from `plugins/session-asset-attachments/README.md`. Generic application-level actions for downloading session-owned attachment assets. ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # session-message-count-meta-app Generated from `plugins/session-message-count-meta-app/README.md`. Application-level plugin that computes message-count fields for sessions. ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # session-ordering-buckets-app Generated from `plugins/session-ordering-buckets-app/README.md`. Application-level plugin defining bucketed ordering for session lists. ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # session-pinned-app Generated from `plugins/session-pinned-app/README.md`. Application-level plugin that toggles pinned state on sessions. ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # session-timestamp-meta-app Generated from `plugins/session-timestamp-meta-app/README.md`. Application-level plugin that computes timestamp-derived fields for sessions. ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # session-title-app Generated from `plugins/session-title-app/README.md`. Application-level plugin providing actions and UI schema for editing session titles. ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # skill-shared Generated from `plugins/skill-shared/README.md`. Shared discovery and parsing helpers for vendor-owned skill integrations. This package is a library package, not a plugin package. It is responsible for mechanical skill discovery, filesystem safety, frontmatter parsing, and optional sidecar YAML loading. Vendor packages own prompt wording, activation rules, and tool behavior. # summarize-range-app Generated from `plugins/summarize-range-app/README.md`. Application-level plugin providing summarize-range session actions. ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # template-bash-tool Generated from `plugins/template-bash-tool/README.md`. Template plugin repo for **bash tool plugins**. This mirrors `plugins/bash-read-line-range`: - A `*.bash` tool script implementing `schema`, `preview`, `run`, and `error`. - An `agent_plugin.json` descriptor listing bash tool files. - Minimal Python packaging (`pyproject.toml`) so tests can be run with `pytest`. ## Development ``` python -m pip install -e core/python python -m pip install -e "plugins/template-bash-tool[dev]" pytest plugins/template-bash-tool/tests -q ``` ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # template-js-multi-tools Generated from `plugins/template-js-multi-tools/README.md`. Template plugin repo for **Node.js / TypeScript tool plugins**. This is intentionally small and mirrors `plugins/js-multi-tools`: - `package.json` with an `agent.tools` descriptor - `src/` TypeScript sources - No `dist/` checked in (build happens at install/load time) ## Build ``` npm install npm run build ``` ## Load ``` { "plugins": [ { "node_tool": { "path": "plugins/template-js-multi-tools" } } ] } ``` ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # template-python-provider Generated from `plugins/template-python-provider/README.md`. Template plugin repo for **Python provider plugins** (provider + provider extension + feature). This is intentionally small and mirrors the structure of `plugins/template-python-tools`: - `pyproject.toml` (editable install + `pytest` in `.[dev]`) - `agent_plugin.json` descriptor - `src/` layout Python package - `tests/` with minimal unit tests (offline, deterministic) ## What’s included This template contains **minimal placeholders**, plus a small shared implementation for the **accumulator streaming pattern**. You should be able to copy this repo and only **add** provider-specific logic (HTTP calls, chunk parsing, conversions) without deleting a pre-built example. Included: - `TemplateProvider` (skeleton) - includes a shared accumulator-based `process_chunk`/`finalize` - you implement: `call_api`, `stream_api`, `extract_delta`, `process_delta` - `TemplateProviderExtension` (no-op skeleton) - `TemplateFeature` (no-op skeleton) The `tests/` directory includes: - offline unit tests for the skeleton and common extension patterns - integration scaffolds (marked `integration` and `xfail(strict=True)`) that you can enable by filling in `tests/provider_test_config.py` and running with `-m integration` ## Development From the repo root: ``` python -m pip install -e core/python python -m pip install -e "plugins/template-python-provider[dev]" pytest plugins/template-python-provider/tests -q ``` Run integration scaffolds (requires a configured API key env var; will still `xfail` until you remove the mark): ``` pytest plugins/template-python-provider/tests -q -m integration ``` ## Using this template Copy this directory, then rename: - the project name in `pyproject.toml` - the Python package under `src/` - plugin class names and their `name`/`version` fields - entries in `agent_plugin.json` Then add provider-specific API I/O logic in your provider class and expand tests to match your integration. ## Two streaming paths 1. **Accumulator pattern (recommended)** - keep the shared `process_chunk` and `finalize` - implement: `stream_api`, `extract_delta`, `process_delta` 2. **Custom streaming** - implement: `process_chunk` and `finalize` yourself - use this when the provider stream format is unusual or you need specialized finalization behavior ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # template-python-tools Generated from `plugins/template-python-tools/README.md`. Template plugin repo for **Python tool plugins**. This is intentionally small and mirrors the structure of `plugins/codex-tools`: - `pyproject.toml` (editable install + `pytest` in `.[dev]`) - `agent_plugin.json` descriptor - `src/` layout Python package - `tests/` with a minimal unit test ## Development From the repo root: ``` python -m pip install -e core/python python -m pip install -e "plugins/template-python-tools[dev]" pytest plugins/template-python-tools/tests -q ``` ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # timestamp-extension Generated from `plugins/timestamp-extension/README.md`. Timing-related feature plugins. ## TimestampExtension - Adds a footer timestamp derived from `metadata.timestamp`. - Timestamps are allocated when: - A new user/system message is appended to the session (`to_native_messages`). - An assistant message is received from the provider (`finalize`). ### Known limitation: tool-result timestamps Tool results receive timestamps **only after the session is persisted and reloaded**, not inside the live `tool_results` event stream. This happens because `to_native_messages` runs inside `add_message`, and tool-result messages are added to the session after the `tool_results` event has already been emitted. All providers are affected equally. ## DurationExtension - Adds per-request `duration` (start/end timestamps + elapsed time). - Adds per-turn cumulative `turn duration` similar to OpenRouter usage turn totals. ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # tool-compat-shared Generated from `plugins/tool-compat-shared/README.md`. Internal helper package for the vendor-style tool bundles. This package is intentionally not a public plugin package and does not expose `agent_plugin.json` entries. It exists only to share filesystem, decoding, truncation, formatting, write, literal-replacement, and web search logic across `gemini-tools`, `qwen-tools`, `kimi-tools`, and `grok-tools`. ## Engine vs Shape Principle This package follows the **Engine vs Shape** separation: - **Engine (this package)**: HTTP calls, API clients, data extraction, protocol definitions, data models - **Shape (vendor plugins)**: Tool name, parameters, descriptions, result formatting, error messages The `web_search_engine` module returns structured data (`SearchResponse`, `SearchResult`) only. It does NOT format results for LLM consumption. All formatting — citation markers, source footers, truncation banners, result numbering — belongs in vendor tool plugins. > **Note**: The existing `reader_engine.py` and `search_engine.py` modules export vendor-specific formatting functions (e.g., `format_gemini_*`, `format_qwen_*`). These are precedent violations of the Engine vs Shape principle. New modules should not follow this pattern. ## Modules ### `reader_engine.py` File reading, encoding detection, BOM handling, path resolution, and line truncation. ### `editor_engine.py` Literal text replacement, multi-edit application, and file writing. ### `search_engine.py` Filesystem search: directory listing, glob search, grep search. Includes vendor-specific formatting functions (precedent violations — see note above). ### `shell_engine.py` Shell command execution, background process support, output truncation. ### `web_search_engine.py` Web search infrastructure: data models, provider protocol, and reusable search provider implementations (Tavily, Gemini API). #### Data Models - **`SearchResult`** — Frozen dataclass with fields: `title`, `url`, `snippet`, `content`, `date`, `site_name`, `score`. All optional fields have defaults. - **`SearchResponse`** — Frozen dataclass wrapping `List[SearchResult]` plus optional `answer` (AI-generated, from Tavily or Gemini API), `query`, `grounding_chunks`, and `grounding_supports` (Gemini API citation metadata for vendor-side formatting). #### Provider Protocol - **`SearchProvider`** — `Protocol` with `name` (str), `is_available()` (bool), `async search(query, *, limit) -> SearchResponse`. #### Provider Implementations - **`TavilyProvider`** — POST to `api.tavily.com/search`. Config: `api_key`, `search_depth` (default: `"advanced"`), `include_answer` (default: `True`). - **`GeminiApiProvider`** — Uses `google-genai` SDK with `web-search` model alias for server-side Google Search grounding. Config: `api_key`, `model` (default: `"web-search"`). Returns `answer` (response text), `results` (from grounding chunks), and raw `grounding_chunks`/`grounding_supports` for vendor-side citation formatting. Requires `google-genai>=0.3.0` (optional dependency: `pip install tool-compat-shared[gemini]`). #### Error Handling - **`SearchProviderError`** — Exception with `provider_name` and `original_error`. Message format: `[ProviderName] message`. ## Dependencies - `aiohttp>=3.8.0` — For async HTTP in `web_search_engine.py` (lazy-imported) - `google-genai>=0.3.0` — Optional, for `GeminiApiProvider` (install with `[gemini]` extra) ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # web-fetch-shared Generated from `plugins/web-fetch-shared/README.md`. Shared HTTP fetch and HTML extraction helpers for vendor web fetch tools. This is a **library package**, not a plugin package. It provides reusable components for implementing vendor-specific web fetch tools (Gemini, Qwen, Kimi, etc.). ## Installation ``` pip install -e . ``` ## Dependencies - `aiohttp>=3.8.0` - Async HTTP client - `trafilatura>=1.6.0` - HTML-to-text extraction ## Usage ### Fetch URL ``` from web_fetch_shared import fetch_url, FetchResult result = await fetch_url("https://example.com") if not result.is_error: print(result.content) ``` ### Extract Text from HTML ``` from web_fetch_shared import extract_text html = "

Hello world

" text = extract_text(html, "text/html") print(text) # "Hello world" ``` ### URL Helpers ``` from web_fetch_shared import normalize_github_url, is_private_url, parse_urls_from_text # Convert GitHub blob URL to raw content URL raw_url = normalize_github_url("https://github.com/user/repo/blob/main/README.md") # Returns: https://raw.githubusercontent.com/user/repo/main/README.md # Check if URL points to private IP is_private = is_private_url("http://192.168.1.1/internal") # Returns: True # Parse URLs from text valid, errors = parse_urls_from_text("Check https://example.com and http://[invalid") ``` ### Content Utilities ``` from web_fetch_shared import truncate_content, format_gemini_citations # Truncate long content truncated = truncate_content(long_text, max_chars=1000) # Format Gemini grounding citations formatted = format_gemini_citations(text, grounding_chunks, grounding_supports) ``` ## API Reference ### `FetchResult` Dataclass representing a URL fetch result. | Field | Type | Description | | --------------- | ---------------- | --------------------------- | | `content` | `str` | Fetched content | | `content_type` | `str` | Content-Type header | | `status_code` | `int` | HTTP status code | | `headers` | `Dict[str, str]` | Response headers | | `url` | `str` | Final URL after redirects | | `is_error` | `bool` | Whether this is an error | | `error_message` | `Optional[str]` | Error message if `is_error` | ### `fetch_url()` ``` async def fetch_url( url: str, *, timeout_seconds: float = 10.0, headers: Optional[Dict[str, str]] = None, max_size_bytes: int = 10 * 1024 * 1024, max_redirects: int = 5, user_agent: str = "Mozilla/5.0 (compatible; AI-Agent-Platform/1.0)", ) -> FetchResult ``` ### `extract_text()` ``` def extract_text( html_or_text: str, content_type: str, *, include_comments: bool = True, include_tables: bool = True, include_formatting: bool = False, output_format: str = "txt", ) -> str ``` ### `normalize_github_url()` ``` def normalize_github_url(url: str) -> str ``` ### `is_private_url()` ``` def is_private_url(url: str) -> bool ``` ### `truncate_content()` ``` def truncate_content( content: str, max_chars: int = 100000, truncation_marker: str = "... [Content truncated due to size limit]", ) -> str ``` ### `format_gemini_citations()` ``` def format_gemini_citations( text: str, grounding_chunks: list[dict[str, Any]], grounding_supports: list[dict[str, Any]], ) -> str ``` ### `parse_urls_from_text()` ``` def parse_urls_from_text(text: str) -> tuple[list[str], list[str]] ``` ## Constants | Constant | Value | Description | | ------------------------- | --------------------------------------------- | -------------------------------- | | `DEFAULT_TIMEOUT_SECONDS` | `10.0` | Default fetch timeout | | `DEFAULT_MAX_SIZE_BYTES` | `10 * 1024 * 1024` | Default max response size (10MB) | | `DEFAULT_MAX_CHARS` | `100000` | Default truncation limit | | `TRUNCATION_MARKER` | `"... [Content truncated due to size limit]"` | Default truncation marker | ## License Copyright 2026 Dynamic Programming Solutions Kft. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. # Reference # Core SDK API Reference ## Overview The Crystal Lattice Core SDK provides a **functional**, modular interface for building conversational AI applications with any LLM provider through an extensible plugin system. **Key Principles:** - **Functional Architecture**: Immutable data structures, pure functions - **State Management**: Provider wrapper maintains a single shared state per request; extensions/features update it; tools manage their own state - **Typed Interfaces**: Specialized wrappers for each plugin type (provider, feature, tool, extension) - **Extensible**: Provider extensions for hot-path processing and stateless message transforms ## Installation ``` pip install agent-core ``` ## Quick Start ``` from agent_core import AgentCore from plugins.openai_provider import OpenAICompatibleProvider # Initialize core (stateless pipeline) core = AgentCore() # Register provider core.register_provider(OpenAICompatibleProvider) # Application manages config config = { "provider": "openai_compatible", "model": "gpt-4o", "api_key": "sk-...", } # Create session (immutable) session = core.create_session() # Add messages (returns new session) session = core.add_message(session, "user", "Hello!") # Send request (returns new session + response) session, messages = core.send_request(session, config) print(messages[-1]["content"]) ``` ______________________________________________________________________ ## AgentCore Stateless processing pipeline for Crystal Lattice. **Architecture:** - Stores plugin adapters (not state) - All methods are pure functions - Takes session/config as parameters - Returns new objects (immutable) ### Constructor ``` AgentCore() ``` Initialize core with empty plugin pipeline. **Example:** ``` core = AgentCore() ``` ### Methods #### register_provider ``` register_provider( plugin_class: type, extensions: Optional[List[type]] = None ) -> None ``` Register provider plugin class with optional extensions. **Parameters:** - `plugin_class` (type): Provider plugin class (instance methods) - `extensions` (list, optional): List of provider extension classes **Example:** ``` from plugins.openai_provider import OpenAICompatibleProvider core.register_provider(OpenAICompatibleProvider) # With extensions core.register_provider( OpenAICompatibleProvider, extensions=[ThinkingExtension, CitationExtension] ) ``` #### register_feature ``` register_feature(plugin_class: type) -> None ``` Register feature plugin class. **Parameters:** - `plugin_class` (type): Feature plugin class (instance methods) **Example:** ``` core.register_feature(WebSearchFeature) core.register_feature(ContextManager) ``` #### register_tool ``` register_tool(plugin_class: type) -> None ``` Register tool plugin class. **Parameters:** - `plugin_class` (type): Tool plugin class (instance methods) **Example:** ``` core.register_tool(FileReaderTool) core.register_tool(CalculatorTool) ``` #### discover_and_register ``` discover_and_register(modules: List[str]) -> Dict[str, List[str]] ``` Discover plugins from Python modules and register them by duck typing. - Provider: has `stream_api` or `call_api` - Provider Extension: has `process_chunk` (and no tool/provider I/O methods) - Tool: has `get_tool_schemas` and `execute_tool` - Feature: otherwise - If a module exposes multiple provider classes, discovery prefers one whose plugin name or snake_case class name matches the module basename. Returns a summary of registered plugin names by type. **Example:** ``` summary = core.discover_and_register([ "plugins.openai_provider", "plugins.web_search_feature", "plugins.file_reader_tool", ]) print(summary) # {"providers": ["openai_compatible"], "features": ["web_search"], "tools": ["file_reader"], "extensions": []} ``` #### modify_message ``` modify_message( session: Session, index: int, content: str, config: Dict[str, Any] | None = None, ) -> Session ``` Modify the content of an existing message and return a new session. - Supports only `"system"`, `"user"`, and `"assistant"` roles; attempting to modify `"tool"` messages raises `ValueError`. - Keeps the message role and metadata unchanged; only `content` is updated. - When provider-native history (`native_messages`) and per-message `metadata["native_indices"]` are present and form a 1:1 mapping for the targeted message, the corresponding native messages are updated using the provider + feature pipeline for the supplied `config` (or the single registered provider when `config` omits `provider`). - When native history cannot be safely preserved (for example, missing or shared `native_indices`, multiple providers without a resolvable configuration, or conversion errors), the core falls back to a **core-only** modification: - All `native_messages`, `native_messages_integrity`, and per-message `native_indices` are removed. - Future requests will reconstruct provider-native history from core messages as needed. **Example:** ``` session = core.add_message(session, "user", "Original question") # ... after one or more turns ... session = core.modify_message(session, 1, "Edited question", config) ``` #### create_session ``` create_session(session_id: Optional[str] = None) -> Session ``` Create new empty session (convenience function). **Parameters:** - `session_id` (str, optional): Session identifier (auto-generated if not provided) **Returns:** - `Session`: New empty immutable session **Example:** ``` session = core.create_session() session = core.create_session(session_id="user-123") ``` #### send_request ``` send_request( session: Session, config: Dict[str, Any] ) -> Tuple[Session, List[Dict[str, Any]]] ``` Send request and return new session with response (pure function). **Parameters:** - `session` (Session): Current session - `config` (dict): Provider and feature configuration. When multiple providers are registered, `config["provider"]` must select the provider (by name or fully-qualified class path). **Returns:** - `Tuple[Session, list]`: Tuple of (new_session, final_messages) **Example:** ``` config = { "provider": "openai_compatible", "model": "gpt-4o", "api_key": "sk-...", } session, messages = core.send_request(session, config) print(messages[-1]["content"]) ``` #### send_request_stream ``` send_request_stream( session: Session, config: Dict[str, Any] ) -> Iterator[Dict[str, Any]] # yields {"type": "partial"|"final", ...} ``` Stream request and yield partials, then final session (generator function). **Parameters:** - `session` (Session): Current session - `config` (dict): Provider and feature configuration **Yields:** - `dict`: Chunk dictionaries: - `{"type": "partial", "message": {"role": "assistant", "content": "...", "metadata": {...}}}` - Streaming partial message - `{"type": "final", "session": new_session, "messages": [...]}` - Final result (list of final messages) **Example:** ``` for chunk in core.send_request_stream(session, config): if chunk["type"] == "partial": print(chunk["message"]["content"], end="", flush=True) elif chunk["type"] == "final": session = chunk["session"] print("\n[Done]") ``` #### send_request_stream_async ``` async send_request_stream_async( session: Session, config: Dict[str, Any] ) -> AsyncIterator[Dict[str, Any]] ``` Async version of `send_request_stream()`. **Parameters:** - `session` (Session): Current session - `config` (dict): Provider and feature configuration **Yields:** - `dict`: Same as `send_request_stream()` **Example:** ``` async for chunk in core.send_request_stream_async(session, config): if chunk["type"] == "partial": print(chunk["message"]["content"], end="", flush=True) elif chunk["type"] == "final": session = chunk["session"] ``` #### get_config_schema ``` get_config_schema() -> List[Dict[str, Any]] ``` Get flattened config schema from all registered plugins. Each plugin contributes its config keys to a shared flat list of entries. Every entry includes: - `key`: the top-level config key. - `plugin`: the contributing plugin. - Any additional schema fields such as `type`, `default`, `required`, or `description`. Multiple plugins may contribute entries for the same `key`. **Returns:** - `list[dict]`: List of config schema entries, each with `key` and `plugin`. **Example:** ``` schema = core.get_config_schema() # [ # {"key": "model", "type": "string", "required": True, "plugin": "openai"}, # {"key": "api_key", "type": "string", "plugin": "openai"}, # {"key": "api_key", "type": "string", "plugin": "other_provider"}, # ] ``` #### get_ui_schema ``` get_ui_schema(config: Dict[str, Any]) -> List[Dict[str, Any]] ``` Get combined (flattened) UI schema for the effective config. The core computes capability `tags` and available `models` for the given config and passes them to plugins via `get_ui_elements(config, tags, models)`. Each element in the returned list is a dictionary that describes a UI element contributed by a provider, extension, feature, or tool. The core treats these dictionaries as opaque and simply flattens and annotates them; applications (such as the terminal client) decide how to render or interpret each entry. Plugins may include a `ui_type` field to classify elements: - `"config"` (default): configuration inputs such as text fields, checkboxes, or selects. When `ui_type` is missing or falsy, the core normalizes the element to `"config"` and deduplicates by its `key` when combining schemas from multiple plugins. - `"message_footer"`: per-message footer fields derived from individual messages (for example, timestamps or cached-token counters). These elements typically include a dotted JSON `data` path (such as `"metadata.timestamp"`) and an optional `template` string that a UI can use to format the value. - `"status_bar"`: persistent status bar fields, often derived from the last assistant message in a session. The element shape generally mirrors `"message_footer"` entries (for example, `{ "ui_type": "status_bar", "data": "metadata.total_cost", "template": "Total: {{data}}" }`). - `"action"` (reserved): intended for future actionable UI elements. The core does not assign special semantics to this value, and no built-in applications currently interpret it. The core annotates each element with a `plugin` field (when absent) naming the contributing plugin and preserves the relative order of all non-`"config"` elements. **Returns:** - `list[dict]`: Flattened list of UI element definitions (each with `ui_type` and `plugin` where available). **Example:** ``` schema = core.get_ui_schema(config) # [ # {"key": "model", "type": "text", "label": "Model"}, # {"key": "reasoning_effort", "type": "select", "options": [...]}, # ] ``` #### get_completions ``` get_completions(config: Dict[str, Any], text: str) -> List[Dict[str, Any]] ``` Collect completion suggestions from registered feature plugins for the given input text. This is typically used by interactive applications to implement context-aware autocomplete (for example, `@file:` path expansion). Each completion entry is a dictionary whose structure is feature-defined and interpreted by the application. A common shape is: ``` { "replacement": "text to insert", "start": 5, # 0-based index in `text` to start replacing "display": "label", # label shown in the UI "display_meta": "info", # optional extra description } ``` **Parameters:** - `config` (dict): Resolved configuration for the current agent or request. - `text` (str): Full input text before the cursor. **Returns:** - `list[dict]`: Flattened list of completion descriptors contributed by feature plugins. #### apply_feature_completion ``` apply_feature_completion( config: Dict[str, Any], text: str, completion: Dict[str, Any], ) -> str ``` Ask registered feature plugins to transform an accepted completion into the final snippet to insert into the buffer. This is typically used by interactive applications after they have inserted a completion's `"replacement"` text into an input buffer. The core iterates over feature plugins in registration order and invokes their `apply_completion` hooks; the first non-empty string is returned as the replacement snippet. **Parameters:** - `config` (dict): Resolved configuration for the current agent or request. - `text` (str): Full buffer text after the completion has been applied. - `completion` (dict): Completion descriptor that was accepted, as previously returned by `get_completions`. **Returns:** - `str`: Replacement snippet string, or an empty string when no feature chooses to handle the completion. #### get_tool_schemas ``` get_tool_schemas(config: Dict[str, Any]) -> List[Dict[str, Any]] ``` Get tool schemas from all registered tool plugins. **Parameters:** - `config` (dict): Tool configuration **Returns:** - `list`: List of tool schemas in whatever formats the active tool plugins expose **Example:** ``` schemas = core.get_tool_schemas(config) # Could be chat-function, Responses function, or another accessor-supported shape ``` #### execute_tool_calls ``` execute_tool_calls(tool_calls: List[Dict[str, Any]], config: Dict[str, Any]) -> List[Dict[str, Any]] ``` Execute a batch of tool calls via registered tool plugins and return core "tool" messages. - Tool calls are inspected through the active tool interop registry rather than one fixed wire shape. - Built-in support includes OpenAI chat-completions function calls, OpenAI Responses `function_call` items, and Responses `custom_tool_call` items. - The tool plugin receives the final payload object directly, along with optional payload metadata such as payload kind / format. - Formats results using each tool's `format_tool_result`. The common path is a string tool message. Tools may also return an explicit `provider_native_tool_result` envelope for provider-specific structured content; providers interpret those envelopes during message conversion. **Parameters:** - `tool_calls` (list): tool-call dicts in any format supported by active call accessors - `config` (dict): Application config passed to initialize tool state **Returns:** - `list[dict]`: Core messages with `role: "tool"`, `content` formatted string, and `metadata.tool_call_id` and `metadata.tool_name`. **Example:** ``` tool_calls = [ { "id": "call_1", "type": "function", "function": {"name": "read_file", "arguments": {"file_path": "README.md"}}, }, ] results = core.execute_tool_calls(tool_calls, config) # [{"role": "tool", "content": "File contents...", "metadata": {"tool_call_id": "call_1", "tool_name": "read_file"}}] ``` Custom/freeform example: ``` tool_calls = [ { "type": "custom_tool_call", "call_id": "call_patch_1", "name": "apply_patch", "input": "*** Begin Patch\n*** End Patch", }, ] results = core.execute_tool_calls(tool_calls, config) ``` #### Tool Interop `AgentCore` does not enforce a single canonical external tool schema or tool-call format. Instead, tool interop is based on: - schema accessors: inspect a schema and extract semantic fields like tool name - call accessors: inspect a tool call and extract call id, tool name, payload, payload kind, and optional payload metadata - schema adapters: convert schema objects between formats - call adapters: convert tool-call objects between formats Registry composition is additive: - explicit interop contributions are tried first - provider / extension / tool contributions are tried next - built-in defaults are used as fallback Providers and provider extensions can also declare which schema formats they accept and which tool-call formats they emit, allowing adapter selection to target actual provider capabilities. #### execute_session_action ``` execute_session_action( session: Session, config: Dict[str, Any], plugin_id: str, action_id: str, params: Dict[str, Any], context: Optional[Dict[str, Any]] = None ) -> Tuple[Session, Dict[str, Any]] ``` Execute a session-scoped action on a feature plugin or provider extension. This method provides the core-level entry point for plugin actions that operate on a single session's provider-native message history. Actions can modify native messages and/or patch session metadata. **Parameters:** - `session` (Session): Current session - `config` (dict): Resolved configuration for the current agent - `plugin_id` (str): Plugin identifier (e.g., `"my_feature"`) - `action_id` (str): Action identifier defined by the plugin - `params` (dict): Validated action parameters - `context` (dict, optional): Additional execution context **Returns:** - `Tuple[Session, dict]`: Updated session and action result dictionary **Context (Layer 1 - Core-level):** ``` context = { "core": self, # AgentCore instance "config": config, # Current resolved config "trigger_source": "core", # Where action was triggered "session": session.to_dict(), # Serialized session "lifecycle": trigger, # Lifecycle trigger name (if applicable) "request_context": {...}, # Request-initialized plugin context "request_runtime": {...}, # Request runtime helpers } ``` Caller-provided `context` is additive only. Reserved keys owned by the context builders are not overridden; on clashes the builder keeps the owned value and emits a warning. **Example:** ``` # Execute a compaction action session, result = core.execute_session_action( session, config, "gemini_compaction_feature", "compact_range", {"start": 0, "end": 10}, ) # Result contains: # - native_messages: replacement provider-native history # - session_metadata: patch for session.metadata # - error: optional error information if "error" not in result: print(f"Compacted {result.get('compaction_result', {}).get('message')}") ``` #### execute_lifecycle_actions ``` execute_lifecycle_actions( session: Session, config: Dict[str, Any], trigger: str, context: Optional[Dict[str, Any]] = None ) -> Tuple[Session, List[Dict[str, Any]]] ``` Execute all registered lifecycle actions matching a trigger. Lifecycle actions are plugin-defined actions with a `trigger` field that matches the given trigger name. This method is typically called by `AgentApplication` during session lifecycle events (create, save, request_prepare, etc.). **Parameters:** - `session` (Session): Current session - `config` (dict): Resolved configuration for the current agent - `trigger` (str): Lifecycle trigger name (e.g., `"session_create"`, `"request_prepare"`) - `context` (dict, optional): Additional execution context **Returns:** - `Tuple[Session, list]`: Updated session and list of action results **Context:** Same as `execute_session_action`, with additional `lifecycle` key set to the trigger name. Lifecycle and `response_finalize` action-style flows also receive `request_context` and `request_runtime` so plugins can opt into the same request initialization helpers that explicit session actions use. **Example:** ``` # Run lifecycle actions before a request session, results = core.execute_lifecycle_actions( session, config, "request_prepare", ) for result in results: plugin_id = result.get("plugin") action_id = result.get("action_id") print(f"Ran {plugin_id}.{action_id}") ``` **Standard lifecycle triggers:** - `session_create`: After session creation - `session_save_prepare`: Before session persistence - `request_prepare`: Before LLM request - `request_complete`: After successful request - `request_error`: After failed request - `session_fork`: After session fork - `agent_switch_prepare`: Before agent switch - `agent_switch_complete`: After agent switch - `session_delete_prepare`: Before session deletion #### export_session ``` export_session(session: Session, format: str = "json") -> str ``` Export session to string. **Parameters:** - `session` (Session): Session to export - `format` (str): Export format ("json" supported) **Returns:** - `str`: Serialized session data **Example:** ``` data = core.export_session(session) ``` #### import_session ``` import_session(data: str, format: str = "json") -> Session ``` Import session from string. **Parameters:** - `data` (str): Serialized session data - `format` (str): Import format ("json" supported) **Returns:** - `Session`: Imported immutable session **Example:** ``` session = core.import_session(data) ``` ______________________________________________________________________ ## Data Types ### Message Immutable message structure. ``` @dataclass(frozen=True) class Message: role: MessageRole # "system" | "user" | "assistant" | "tool" content: str metadata: Optional[Dict[str, Any]] = None ``` **Methods:** - `to_dict() -> Dict[str, Any]`: Convert to dictionary - `from_dict(data: Dict[str, Any]) -> Message`: Create from dictionary (classmethod) **Example:** ``` from agent_core import Message msg = Message( role="user", content="Hello", metadata={"timestamp": "2025-01-01"} ) # Cannot modify (frozen dataclass) # msg.role = "assistant" # Error! ``` ### Session Immutable session data structure (pure data container). ``` @dataclass(frozen=True) class Session: session_id: str messages: List[Message] # Messages (treat as immutable) metadata: Dict[str, Any] = field(default_factory=dict) ``` **Methods:** - `to_dict() -> Dict[str, Any]`: Convert to dictionary - `from_dict(data: Dict[str, Any]) -> Session`: Create from dictionary (classmethod) **Example:** ``` from agent_core import Session, Message session = Session( session_id="test", messages=[ Message(role="user", content="Hello"), Message(role="assistant", content="Hi!") ] ) # Cannot modify (frozen dataclass) # Avoid mutating session.messages directly; create a new Session instead ``` ### MessageRole Type alias for message roles. ``` MessageRole = Literal["system", "user", "assistant", "tool"] ``` ______________________________________________________________________ ## Plugin Infrastructure Specialized wrappers and defaulting adapters provide typed, intuitive interfaces for each plugin type. - Provider: `ProviderWrapper` (owns shared state) + `ProviderDefaultsAdapter` - Feature: `FeatureWrapper` (shared-state lifecycle) + `FeatureDefaultsAdapter` - Tool: `ToolWrapper` + `ToolDefaultsAdapter` - Provider extension: `ExtensionWrapper` (shared-state lifecycle, stateless transforms) + `ProviderExtensionDefaultsAdapter` In the Python SDK these adapters supply sensible defaults for many conversion and lifecycle hooks when plugin classes omit them, so plugin authors can implement only the subset of methods they actually need. Note: Methods remain stateless (pure) and use explicit state parameters. Plugin instances are short‑lived (per request) and should not hold durable mutable state. The provider wrapper discards shared state after each request. Tools manage their own state dicts passed in/out explicitly. ______________________________________________________________________ ## Complete Examples ### Basic Chat Application ``` from agent_core import AgentCore from plugins.openai_provider import OpenAICompatibleProvider # Setup core = AgentCore() core.register_provider(OpenAICompatibleProvider) # Application manages config and sessions config = { "provider": "openai_compatible", "model": "gpt-4o", "api_key": "sk-...", } # Create session session = core.create_session() session = core.add_message(session, "system", "You are helpful") # Chat loop while True: user_input = input("You: ") if user_input.lower() == "quit": break # Functional updates session = core.add_message(session, "user", user_input) session, messages = core.send_request(session, config) print(f"Assistant: {response['content']}") ``` ### Streaming with Progress ``` from agent_core import AgentCore from plugins.openai_provider import OpenAICompatibleProvider core = AgentCore() core.register_provider(OpenAICompatibleProvider) config = { "provider": "openai_compatible", "model": "gpt-4o", } session = core.create_session() session = core.add_message(session, "user", "Write a story") print("Assistant: ", end="", flush=True) for chunk in core.send_request_stream(session, config): if chunk["type"] == "partial": print(chunk["message"]["content"], end="", flush=True) elif chunk["type"] == "final": session = chunk["session"] print("\n") ``` ### Provider with Extensions ``` from agent_core import AgentCore from plugins.openai_provider import OpenAICompatibleProvider core = AgentCore() # Register provider with extensions core.register_provider( OpenAICompatibleProvider, extensions=[ThinkingExtension, CitationExtension] ) config = { "provider": "openai_compatible", "model": "gpt-4o", "api_key": "sk-...", "extract_thinking": True, # Extension config "extract_citations": True, } session = core.create_session() session = core.add_message(session, "user", "Explain quantum physics") for chunk in core.send_request_stream(session, config): if chunk["type"] == "final": message = chunk["messages"][-1] # Extensions added metadata if "thinking_blocks" in message.get("metadata", {}): print("Thinking:", message["metadata"]["thinking_blocks"]) if "citations" in message.get("metadata", {}): print("Citations:", message["metadata"]["citations"]) ``` ### Multi-Feature Setup ``` from agent_core import AgentCore from plugins.openai_provider import OpenAICompatibleProvider core = AgentCore() # Register plugins core.register_provider(OpenAICompatibleProvider) core.register_feature(WebSearchFeature) core.register_feature(ContextManager) core.register_tool(FileReaderTool) config = { "provider": "openai_compatible", "model": "gpt-4", "api_key": "sk-...", "web_search_enabled": True, "max_results": 5, "allowed_paths": ["./workspace"], } session = core.create_session() session = core.add_message(session, "user", "Search for AI news") session, messages = core.send_request(session, config) ``` ### Async Streaming ``` import asyncio from agent_core import AgentCore from plugins.openai_provider import OpenAICompatibleProvider async def chat(): core = AgentCore() core.register_provider(OpenAICompatibleProvider) config = { "provider": "openai_compatible", "model": "gpt-4o", } session = core.create_session() session = core.add_message(session, "user", "Hello") async for chunk in core.send_request_stream_async(session, config): if chunk["type"] == "partial": print(chunk["message"]["content"], end="", flush=True) elif chunk["type"] == "final": session = chunk["session"] asyncio.run(chat()) ``` ______________________________________________________________________ ## Functional Architecture ### Immutability All data structures are frozen dataclasses: ``` # Messages are immutable msg = Message(role="user", content="Test") # msg.role = "assistant" # Error! # Sessions are immutable session = Session(session_id="test", messages=[]) # Avoid mutating session.messages directly; create a new Session instead # Functional updates new_session = Session( session_id=session.session_id, messages=[*session.messages, msg] ) ``` ### Pure Functions Core methods are pure (same input → same output): ``` session = core.create_session() session = core.add_message(session, "user", "Hello") # Same inputs work consistently result1 = core.send_request(session, config) result2 = core.send_request(session, config) # Both produce same results (compare last message) assert result1[1][-1]["content"] == result2[1][-1]["content"] # Original session unchanged assert len(session.messages) == 1 ``` ### Explicit State No hidden state - everything passed explicitly: ``` # Config passed explicitly (not stored in core) session, messages = core.send_request(session, config) # Session passed explicitly (not stored in core) new_session = core.add_message(session, "user", "Test") ``` ### Application Responsibility Applications manage state (sessions, config): ``` # Application manages sessions sessions = { "user-1": core.create_session("user-1"), "user-2": core.create_session("user-2") } # Application manages config configs = { "fast": {"model": "gpt-3.5-turbo"}, "smart": {"model": "gpt-4"} } # Application decides which to use session, messages = core.send_request( sessions["user-1"], configs["smart"] ) ``` ______________________________________________________________________ ## Error Handling ### Common Exceptions - `RuntimeError`: No provider registered, plugin not initialized - `AttributeError`: Plugin doesn't implement required method - `ValueError`: Unsupported format, invalid configuration - `TypeError`: Invalid method arguments **Example:** ``` try: session, messages = core.send_request(session, config) except RuntimeError as e: print(f"Error: {e}") # "No provider registered" ``` ______________________________________________________________________ ## Version Current version: `0.3.0` (Functional Architecture with Plugin Wrappers + Discovery) ## License MIT License # Event Stream Types This document describes the **event types** produced by the application layer (`AgentApplication` + `ToolLoopRunner`) and exposed via: - HTTP polling: `GET /events` in `application/python/agent_terminal_app/server.py` - Bridge streaming: `session_event` messages forwarded by `application/python/agent_terminal_app/bridge_client.py` and `bridge-elixir`. Each event is a JSON object stored in an `EventStore` implementation (`core/python/agent_app/events.py`) and augmented with a monotonically increasing string `event_id` when published. ______________________________________________________________________ ## Storage and Transport - **Event store protocol**: `core/python/agent_app/events.py` - `EventStore` interface (`publish`, `list`). - `InMemoryEventStore` adds `event_id: str` on `publish`. - **Application event producer**: `core/python/agent_app/application_future.py` - `AgentApplication.send_request(...)` yields events from `ToolLoopRunner.run(...)` and prepends a `request_started` event. - Also handles cancellation and marks requests as completed. - **Tool loop and streaming**: `core/python/agent_app/tool_loop.py` - `ToolLoopRunner` emits request / assistant / tool / error events. - Internal `session_checkpoint` events include live `Session` objects and are meant for in-process persistence only. - **HTTP exposure**: `application/python/agent_terminal_app/server.py` - `POST /sessions/{session_id}/send` starts a background worker which calls `AgentApplication.send_request(..., stream=True)` and publishes all yielded events into the shared event store. - `GET /events` returns a window of events filtered by `since_id`, `session_id`, and/or `request_id`. - When called with `session_id` but no `since_id`, the server defaults to the per-session cursor (`cursor_event_id`) so that polling does not replay the entire history for that session. - When called with no `since_id`, no `session_id`, and no `request_id`, the server returns an empty `events` list and a `latest_event_id` field suitable for bootstrapping polling without replaying existing events. - For non-bootstrap calls, the server applies a lightweight compaction pass that drops redundant `partial` / `tool_partial` events before terminal events and merges consecutive partials for the same request into a single event. - Session/message routes publish additional lifecycle events (`session_created`, `message_appended`, etc.). - **Bridge forwarding (streaming)**: - `application/python/agent_terminal_app/bridge_client.py`: - `_forward_events_loop()` polls the event store with `since_id` and sends each new event as a `session_event` Phoenix message on topic `server:`. - `bridge-elixir/lib/bridge/server_channel.ex`: - `handle_in("session_event", ...)` re-broadcasts events via `Phoenix.PubSub` on `"events:server:"`. - `bridge-elixir/lib/bridge/app_channel.ex`: - App sockets subscribe to `"events:server:"` when a pairing session is established. - `handle_info({:session_event, event}, ...)` pushes the event to the app as `event: "session_event", payload: {"event": event}`. - Mobile bridge client (`BridgeAdapter`) receives these `session_event` messages and passes the raw `event` to listeners. ______________________________________________________________________ ## Common Fields All externally visible events share a few common patterns: - `event_id: str` - Assigned by `InMemoryEventStore.publish` when the event is stored. - Used with `since_id` in `/events` and the bridge forwarder. - `type: str` - Discriminator for the event’s schema. - `session_id: str` - The logical session this event belongs to. - `request_id: str` - Present for request/streaming/tool events. - Absent for pure session lifecycle events such as `session_created`. Additional fields depend on `type` and are described below. ______________________________________________________________________ ## Request Lifecycle Events Defined in: - `core/python/agent_app/application_future.py` - `AgentApplication.send_request` (request_started, request_cancelled) - `core/python/agent_app/tool_loop.py` - `ToolLoopRunner.run` (request_completed, error) ### `request_started` Emitted once per logical request, before any streaming tokens or tool events. ``` { "type": "request_started", "session_id": "", "request_id": "", "stream": true } ``` ### `request_cancelled` Emitted when a request is cancelled via `AgentApplication.cancel_request(request_id)`. ``` { "type": "request_cancelled", "session_id": "", "request_id": "" } ``` ### `request_completed` Emitted after the tool loop finishes successfully or with an error (always the final event for a given `request_id`). ``` { "type": "request_completed", "session_id": "", "request_id": "" } ``` ### `error` Represents a fatal error during the request/tool loop. ``` { "type": "error", "session_id": "", "request_id": "", "detail": "" } ``` ______________________________________________________________________ ## Assistant Streaming Events Defined in: - `core/python/agent_app/tool_loop.py` - `_run_llm_phase` (assistant_message, partial, final) ### `assistant_message` Signals the lifecycle of a streamed assistant reply. ``` { "type": "assistant_message", "session_id": "", "request_id": "", "phase": "start" | "end" } ``` ### `partial` Streaming assistant chunks. Each `partial` event contains a single assistant `message` chunk. ``` { "type": "partial", "session_id": "", "request_id": "", "message": { "role": "assistant", "content": "...", // may be incremental "metadata": { /* optional, provider-specific */ } } } ``` ### `final` Final assistant messages for a phase. ``` { "type": "final", "session_id": "", "request_id": "", "messages": [ { "role": "assistant", "content": "...", "metadata": { /* ... */ } }, // possibly multiple assistant/tool/system messages ] } ``` > Note: Internal `final` events inside the tool loop also carry a live `session` object. That field is **not** required or relied upon by external consumers; the shared event store and bridge forwarding work with the serializable parts. ______________________________________________________________________ ## Tool Calling Events Defined in: - `core/python/agent_app/tool_loop.py` - `_apply_tool_calls` (tool_calls, tool_partial, tool_results) ### `tool_calls` Emitted when the LLM requests tools to be executed. ``` { "type": "tool_calls", "session_id": "", "request_id": "", "tool_calls": [ { "id": "", // or "tool_call_id" "function": { "name": "tool_name", "arguments": "{...}" // JSON-encoded arguments }, // additional provider-specific fields may be present } ] } ``` ### `tool_partial` Optional streaming events for tool output when `stream_tools=True`. ``` { "type": "tool_partial", "session_id": "", "request_id": "", "tool_call_id": "", "phase": "start" | "stream" | "end", "payload": { // For "start" / "stream": chunk from iter_tool_messages (includes "part") // For "end": { "result": } } } ``` ### `tool_results` Summary of final tool messages appended to the session. ``` { "type": "tool_results", "session_id": "", "request_id": "", "messages": [ { "role": "tool", // or another valid message role "content": "...", "metadata": { "tool_call_id": "", "tool_name": "tool_name", // provider- or tool-specific metadata // // Optional display helper used by terminal and other UIs. // When present and display.type == "text", renderers prefer // display.content over the raw message.content when showing // tool output. "display": { "type": "text", "content": "human-friendly, possibly truncated output" } } } ] } ``` ______________________________________________________________________ ## Internal Checkpoint Events Defined in: - `core/python/agent_app/tool_loop.py` - `_run_llm_phase`, `_apply_tool_calls` ### `session_checkpoint` (internal only) These events are **internal only** and are used by the server and terminal app to persist `Session` objects incrementally. ``` { "type": "session_checkpoint", "session_id": "", "request_id": "", "session": { /* live Session object; not intended for external use */ } } ``` The HTTP server’s background worker (`_run_send_request_background` in `application/python/agent_terminal_app/server.py`) consumes these events but does **not** forward them to `/events` or the bridge as public API. Instead, whenever the server processes a `session_checkpoint` it emits a lightweight, serializable **checkpoint marker** event into the shared event store (described below). ### `checkpoint` (marker event) Defined in: - `application/python/agent_terminal_app/server.py` - `_run_send_request_background` - Session/message lifecycle endpoints Lightweight marker emitted whenever a session has just been persisted to disk, either: - after processing an internal `session_checkpoint` from the tool loop, or - after a non-streaming HTTP operation that modifies a session and saves it (append/modify/delete messages, create/fork sessions, certain agent updates). ``` { "type": "checkpoint", "session_id": "", "request_id": "", // may be omitted for non-request ops "timestamp": "2025-01-01T00:00:00Z" } ``` Semantics: - `checkpoint` events are written to the same `EventStore` as all other events and therefore receive an `event_id` on publish. - The server tracks the most recent **session cursor** event id per session and exposes it as `cursor_event_id` from `GET /sessions/{session_id}/messages`. The cursor may correspond to the `event_id` of a `checkpoint` event or to another event (for example, a `final` or `message_appended` event) whose effects are known to be reflected in the persisted session state. - Within a single LLM/tool phase, `ToolLoopRunner` emits public events (such as `final` or `tool_results`) *before* the internal `session_checkpoint` that captures the updated `Session`. When the server handles this `session_checkpoint` and records a corresponding `checkpoint` marker, the resulting session cursor is always at or after the events that produced the last messages visible in the persisted session history. - A client that loads messages and then starts polling `GET /events` with `since_id = cursor_event_id` will not receive events whose effects are already reflected in that snapshot. ______________________________________________________________________ ## Session & Message Lifecycle Events Defined in `application/python/agent_terminal_app/server.py`: - `create_session` (`POST /sessions`) - `fork_session` (`POST /sessions/{session_id}/fork`) - `add_message` (`POST /sessions/{session_id}/messages`) - `update_message` (`PATCH /sessions/{session_id}/messages/{index}`) - `delete_messages` (`DELETE /sessions/{session_id}/messages`) These events are written into the same shared event store so that terminal and HTTP/mobile frontends see a unified history. ### `session_created` Emitted when a new session is created, either directly or via fork. ``` { "type": "session_created", "session_id": "", "agent_id": "default" | "", "source_session_id": ""?, // present for forked sessions "session": { /* full serialized Session */ } } ``` ### `message_appended` Emitted when a user/system/assistant/tool message is appended. ``` { "type": "message_appended", "session_id": "", "role": "user" | "assistant" | "system" | "tool", "content": "...", "metadata": { /* per-message metadata, if any */ }, "message": { /* serialized Message */ }, "session": { /* full serialized Session after append */ } } ``` ### `message_modified` Emitted when a message’s content is modified. ``` { "type": "message_modified", "session_id": "", "index": 0, "content": "new content", "session": { /* full serialized Session after modification */ } } ``` ### `messages_deleted` Emitted when one or more messages are deleted from a session. ``` { "type": "messages_deleted", "session_id": "", "indices": [0, 1, 2], "session": { /* full serialized Session after deletion */ } } ``` ______________________________________________________________________ ## Where These Events Are Consumed - **Terminal rendering**: - `application/python/agent_terminal_app/event_rendering.py` - Renders `partial`, `final`, `tool_*`, `request_*`, and error events into a human-readable terminal view. - For tool messages/results, prefers `metadata.display.content` (when `display.type == "text"`) over the raw `content` field, so tools can provide a dedicated, user-facing rendering separate from their full machine-oriented output. - **Terminal application logic**: - `application/python/agent_terminal_app/terminal_app.py` - Uses the shared event store to poll and aggregate events for the interactive terminal (see `poll_new_events`, `list_events`). - **HTTP API**: - `application/python/agent_terminal_app/server.py` - `GET /events` exposes stored events for web/mobile clients, applying cursor-aware defaults and basic compaction as described above. - **Bridge + mobile**: - `application/python/agent_terminal_app/bridge_client.py` - Forwards stored events to the Elixir bridge via `session_event`. - `bridge-elixir/lib/bridge/server_channel.ex` - Receives `session_event` from servers and broadcasts via PubSub. - `bridge-elixir/lib/bridge/app_channel.ex` - Pushes `session_event` messages to app sockets. - `mobile/crystal-lattice-control-rn/App.tsx` - `BridgeAdapter` receives `session_event` and surfaces the raw event to listeners. - `mobile/crystal-lattice-control-rn/src/useSessionEvents.ts` - High-level hook that either consumes pushed events (bridge) or polls `/events` and passes them to screens. - `mobile/crystal-lattice-control-rn/src/SessionChatScreen.tsx` - Currently logs per-session events; will later use them to drive live message-list updates. Together, these components define the end-to-end contract for event streaming across terminal, HTTP, bridge, and mobile clients. # AgentCore Core SDK for the AI Agent Platform. Functional architecture: AgentCore stores only the processing pipeline (plugins via wrappers), all transformations are pure functions that take session/config and return new objects. Examples: Basic non-streaming usage: ``` from agent_core import AgentCore from plugins.openai_provider import OpenAICompatibleProvider core = AgentCore() core.register_provider(OpenAICompatibleProvider) session = core.create_session() session = core.add_message(session, "user", "Hello!") config = { "provider": "openai_compatible", "model": "gpt-4o", "base_url": "https://api.openai.com/v1", "api_key": "sk-...", } session, finals = core.send_request(session, config) print(finals[-1]["content"]) # Assistant reply ``` Streaming usage: ```python for chunk in core.send_request_stream(session, config): if chunk["type"] == "partial": print(chunk"message", end="", flush=True) elif chunk["type"] == "final": session = chunk["session"] print(" [Done]") ``` ```` Tool schemas and execution: ```python tool_schemas = core.get_tool_schemas(config) # Execute tool calls the model asked for (application loop) tool_results = core.execute_tool_calls(tool_calls, config) for msg in tool_results: session = core.add_message(session, msg["role"], msg["content"], msg.get("metadata")) ```` ``` ## AgentCore ``` AgentCore(model_cache=None) ``` Stateless processing pipeline for AI Agent Platform. Stores registered plugin wrappers (processing logic), not runtime state. All methods are pure functions that take session/config as parameters. The application layer manages sessions and configuration. Initialize core with empty plugin pipeline. Loggers are initialized lazily so that structlog configuration can be customized by applications before any loggers are created. Source code in `core/python/agent_core/core.py` ``` def __init__(self, model_cache: Optional[ModelDiscoveryCache] = None): """Initialize core with empty plugin pipeline. ``` Loggers are initialized lazily so that structlog configuration can be customized by applications before any loggers are created. """ global logger, log_chunk_processing logger = get_logger("core") log_chunk_processing = get_logger("chunk_processing") self._providers: List[ProviderWrapper] = [] self._features: List[FeatureWrapper] = [] self._tools: List[ToolWrapper] = [] # Model discovery can be shared across fresh AgentCore instances by # passing the same cache object. Dependency/tag/tool-preparation caches # remain core-local because they are tied to wrapper/runtime state. self._model_cache = model_cache or ModelDiscoveryCache() self._dependency_cache: Dict[ Tuple[int, str, str], Tuple[List[str], List[str], List[str], List[str]], ] = {} self._tags_cache: Dict[Tuple[int, Tuple[int, str, str]], List[str]] = {} self._prepared_tool_states: Dict[Tuple[int, str], Dict[str, Any]] = {} self._prepared_tool_data: Dict[Tuple[int, str], Dict[str, Any]] = {} self._context = CoreContext(core=self) ``` ``` ### add_message ``` add_message( session, role, content, metadata=None, config=None, \*, multipart_content=None, after_index=None, context=None, tool_result=None ) ``` Add a message to the session (pure function). When session.metadata contains provider-native history (native_messages) and a provider is registered, converts the newly added message to provider-native format and appends it to the stored native history. Updates verification data for integrity checks. Parameters: | Name | Type | Description | Default | | --- | --- | --- | --- | | `session` | `Session` | Current session. | *required* | | `role` | `MessageRole` | Message role ("system", "user", "assistant", or "tool"). | *required* | | `content` | `Any` | Message content. | *required* | | `metadata` | `Optional[Dict[str, Any]]` | Optional per-message metadata. Note: when provider-native history is enabled (session metadata contains native_messages) and the core rebuilds messages from the provider-native sequence, arbitrary metadata is not preserved by default. Persist metadata explicitly via the provider/feature/extension pipelines using the _metadata key on provider-native messages. | `None` | | `config` | `Optional[Dict[str, Any]]` | Optional configuration used to resolve provider and plugins. | `None` | | `tool_result` | `Optional[Dict[str, Any]]` | Optional structured tool result payload for this message. This is the canonical structured representation for tool messages and may be consumed by providers when appending to provider-native history. | `None` | | `after_index` | `Optional[int]` | Optional index after which to insert the message. When None (default), the message is appended at the end. after_index == -1 inserts at the beginning. Negative indices follow Python semantics relative to the end of the message list. | `None` | Returns: | Type | Description | | --- | --- | | `Session` | New Session with the message added (immutable update). | Source code in `core/python/agent_core/core.py` ``` def add_message( self, session: Session, role: MessageRole, content: Any, metadata: Optional\[Dict[str, Any]\] = None, config: Optional\[Dict[str, Any]\] = None, \*, multipart_content: Optional\[List\[Dict[str, Any]\]\] = None, after_index: Optional[int] = None, context: Optional\[Dict[str, Any]\] = None, tool_result: Optional\[Dict[str, Any]\] = None, ) -> Session: """Add a message to the session (pure function). ``` When session.metadata contains provider-native history (native_messages) and a provider is registered, converts the newly added message to provider-native format and appends it to the stored native history. Updates verification data for integrity checks. Args: session: Current session. role: Message role ("system", "user", "assistant", or "tool"). content: Message content. metadata: Optional per-message metadata. Note: when provider-native history is enabled (session metadata contains ``native_messages``) and the core rebuilds messages from the provider-native sequence, arbitrary metadata is not preserved by default. Persist metadata explicitly via the provider/feature/extension pipelines using the ``_metadata`` key on provider-native messages. config: Optional configuration used to resolve provider and plugins. tool_result: Optional structured tool result payload for this message. This is the canonical structured representation for tool messages and may be consumed by providers when appending to provider-native history. after_index: Optional index after which to insert the message. When ``None`` (default), the message is appended at the end. ``after_index == -1`` inserts at the beginning. Negative indices follow Python semantics relative to the end of the message list. Returns: New Session with the message added (immutable update). """ msg_md = dict(metadata) if isinstance(metadata, dict) else {} message_dict: Dict[str, Any] = { "role": role, "content": content, "metadata": msg_md, } if multipart_content: message_dict["multipartContent"] = multipart_content if tool_result is not None: message_dict[TOOL_RESULT_FIELD] = tool_result # Compute insertion point. When after_index is None, append at end. message_count = len(session.messages) if after_index is None: insert_at = message_count else: if after_index == -1: insert_at = 0 else: idx = after_index if idx < 0: idx = message_count + idx if idx < 0 or idx >= message_count: raise IndexError("after_index out of range") insert_at = idx + 1 # Append uses the existing native-preserving helper. Insert at other # positions rebuilds native history so that provider-native messages # stay consistent with the updated core sequence. if insert_at == message_count: # Always prefer the native-preserving helper; when native history # cannot be safely updated, it falls back to a core-only append. # TODO: Legacy behavior with no config should be removed. new_session, _ = self._append_single_message_with_native( session, message_dict, config or {}, context=context, ) return new_session new_session, _ = self._insert_single_message_with_native( session, message_dict, insert_at, config or {}, context=context, ) return new_session ``` ``` ### apply_feature_completion ``` apply_feature_completion(config, text, completion) ``` Ask features to transform an accepted completion into a snippet. Iterates over registered feature plugins and calls their :meth:`apply_completion` hooks in registration order. The first non-empty string returned is used as the replacement snippet. Parameters: | Name | Type | Description | Default | | --- | --- | --- | --- | | `config` | `Dict[str, Any]` | Application configuration for the current agent or request. | *required* | | `text` | `str` | Full buffer text after the completion has been applied. | *required* | | `completion` | `Dict[str, Any]` | Completion descriptor dict that was accepted, as previously returned by :meth:get_completions. | *required* | Returns: | Type | Description | | --- | --- | | `str` | Replacement snippet string, or an empty string when no feature | | `str` | chooses to handle the completion. | Source code in `core/python/agent_core/core.py` ``` def apply_feature_completion( self, config: Dict[str, Any], text: str, completion: Dict[str, Any], ) -> str: """Ask features to transform an accepted completion into a snippet. ``` Iterates over registered feature plugins and calls their :meth:`apply_completion` hooks in registration order. The first non-empty string returned is used as the replacement snippet. Args: config: Application configuration for the current agent or request. text: Full buffer text after the completion has been applied. completion: Completion descriptor dict that was accepted, as previously returned by :meth:`get_completions`. Returns: Replacement snippet string, or an empty string when no feature chooses to handle the completion. """ if not self._features: return "" for feature in self._features: out = feature.apply_completion(config, text, completion) if isinstance(out, str) and out: return out return "" ``` ``` ### core_messages_to_native ``` core_messages_to_native( core_messages, config, \*, context=None ) ``` Convert core-shaped messages into provider-native messages. This is a thin public wrapper around the normal provider/extension/ feature conversion path. It is intended for request-time features and actions that need to synthesize a core message and append its provider-native form to an in-flight request without re-implementing provider-specific message shaping. Source code in `core/python/agent_core/core.py` ``` def core_messages_to_native( self, core_messages: List\[Dict[str, Any]\], config: Dict[str, Any], \*, context: Optional\[Dict[str, Any]\] = None, ) -> List\[Dict[str, Any]\]: """Convert core-shaped messages into provider-native messages. ``` This is a thin public wrapper around the normal provider/extension/ feature conversion path. It is intended for request-time features and actions that need to synthesize a core message and append its provider-native form to an in-flight request without re-implementing provider-specific message shaping. """ if not isinstance(core_messages, list): raise TypeError("core_messages must be a list") if not isinstance(config, dict): raise TypeError("config must be a dict") return self._core_messages_to_native_for_config( core_messages, config, context=context, ) ``` ``` ### create_session ``` create_session(session_id=None) ``` Create a new empty session. Parameters: | Name | Type | Description | Default | | --- | --- | --- | --- | | `session_id` | `Optional[str]` | Optional session identifier. Auto-generated if omitted. | `None` | Returns: | Type | Description | | --- | --- | | `Session` | New immutable Session instance with no messages. | Source code in `core/python/agent_core/core.py` ``` def create_session(self, session_id: Optional[str] = None) -> Session: """Create a new empty session. ``` Args: session_id: Optional session identifier. Auto-generated if omitted. Returns: New immutable Session instance with no messages. """ if session_id is None: session_id = str(uuid.uuid4()) return Session(session_id=session_id, messages=[]) ``` ``` ### discover_and_register ``` discover_and_register(modules) ``` Discover plugins from Python modules and register them by duck typing. Plugin type is inferred based on the presence of methods on the class: - Provider: has 'stream_api' or 'call_api' - Provider Extension: has 'process_chunk' (and no tool/provider I/O methods) - Tool: has 'get_tool_schemas' and 'execute_tool' - Feature: otherwise Parameters: | Name | Type | Description | Default | | --- | --- | --- | --- | | `modules` | `List[str]` | List of import paths to scan for plugin classes. | *required* | Returns: | Type | Description | | --- | --- | | `Dict[str, List[str]]` | Summary of registered plugin names by type with keys: providers, features, tools, extensions. | Source code in `core/python/agent_core/core.py` ``` def discover_and_register(self, modules: List[str]) -> Dict\[str, List[str]\]: """Discover plugins from Python modules and register them by duck typing. ``` Plugin type is inferred based on the presence of methods on the class: - Provider: has 'stream_api' or 'call_api' - Provider Extension: has 'process_chunk' (and no tool/provider I/O methods) - Tool: has 'get_tool_schemas' and 'execute_tool' - Feature: otherwise Args: modules: List of import paths to scan for plugin classes. Returns: Summary of registered plugin names by type with keys: providers, features, tools, extensions. """ import importlib import inspect def _normalize_identifier(value: str) -> str: return re.sub(r"[^a-z0-9]+", "_", value.lower()).strip("_") def _class_identifier(obj: type) -> str: snake_name = re.sub(r"(? Tuple\[Session, List\[Dict[str, Any]\]\]: """Execute all extension/feature actions whose trigger matches `trigger`.""" ``` if not isinstance(config, dict): raise TypeError("config must be a dict") if not isinstance(trigger, str) or not trigger: raise ValueError("trigger must be a non-empty string") if not self._providers: return session, [] provider, features, state, available_actions = ( self._get_core_session_actions_and_state(config) ) current_session = session results: List[Dict[str, Any]] = [] for action_def in available_actions: if not self._action_matches_trigger(action_def, trigger): continue plugin_id = action_def.get("plugin") action_id = action_def.get("id") action_owner = action_def.get("action_owner") if not isinstance(plugin_id, str) or not isinstance(action_id, str): continue validated_inputs = self._validate_session_action_inputs(action_def, {}) native_messages = self._native_messages_for_session_action( current_session, config ) request_context = self._build_request_context( config=state.get("config") or config, provider=provider, enabled_extensions=provider._iter_extensions(), enabled_features=features, enabled_tools=[], tags=[], models=self._get_models_for_config(provider, config), available_tools=self.get_available_tools(config), tools=self._get_prepared_tools_for_config(config), session=current_session, extra=context, ) request_runtime = self._build_request_runtime(provider, features) enriched_context = self._build_lifecycle_action_context( config=config, session=current_session, lifecycle=trigger, request_context=request_context, request_runtime=request_runtime, extra=context, ) if action_owner == "feature": result: Optional[Dict[str, Any]] = None for feature in features: if feature.name != plugin_id: continue result = feature.execute_action( action_id, current_session, native_messages, validated_inputs, enriched_context, state, ) break if result is None: continue else: result = provider.execute_extension_action( plugin_id, action_id, current_session, native_messages, validated_inputs, enriched_context, state, ) if not isinstance(result, dict): raise RuntimeError("session action must return a dict") current_session, public_result = self._apply_session_action_result( current_session, config, result, context=context, ) results.append( { "plugin": plugin_id, "action_id": action_id, "action_owner": action_owner, "result": public_result, } ) return current_session, results ``` ``` ### execute_response_lifecycle_actions ``` execute_response_lifecycle_actions( session, config, trigger, \*, provider, features, state, final_messages, native_messages, native_final_messages, stream, turn_native_start_index, request_context, extra_context=None ) ``` Execute core-owned response lifecycles before finals are emitted. Source code in `core/python/agent_core/core.py` ``` def execute_response_lifecycle_actions( self, session: Session, config: Dict[str, Any], trigger: str, \*, provider: Any, features: List[Any], state: Dict[str, Any], final_messages: List\[Dict[str, Any]\], native_messages: List\[Dict[str, Any]\], native_final_messages: List\[Dict[str, Any]\], stream: bool, turn_native_start_index: int, request_context: Dict[str, Any], extra_context: Optional\[Dict[str, Any]\] = None, ) -> Tuple\[ List\[Dict[str, Any]\], List\[Dict[str, Any]\], Dict[str, Any], List\[Dict[str, Any]\] \]: """Execute core-owned response lifecycles before finals are emitted.""" ``` if not isinstance(config, dict): raise TypeError("config must be a dict") if not isinstance(trigger, str) or not trigger: raise ValueError("trigger must be a non-empty string") current_final_messages = [ msg for msg in final_messages if isinstance(msg, dict) ] current_native_messages = [ msg for msg in native_messages if isinstance(msg, dict) ] metadata_patch: Dict[str, Any] = {} results: List[Dict[str, Any]] = [] request_runtime = self._build_request_runtime(provider, features) available_actions = self._collect_core_session_actions( provider, features, state ) for action_def in available_actions: if not self._action_matches_trigger(action_def, trigger): continue plugin_id = action_def.get("plugin") action_id = action_def.get("id") action_owner = action_def.get("action_owner") if not isinstance(plugin_id, str) or not isinstance(action_id, str): continue validated_inputs = self._validate_session_action_inputs(action_def, {}) enriched_context = self._build_response_lifecycle_context( config=config, session=session, lifecycle=trigger, request_context=request_context, request_runtime=request_runtime, final_messages=current_final_messages, native_final_messages=[ msg for msg in native_final_messages if isinstance(msg, dict) ], native_messages=current_native_messages, stream=stream, turn_native_start_index=turn_native_start_index, extra=extra_context, ) if action_owner == "feature": result: Optional[Dict[str, Any]] = None for feature in features: if feature.name != plugin_id: continue result = feature.execute_action( action_id, session, current_native_messages, validated_inputs, enriched_context, state, ) break if result is None: continue else: result = provider.execute_extension_action( plugin_id, action_id, session, current_native_messages, validated_inputs, enriched_context, state, ) if not isinstance(result, dict): raise RuntimeError("response lifecycle action must return a dict") if "final_messages" in result: raw_finals = result.get("final_messages") if not isinstance(raw_finals, list) or not all( isinstance(item, dict) for item in raw_finals ): raise RuntimeError( "response lifecycle action final_messages must be a list of dicts" ) current_final_messages = [ item for item in raw_finals if isinstance(item, dict) ] if "native_messages" in result: raw_native = result.get("native_messages") if not isinstance(raw_native, list) or not all( isinstance(item, dict) for item in raw_native ): raise RuntimeError( "response lifecycle action native_messages must be a list of dicts" ) current_native_messages = [ item for item in raw_native if isinstance(item, dict) ] metadata_patch_raw = result.get("session_metadata") if isinstance(metadata_patch_raw, dict): metadata_patch.update(metadata_patch_raw) public_result = { key: value for key, value in result.items() if key not in {"final_messages", "native_messages", "session_metadata"} } results.append( { "plugin": plugin_id, "action_id": action_id, "action_owner": action_owner, "result": public_result, } ) return current_final_messages, current_native_messages, metadata_patch, results ``` ``` ### execute_session_action ``` execute_session_action( session, config, plugin_id, action_id, params, context=None, ) ``` Execute a generic session-scoped extension or feature action. Source code in `core/python/agent_core/core.py` ``` def execute_session_action( self, session: Session, config: Dict[str, Any], plugin_id: str, action_id: str, params: Dict[str, Any], context: Optional\[Dict[str, Any]\] = None, ) -> Tuple\[Session, Dict[str, Any]\]: """Execute a generic session-scoped extension or feature action.""" ``` if not isinstance(config, dict): raise TypeError("config must be a dict") if not isinstance(plugin_id, str) or not plugin_id: raise ValueError("plugin_id must be a non-empty string") if not isinstance(action_id, str) or not action_id: raise ValueError("action_id must be a non-empty string") if not isinstance(params, dict): raise ValueError("params must be a mapping of action inputs") provider, features, state, available_actions = ( self._get_core_session_actions_and_state(config) ) action_def = self._find_session_action_definition( available_actions, plugin_id, action_id ) if action_def is None: raise KeyError( f"Unknown session action {action_id!r} for plugin {plugin_id!r}" ) validated_inputs = self._validate_session_action_inputs(action_def, params) native_messages = self._native_messages_for_session_action(session, config) request_context = self._build_request_context( config=state.get("config") or config, provider=provider, enabled_extensions=provider._iter_extensions(), enabled_features=features, enabled_tools=[], tags=[], models=self._get_models_for_config(provider, config), available_tools=self.get_available_tools(config), tools=self._get_prepared_tools_for_config(config), session=session, extra=context, ) request_runtime = self._build_request_runtime(provider, features) enriched_context = self._build_session_action_context( config=config, session=session, request_context=request_context, request_runtime=request_runtime, extra=context, ) action_owner = action_def.get("action_owner") if action_owner == "feature": result: Optional[Dict[str, Any]] = None for feature in features: if feature.name != plugin_id: continue result = feature.execute_action( action_id, session, native_messages, validated_inputs, enriched_context, state, ) break if result is None: raise KeyError( f"Unknown feature action {action_id!r} for plugin {plugin_id!r}" ) else: result = provider.execute_extension_action( plugin_id, action_id, session, native_messages, validated_inputs, enriched_context, state, ) if not isinstance(result, dict): raise RuntimeError("session action must return a dict") return self._apply_session_action_result( session, config, result, context=context, ) ``` ``` ### execute_tool_calls ``` execute_tool_calls(tool_calls, config, \*, context=None) ``` Return only the final core `tool` message per tool call. This is a compatibility wrapper around :meth:`iter_tool_messages` that ignores partial payloads and keeps only the last final tool message for each call. When a streaming tool yields no items for a call, a synthetic error message is returned. Parameters: | Name | Type | Description | Default | | --- | --- | --- | --- | | `tool_calls` | `List[Dict[str, Any]]` | List of tool call dictionaries to execute. | *required* | | `config` | `Dict[str, Any]` | Current resolved request configuration. | *required* | | `context` | `Optional[Dict[str, Any]]` | Optional context for tool execution. Passed through to :meth:iter_tool_messages which enriches with Layer 1 context. | `None` | Source code in `core/python/agent_core/core.py` ``` def execute_tool_calls( self, tool_calls: List\[Dict[str, Any]\], config: Dict[str, Any], \*, context: Optional\[Dict[str, Any]\] = None, ) -> List\[Dict[str, Any]\]: """Return only the final core `tool` message per tool call. ``` This is a compatibility wrapper around :meth:`iter_tool_messages` that ignores partial payloads and keeps only the last final tool message for each call. When a streaming tool yields no items for a call, a synthetic error message is returned. Args: tool_calls: List of tool call dictionaries to execute. config: Current resolved request configuration. context: Optional context for tool execution. Passed through to :meth:`iter_tool_messages` which enriches with Layer 1 context. """ results: List[Dict[str, Any]] = [] if not tool_calls: return results logger.debug( "execute_tool_calls_start", count=len(tool_calls), ) for call in tool_calls: last_final: Dict[str, Any] | None = None executed = False for item in self.iter_tool_messages([call], config, context=context): if not isinstance(item, dict): continue if "part" in item: # Streaming partial; ignored in final-only API. continue last_final = item executed = True if last_final is None: try: registry = self._get_tool_interop_registry_for_config(config) inspected_call = registry.inspect_call(call) call_id = inspected_call.call_id name = inspected_call.tool_name except Exception: call_id = None name = None metadata: Dict[str, Any] = { "tool_call_id": call_id, "tool_name": name, } last_final = { "role": "tool", "content": f"Error: no tool handled '{name}'", "metadata": metadata, } executed = False md = last_final.get("metadata") or {} logger.debug( "execute_tool_call_complete", tool_name=str(md.get("tool_name")), executed=executed, ) results.append(last_final) return results ``` ``` ### export_session ``` export_session(session, format='json') ``` Export session to a serialized string. Parameters: | Name | Type | Description | Default | | --- | --- | --- | --- | | `session` | `Session` | Session to serialize. | *required* | | `format` | `str` | Export format. Only "json" is supported. | `'json'` | Returns: | Type | Description | | --- | --- | | `str` | Serialized string representing the session. | Raises: | Type | Description | | --- | --- | | `ValueError` | If the format is not supported. | Source code in `core/python/agent_core/core.py` ``` def export_session(self, session: Session, format: str = "json") -> str: """Export session to a serialized string. ``` Args: session: Session to serialize. format: Export format. Only "json" is supported. Returns: Serialized string representing the session. Raises: ValueError: If the format is not supported. """ if format == "json": return json.dumps(session.to_dict(), indent=2) raise ValueError(f"Unsupported format: {format}") ``` ``` ### extract_tool_calls_from_messages ``` extract_tool_calls_from_messages(messages) ``` Extract tool-call dictionaries from assistant message metadata. Source code in `core/python/agent_core/core.py` ``` def extract_tool_calls_from_messages( self, messages: List\[Dict[str, Any]\], ) -> List\[Dict[str, Any]\]: """Extract tool-call dictionaries from assistant message metadata.""" ``` tool_calls: List[Dict[str, Any]] = [] for msg in messages: if not isinstance(msg, dict) or msg.get("role") != "assistant": continue metadata = msg.get("metadata") or {} calls = metadata.get("tool_calls") or [] if isinstance(calls, list): tool_calls.extend([call for call in calls if isinstance(call, dict)]) return tool_calls ``` ``` ### fork_session ``` fork_session( session, config=None, \*, upto_index, new_session_id=None ) ``` Convenience helper to fork a session up to upto_index. Source code in `core/python/agent_core/core.py` ``` def fork_session( self, session: Session, config: Optional\[Dict[str, Any]\] = None, \*, upto_index: int, new_session_id: Optional[str] = None, ) -> Session: """Convenience helper to fork a session up to upto_index.""" sliced = self.slice_session( session, config, start=0, end=upto_index + 1, ) if new_session_id is None or new_session_id == sliced.session_id: return sliced return Session( session_id=new_session_id, messages=sliced.messages, metadata=sliced.metadata, ) ``` ### format_tool_call_preview ``` format_tool_call_preview(tool_call, config) ``` Return a preview record for a single tool call. Source code in `core/python/agent_core/core.py` ``` def format_tool_call_preview( self, tool_call: Dict[str, Any], config: Dict[str, Any], ) -> Dict\[str, Any\]: """Return a preview record for a single tool call.""" ``` provider, extensions, tool_states = self._get_enabled_tool_states_for_config( config ) registry = self._get_tool_interop_registry_for_config( config, provider=provider, extensions=extensions, tool_states=tool_states, ) try: inspected_call = registry.inspect_call(tool_call) except Exception: inspected_call = None if inspected_call is None: return {"id": None, "preview": ""} tool, state, prepared, _matched_schema = self._resolve_tool_handler_for_call( inspected_call, tool_states, registry, ) if tool is None or state is None: return {"id": inspected_call.call_id, "preview": ""} try: preview = tool.format_tool_call_preview( inspected_call.tool_name, inspected_call.payload, state, payload_kind=inspected_call.payload_kind, payload_format=inspected_call.payload_format, payload_metadata=inspected_call.payload_metadata, tool_call=inspected_call.raw, prepared=prepared, ) except Exception: preview = "" return { "id": inspected_call.call_id, "preview": preview if isinstance(preview, str) else "", } ``` ``` ### format_tool_call_previews ``` format_tool_call_previews(tool_calls, config) ``` Return preview records for all supplied tool calls. Source code in `core/python/agent_core/core.py` ``` def format_tool_call_previews( self, tool_calls: List\[Dict[str, Any]\], config: Dict[str, Any], ) -> List\[Dict[str, Any]\]: """Return preview records for all supplied tool calls.""" ``` return [ self.format_tool_call_preview(tool_call, config) for tool_call in tool_calls if isinstance(tool_call, dict) ] ``` ``` ### get_available_tools ``` get_available_tools(config) ``` Get the full available tool catalog for a config before filtering. Source code in `core/python/agent_core/core.py` ``` def get_available_tools(self, config: Dict[str, Any]) -> List\[ToolDescriptor\]: """Get the full available tool catalog for a config before filtering.""" return self.\_get_available_tools_for_config(config)[0] ``` ### get_completions ``` get_completions(config, text) ``` Collect completion suggestions from registered feature plugins. Parameters: | Name | Type | Description | Default | | --- | --- | --- | --- | | `config` | `Dict[str, Any]` | Application configuration for the current agent or request. | *required* | | `text` | `str` | Full text before the cursor (for example, the current input line in a terminal UI). | *required* | Returns: | Type | Description | | --- | --- | | `List[Dict[str, Any]]` | List of completion descriptor dictionaries contributed by | | `List[Dict[str, Any]]` | features. The exact shape of each entry is feature- and | | `List[Dict[str, Any]]` | application-defined; common keys include "replacement", | | `List[Dict[str, Any]]` | "start", "display", and "display_meta". | Source code in `core/python/agent_core/core.py` ``` def get_completions( self, config: Dict[str, Any], text: str ) -> List\[Dict[str, Any]\]: """Collect completion suggestions from registered feature plugins. ``` Args: config: Application configuration for the current agent or request. text: Full text before the cursor (for example, the current input line in a terminal UI). Returns: List of completion descriptor dictionaries contributed by features. The exact shape of each entry is feature- and application-defined; common keys include "replacement", "start", "display", and "display_meta". """ completions: List[Dict[str, Any]] = [] if not self._features: return completions for feature in self._features: try: items = feature.get_completions(config, text) or [] except Exception: continue for item in items: if isinstance(item, dict): completions.append(item) return completions ``` ``` ### get_config_schema ``` get_config_schema() ``` Get flattened config schema from all registered plugins. Returns: | Type | Description | | --- | --- | | `List[Dict[str, Any]]` | List of config entry descriptors. Each element is a | | `List[Dict[str, Any]]` | dictionary that includes: | | `List[Dict[str, Any]]` | "key": the top-level config key. | | `List[Dict[str, Any]]` | "plugin": the contributing plugin's id/name (provider, extension, feature, or tool). | | `List[Dict[str, Any]]` | Any additional metadata provided by the plugin (for example, "type", "default", "required", "description"). | | `List[Dict[str, Any]]` | Multiple plugins may contribute entries for the same | | `List[Dict[str, Any]]` | "key"; each contribution is represented as a separate | | `List[Dict[str, Any]]` | element in the returned list. | Source code in `core/python/agent_core/core.py` ``` def get_config_schema(self) -> List\[Dict[str, Any]\]: """Get flattened config schema from all registered plugins. ``` Returns: List of config entry descriptors. Each element is a dictionary that includes: - ``"key"``: the top-level config key. - ``"plugin"``: the contributing plugin's id/name (provider, extension, feature, or tool). - Any additional metadata provided by the plugin (for example, ``"type"``, ``"default"``, ``"required"``, ``"description"``). Multiple plugins may contribute entries for the same ``"key"``; each contribution is represented as a separate element in the returned list. """ entries: List[Dict[str, Any]] = [] def _extend(schema: Dict[str, Any] | None, plugin_name: str) -> None: if not schema: return for key, value in schema.items(): if not isinstance(key, str) or not key: continue if not isinstance(value, dict): continue entry: Dict[str, Any] = dict(value) if "plugin" not in entry: entry["plugin"] = plugin_name entry.setdefault("key", key) entries.append(entry) for provider in self._providers: _extend(provider.get_config_schema(), provider.name) # Include provider extension schemas for name, ext_schema in provider.get_extension_config_schemas().items(): _extend(ext_schema, name) for feature in self._features: _extend(feature.get_config_schema(), feature.name) for tool in self._tools: _extend(tool.get_config_schema(), tool.name) return entries ``` ``` ### get_plugins_for_config ``` get_plugins_for_config(config) ``` Return active plugin identifiers for the given config. The result is derived from the same tag-based dependency resolution used internally by the core. It maps plugin kinds ("providers", "extensions", "features", "tools") to lists of active plugin identifiers for this config. Source code in `core/python/agent_core/core.py` ``` def get_plugins_for_config( self, config: Dict[str, Any], ) -> Dict\[str, List[str]\]: """Return active plugin identifiers for the given config. ``` The result is derived from the same tag-based dependency resolution used internally by the core. It maps plugin kinds ("providers", "extensions", "features", "tools") to lists of active plugin identifiers for this config. """ ( provider, enabled_extensions, enabled_features, enabled_tools, _tags, ) = self._resolve_plugins_for_config(config) return { "providers": [provider.name], "extensions": [ext.name for ext in enabled_extensions], "features": [feat.name for feat in enabled_features], "tools": [tool.name for tool in enabled_tools], } ``` ``` ### get_session_actions ``` get_session_actions(config) ``` Return session-scoped extension and feature actions for the config. Source code in `core/python/agent_core/core.py` ``` def get_session_actions(self, config: Dict[str, Any]) -> List\[Dict[str, Any]\]: """Return session-scoped extension and feature actions for the config.""" ``` if not isinstance(config, dict): raise TypeError("config must be a dict") if not self._providers: return [] _provider, _features, _state, actions = ( self._get_core_session_actions_and_state(config) ) return actions ``` ``` ### get_tool_schemas ``` get_tool_schemas(config) ``` Get the effective tool schemas for a config after feature policy. Source code in `core/python/agent_core/core.py` ``` def get_tool_schemas(self, config: Dict[str, Any]) -> List\[Dict[str, Any]\]: """Get the effective tool schemas for a config after feature policy.""" return [ descriptor.schema for descriptor in self.\_get_prepared_tools_for_config(config) ] ``` ### get_ui_schema ``` get_ui_schema(config) ``` Get flattened UI schema for the effective config. Plugins receive additional context computed from `config`: - `tags`: capability/environment tags produced by the provider and enabled plugins. - `models`: model descriptors returned by `get_models` hooks. Each element is a dictionary describing a UI element contributed by a provider, extension, feature, or tool. The core treats the dictionaries as opaque and simply annotates and flattens them; applications decide how to render or interpret each entry. Source code in `core/python/agent_core/core.py` ``` def get_ui_schema(self, config: Dict[str, Any]) -> List\[Dict[str, Any]\]: """Get flattened UI schema for the effective config. ``` Plugins receive additional context computed from ``config``: - ``tags``: capability/environment tags produced by the provider and enabled plugins. - ``models``: model descriptors returned by ``get_models`` hooks. Each element is a dictionary describing a UI element contributed by a provider, extension, feature, or tool. The core treats the dictionaries as opaque and simply annotates and flattens them; applications decide how to render or interpret each entry. """ if not isinstance(config, dict): raise TypeError("config must be a dict") if not self._providers and not self._features and not self._tools: return [] provider: Optional[ProviderWrapper] = None enabled_extensions: List[ExtensionWrapper] = [] enabled_features: List[FeatureWrapper] = list(self._features) enabled_tools: List[ToolWrapper] = list(self._tools) tags: List[str] = [] models: List[Dict[str, Any]] = [] if self._providers: ( provider, enabled_extensions, enabled_features, enabled_tools, tags, ) = self._resolve_plugins_for_config(config) provider.set_active_extensions(enabled_extensions) models = self._get_models_for_config(provider, config) enabled_features = sorted( enabled_features, key=lambda feat: getattr(feat, "priority", 100) ) enabled_tools = sorted( enabled_tools, key=lambda tool: getattr(tool, "priority", 100) ) available_tools = self.get_available_tools(config) effective_tools = self._get_prepared_tools_for_config(config) ui_context = self._build_plugin_context( config=config, provider=provider, enabled_extensions=enabled_extensions, enabled_features=enabled_features, enabled_tools=enabled_tools, tags=tags, models=models, available_tools=available_tools, tools=effective_tools, ) extension_plugin_names = {ext.name for ext in enabled_extensions} feature_plugin_names = {feature.name for feature in enabled_features} elements: List[Dict[str, Any]] = [] config_by_key: Dict[str, Dict[str, Any]] = {} def _extend(raw_elements: List[Dict[str, Any]], plugin_name: str) -> None: for el in raw_elements or []: if not isinstance(el, dict): continue entry: Dict[str, Any] = dict(el) ui_type = entry.get("ui_type") if not isinstance(ui_type, str) or not ui_type: entry["ui_type"] = "config" ui_type = "config" if "plugin" not in entry and plugin_name: entry["plugin"] = plugin_name if ( plugin_name in extension_plugin_names and ui_type in {"session_action", "message_action"} and isinstance(entry.get("action_id"), str) and "action_owner" not in entry ): entry["action_owner"] = "provider_extension" elif ( plugin_name in feature_plugin_names and ui_type in {"session_action", "message_action"} and isinstance(entry.get("action_id"), str) and "action_owner" not in entry ): entry["action_owner"] = "feature" if ui_type == "config": key = entry.get("key") if isinstance(key, str) and key: config_by_key[key] = entry continue elements.append(entry) if provider is not None: _extend(provider.get_ui_elements(config, ui_context), provider.name) for name, ext_ui in provider.get_extension_ui_elements( config, ui_context ).items(): _extend(ext_ui, name) for feature in enabled_features: _extend(feature.get_ui_elements(config, ui_context), feature.name) for tool in enabled_tools: _extend(tool.get_ui_elements(config, ui_context), tool.name) elements.extend(config_by_key.values()) return elements ``` ``` ### import_session ``` import_session(data, format='json') ``` Import a session from a serialized string. Parameters: | Name | Type | Description | Default | | --- | --- | --- | --- | | `data` | `str` | Serialized session string. | *required* | | `format` | `str` | Import format. Only "json" is supported. | `'json'` | Returns: | Type | Description | | --- | --- | | `Session` | A new Session reconstructed from the serialized data. | Raises: | Type | Description | | --- | --- | | `ValueError` | If the format is not supported. | Source code in `core/python/agent_core/core.py` ``` def import_session(self, data: str, format: str = "json") -> Session: """Import a session from a serialized string. ``` Args: data: Serialized session string. format: Import format. Only "json" is supported. Returns: A new Session reconstructed from the serialized data. Raises: ValueError: If the format is not supported. """ if format == "json": session_dict = json.loads(data) return Session.from_dict(session_dict) raise ValueError(f"Unsupported format: {format}") ``` ``` ### initialize_action_request ``` initialize_action_request( native_messages, state, request_runtime, request_context ) ``` Apply request-time initialization to session-action inputs. Session actions should see the same request-time state that normal provider requests see, including feature/provider `initialize_request` changes such as compiled top-level instructions. Source code in `core/python/agent_core/core.py` ``` def initialize_action_request( self, native_messages: List\[Dict[str, Any]\], state: Dict[str, Any], request_runtime: Dict[str, Any], request_context: Dict[str, Any], ) -> Tuple\[List\[Dict[str, Any]\], Dict[str, Any]\]: """Apply request-time initialization to session-action inputs. ``` Session actions should see the same request-time state that normal provider requests see, including feature/provider `initialize_request` changes such as compiled top-level instructions. """ if not isinstance(native_messages, list): raise TypeError("native_messages must be a list") if not isinstance(state, dict): raise TypeError("state must be a dict") if not isinstance(request_runtime, dict): raise TypeError("request_runtime must be a dict") if not isinstance(request_context, dict): raise TypeError("request_context must be a dict") provider = request_runtime.get("provider") if provider is None: raise ValueError("request_runtime must include provider") raw_features = request_runtime.get("features") if raw_features is None: features: List[Any] = [] elif isinstance(raw_features, list): features = raw_features else: raise TypeError("request_runtime['features'] must be a list") next_native_messages, next_state = provider.initialize_request( native_messages, state, context=request_context, ) initialized_native = ( next_native_messages if next_native_messages != native_messages else native_messages ) initialized_state = next_state for feature in features: next_native_messages, initialized_state = feature.initialize_request( initialized_native, initialized_state, context=request_context, ) if next_native_messages != initialized_native: initialized_native = next_native_messages provider.set_state(initialized_state) return initialized_native, initialized_state ``` ``` ### iter_tool_messages ``` iter_tool_messages( tool_calls, config, \*, cancellation=None, context=None ) ``` Yield a mixed stream of partial payloads and final core `tool` messages. Tools stream via `ToolPlugin.stream_tool`. Partial display payloads are yielded as-is when they contain a `"part"` key. Result dictionaries are detected via the presence of a `"success"` key and transformed into final core `tool` messages at the end of each tool call. Parameters: | Name | Type | Description | Default | | --- | --- | --- | --- | | `tool_calls` | `List[Dict[str, Any]]` | List of tool call dictionaries to execute. | *required* | | `config` | `Dict[str, Any]` | Current resolved request configuration. | *required* | | `cancellation` | `Any | None` | Optional cancellation token. | `None` | | `context` | `Optional[Dict[str, Any]]` | Optional context for tool execution. Layer 1 context (core, config, trigger_source) is enriched by this method if not already present. | `None` | Source code in `core/python/agent_core/core.py` ``` def iter_tool_messages( self, tool_calls: List\[Dict[str, Any]\], config: Dict[str, Any], \*, cancellation: Any | None = None, context: Optional\[Dict[str, Any]\] = None, ) -> Iterator\[Dict[str, Any]\]: """Yield a mixed stream of partial payloads and final core `tool` messages. ``` Tools stream via ``ToolPlugin.stream_tool``. Partial display payloads are yielded as-is when they contain a ``"part"`` key. Result dictionaries are detected via the presence of a ``"success"`` key and transformed into final core ``tool`` messages at the end of each tool call. Args: tool_calls: List of tool call dictionaries to execute. config: Current resolved request configuration. cancellation: Optional cancellation token. context: Optional context for tool execution. Layer 1 context (core, config, trigger_source) is enriched by this method if not already present. """ if not tool_calls: return logger.debug( "iter_tool_messages_start", count=len(tool_calls), ) provider, extensions, tool_states = self._get_enabled_tool_states_for_config( config ) registry = self._get_tool_interop_registry_for_config( config, provider=provider, extensions=extensions, tool_states=tool_states, ) for call in tool_calls: try: inspected_call = registry.inspect_call(call) except Exception: inspected_call = None if inspected_call is None: call_id = None name = None # No matching tool found; synthesize an error message. metadata: Dict[str, Any] = { "tool_call_id": call_id, "tool_name": name, } yield { "role": "tool", "content": f"Error: no tool handled '{name}'", "metadata": metadata, } continue call_id = inspected_call.call_id name = inspected_call.tool_name tool, state, prepared, matched_schema = self._resolve_tool_handler_for_call( inspected_call, tool_states, registry, ) if tool is None or state is None: metadata = { "tool_call_id": call_id, "tool_name": name, } yield { "role": "tool", "content": f"Error: no tool handled '{name}'", "metadata": metadata, } continue last_result: Dict[str, Any] | None = None had_result = False stream_error: str | None = None # Layer 1: Core-level context enrichment. # Higher layers (application, terminal) may have already set these; # use setdefault so we never overwrite a richer value. tool_context = self._build_tool_runtime_context(config, context) try: iterator = tool.stream_tool( name, inspected_call.payload, state, payload_kind=inspected_call.payload_kind, payload_format=inspected_call.payload_format, payload_metadata=inspected_call.payload_metadata, tool_call=inspected_call.raw, cancellation=cancellation, context=tool_context, prepared=prepared, ) iterator = iter(iterator) except Exception as exc: iterator = None stream_error = f"Tool error: {exc}" if iterator is not None: try: for chunk in iterator: if not isinstance(chunk, dict): continue if "success" in chunk: last_result = chunk had_result = True elif "part" in chunk: # Partial display payload; forward as-is. yield chunk else: # Backwards-compatible: treat any other dict as a partial payload. yield {"part": chunk} except Exception as exc: stream_error = f"Tool error: {exc}" logger.error( "iter_tool_messages_stream_error", tool_name=str(name), error=str(exc), ) if not had_result: # No RESULT dict was produced; synthesize an error message. content = stream_error or f"Error: no tool handled '{name}'" metadata = { "tool_call_id": call_id, "tool_name": name, } plugin_name = getattr(tool, "name", None) if isinstance(plugin_name, str) and plugin_name: metadata["tool_plugin"] = plugin_name yield { "role": "tool", "content": content, "metadata": metadata, } continue assert last_result is not None try: formatted_content = tool.format_tool_result(last_result, state) except Exception as exc: formatted_content = f"Tool formatting error: {exc}" inspected_result = registry.inspect_result(formatted_content) display_text = inspected_result.text display_payload: Dict[str, Any] | None = None try: display_candidate = tool.to_display_format( display_text, last_result, state, ) if isinstance(display_candidate, dict): display_payload = display_candidate except Exception: display_payload = None if ( isinstance(display_payload, dict) and display_payload.get("type") == "text" and display_payload.get("content") == display_text and "single_line" not in display_payload and isinstance(inspected_result.display, dict) ): display_payload = inspected_result.display tool_result_value: Dict[str, Any] if isinstance(formatted_content, dict) and ( inspected_result.format_id != CORE_TEXT_TOOL_RESULT_FORMAT_ID ): tool_result_value = formatted_content else: converted_result = registry.convert_tool_result( formatted_content, target=ToolInteropTarget("core.tool_result"), target_formats=[CORE_TEXT_TOOL_RESULT_FORMAT_ID], ) if isinstance(converted_result, dict): tool_result_value = converted_result else: tool_result_value = { "type": "tool_result", "text": inspected_result.text, } if display_payload is None: canonical_result = registry.inspect_result(tool_result_value) if isinstance(canonical_result.display, dict): display_payload = canonical_result.display metadata = { "tool_call_id": call_id, "tool_name": name, } if matched_schema is not None: metadata["tool_schema"] = matched_schema plugin_name = getattr(tool, "name", None) if isinstance(plugin_name, str) and plugin_name: metadata["tool_plugin"] = plugin_name if display_payload is not None: metadata["display"] = display_payload tool_message = { "role": "tool", "content": registry.inspect_result(tool_result_value).text, "metadata": metadata, TOOL_RESULT_FIELD: tool_result_value, } yield tool_message ``` ``` ### join_sessions ``` join_sessions(prefix, suffix, config=None) ``` Join two sessions end-to-end, preserving native history when possible. This helper concatenates the core messages from `prefix` and `suffix` and, when both sessions contain provider-native history, combines their native histories and rebuilds core messages from the merged native sequence. Note: arbitrary per-message `Message.metadata` is not preserved by default across the native-history rebuild step. If metadata must survive native rebuilds, it must be persisted into provider-native messages via the `_metadata` key and reconstructed in `from_native_messages`. Parameters: | Name | Type | Description | Default | | --- | --- | --- | --- | | `prefix` | `Session` | First session segment. | *required* | | `suffix` | `Session` | Second session segment that should follow prefix. | *required* | | `config` | `Optional[Dict[str, Any]]` | Optional configuration used when rebuilding from native history. When omitted, joins are performed in core-only mode. | `None` | Returns: | Type | Description | | --- | --- | | `Session` | New Session instance representing the concatenation of prefix | | `Session` | and suffix. | Source code in `core/python/agent_core/core.py` ``` def join_sessions( self, prefix: Session, suffix: Session, config: Optional\[Dict[str, Any]\] = None, ) -> Session: """Join two sessions end-to-end, preserving native history when possible. ``` This helper concatenates the core messages from ``prefix`` and ``suffix`` and, when both sessions contain provider-native history, combines their native histories and rebuilds core messages from the merged native sequence. Note: arbitrary per-message ``Message.metadata`` is not preserved by default across the native-history rebuild step. If metadata must survive native rebuilds, it must be persisted into provider-native messages via the ``_metadata`` key and reconstructed in ``from_native_messages``. Args: prefix: First session segment. suffix: Second session segment that should follow ``prefix``. config: Optional configuration used when rebuilding from native history. When omitted, joins are performed in core-only mode. Returns: New Session instance representing the concatenation of ``prefix`` and ``suffix``. """ from agent_core.types import Message # local import to avoid cycles # Core-only join when native history is not consistently available on # both sessions or when no configuration is provided. has_prefix_native = isinstance(prefix.metadata, dict) and isinstance( prefix.metadata.get("native_messages"), list ) has_suffix_native = isinstance(suffix.metadata, dict) and isinstance( suffix.metadata.get("native_messages"), list ) if not config or not (has_prefix_native and has_suffix_native): combined_messages: List[Message] = [*prefix.messages, *suffix.messages] new_metadata: Dict[str, Any] = ( dict(prefix.metadata) if isinstance(prefix.metadata, dict) else {} ) new_metadata.pop("native_messages", None) new_metadata.pop("native_messages_integrity", None) return Session( session_id=prefix.session_id, messages=combined_messages, metadata=new_metadata, ) # Native-preserving join: concatenate native histories and rebuild # core messages from the combined native sequence. native_prefix = prefix.metadata.get("native_messages") or [] native_suffix = suffix.metadata.get("native_messages") or [] new_native: List[Dict[str, Any]] = list(native_prefix) + list(native_suffix) # Skip initialize_request for session join operations. # This prevents features like SystemMessageFeature from adding # synthetic messages during join, which would cause duplicates # if one of the sessions already has a system message. rebuilt_messages, post_init_native = self._rebuild_core_from_native( new_native, config or {}, skip_initialize=True ) new_metadata = ( dict(prefix.metadata) if isinstance(prefix.metadata, dict) else {} ) new_metadata["native_messages"] = post_init_native new_metadata["native_messages_integrity"] = self._compute_native_integrity( rebuilt_messages ) return Session( session_id=prefix.session_id, messages=rebuilt_messages, metadata=new_metadata, ) ``` ``` ### modify_message ``` modify_message( session, index, content, config=None, \*, context=None ) ``` Modify the content of an existing message (pure function). Only `system`, `user`, and `assistant` messages are supported. The message role and metadata remain unchanged. When provider-native history is present and safely mappable via `native_indices`, the corresponding native messages are updated. Otherwise, native history is dropped and will be reconstructed on the next request. Parameters: | Name | Type | Description | Default | | --- | --- | --- | --- | | `session` | `Session` | Current session. | *required* | | `index` | `int` | Message index to modify (0-based; negative indices supported). | *required* | | `content` | `str` | New message content. | *required* | | `config` | `Optional[Dict[str, Any]]` | Optional configuration used to resolve provider and plugins when updating native history. | `None` | Returns: | Type | Description | | --- | --- | | `Session` | New Session with the modified message (immutable update). | Source code in `core/python/agent_core/core.py` ``` def modify_message( self, session: Session, index: int, content: str, config: Optional\[Dict[str, Any]\] = None, \*, context: Optional\[Dict[str, Any]\] = None, ) -> Session: """Modify the content of an existing message (pure function). ``` Only `system`, `user`, and `assistant` messages are supported. The message role and metadata remain unchanged. When provider-native history is present and safely mappable via `native_indices`, the corresponding native messages are updated. Otherwise, native history is dropped and will be reconstructed on the next request. Args: session: Current session. index: Message index to modify (0-based; negative indices supported). content: New message content. config: Optional configuration used to resolve provider and plugins when updating native history. Returns: New Session with the modified message (immutable update). """ return self._modify_single_message_with_native( session, index, content, config or {}, context=context ) ``` ``` ### patch_native_internal_metadata ``` patch_native_internal_metadata( native_messages, indices, patch ) ``` Immutably merge `patch` into `_metadata` on selected native items. Source code in `core/python/agent_core/core.py` ``` def patch_native_internal_metadata( self, native_messages: List\[Dict[str, Any]\], indices: List[int], patch: Dict[str, Any], ) -> List\[Dict[str, Any]\]: """Immutably merge `patch` into `_metadata` on selected native items.""" ``` if not indices or not isinstance(patch, dict) or not patch: return native_messages out: Optional[List[Dict[str, Any]]] = None for idx in sorted({i for i in indices if isinstance(i, int)}): if idx < 0 or idx >= len(native_messages): continue item = native_messages[idx] if not isinstance(item, dict): continue raw_internal = item.get("_metadata") internal = dict(raw_internal) if isinstance(raw_internal, dict) else {} merged_internal = {**internal, **patch} if merged_internal == internal: continue if out is None: out = list(native_messages) out[idx] = {**item, "_metadata": merged_internal} return out if out is not None else native_messages ``` ``` ### rebuild_native_history ``` rebuild_native_history( session, config=None, \*, start=None, end=None, context=None ) ``` Rebuild provider-native history from the current core messages. This helper converts the session's core messages to provider-native form using the configured provider, extensions, and features, then rebuilds the full core message sequence from that native history. When `start`/`end` are provided, only the visible message slice `session.messages[start:end]` is rebuilt back into native history before the canonical full core transcript is reconstructed. Parameters: | Name | Type | Description | Default | | --- | --- | --- | --- | | `session` | `Session` | Current session. | *required* | | `config` | `Optional[Dict[str, Any]]` | Configuration used to resolve provider and plugins. | `None` | | `start` | `Optional[int]` | Optional inclusive visible-message start index for selective rebuild. Requires retained native history on the session. | `None` | | `end` | `Optional[int]` | Optional exclusive visible-message end index for selective rebuild. Requires retained native history on the session. | `None` | Returns: | Type | Description | | --- | --- | | `Session` | New Session with native history rebuilt from core messages. | Source code in `core/python/agent_core/core.py` ``` def rebuild_native_history( self, session: Session, config: Optional\[Dict[str, Any]\] = None, \*, start: Optional[int] = None, end: Optional[int] = None, context: Optional\[Dict[str, Any]\] = None, ) -> Session: """Rebuild provider-native history from the current core messages. ``` This helper converts the session's core messages to provider-native form using the configured provider, extensions, and features, then rebuilds the full core message sequence from that native history. When ``start``/``end`` are provided, only the visible message slice ``session.messages[start:end]`` is rebuilt back into native history before the canonical full core transcript is reconstructed. Args: session: Current session. config: Configuration used to resolve provider and plugins. start: Optional inclusive visible-message start index for selective rebuild. Requires retained native history on the session. end: Optional exclusive visible-message end index for selective rebuild. Requires retained native history on the session. Returns: New Session with native history rebuilt from core messages. """ if config is None: raise ValueError("config must be provided to rebuild native history") if not isinstance(config, dict): raise TypeError("config must be a dict when rebuilding native history") if start is not None or end is not None: message_count = len(session.messages) start_idx = 0 if start is None else start end_idx = message_count if end is None else end if ( start_idx < 0 or end_idx < 0 or start_idx >= end_idx or end_idx > message_count ): raise ValueError( "start/end must select a non-empty visible message slice" ) retained_native = None base_metadata: Dict[str, Any] = ( dict(session.metadata) if isinstance(session.metadata, dict) else {} ) rn = base_metadata.get("native_messages") if isinstance(rn, list): retained_native = rn if retained_native is None: raise RuntimeError( "selective rebuild requires retained native history on the session" ) self._verify_native_integrity(session) target_messages = session.messages[start_idx:end_idx] target_native_indices: List[int] = [] for message in target_messages: metadata = ( message.metadata if isinstance(message.metadata, dict) else {} ) native_indices = metadata.get("native_indices") if not isinstance(native_indices, list) or not native_indices: raise RuntimeError( "selective rebuild requires native_indices for every selected message" ) target_native_indices.extend( index for index in native_indices if isinstance(index, int) ) if not target_native_indices: raise RuntimeError( "selected visible message slice maps to no native history" ) unique_indices = sorted(set(target_native_indices)) expected_indices = list(range(unique_indices[0], unique_indices[-1] + 1)) if unique_indices != expected_indices: raise RuntimeError( "selected visible message slice does not map to a contiguous native slice" ) replacement_core = [message.to_dict() for message in target_messages] replacement_native = self._core_messages_to_native_for_config( replacement_core, config, context=context, ) before = retained_native[: unique_indices[0]] after = retained_native[unique_indices[-1] + 1 :] new_native = [*before, *replacement_native, *after] rebuilt_messages, post_init_native = self._rebuild_core_from_native( new_native, config, context=context ) base_metadata["native_messages"] = post_init_native base_metadata["native_messages_integrity"] = self._compute_native_integrity( rebuilt_messages ) return Session( session_id=session.session_id, messages=rebuilt_messages, metadata=base_metadata, ) # Convert core messages to provider-native history. core_dicts: List[Dict[str, Any]] = [m.to_dict() for m in session.messages] new_native = self._core_messages_to_native_for_config( core_dicts, config, context=context, ) # Rebuild canonical core messages from the new native history so that # provider/feature transformations and native_indices are normalized. # Use post-initialize native to capture any structural changes from features. rebuilt_messages, post_init_native = self._rebuild_core_from_native( new_native, config, context=context ) new_metadata: Dict[str, Any] = ( dict(session.metadata) if isinstance(session.metadata, dict) else {} ) new_metadata["native_messages"] = post_init_native new_metadata["native_messages_integrity"] = self._compute_native_integrity( rebuilt_messages ) return Session( session_id=session.session_id, messages=rebuilt_messages, metadata=new_metadata, ) ``` ``` ### register_feature ``` register_feature(plugin_class) ``` Register a feature plugin class. Parameters: | Name | Type | Description | Default | | --- | --- | --- | --- | | `plugin_class` | `type` | Feature plugin class. | *required* | Source code in `core/python/agent_core/core.py` ``` def register_feature(self, plugin_class: type) -> None: """Register a feature plugin class. ``` Args: plugin_class: Feature plugin class. """ wrapper = FeatureWrapper(plugin_class) self._features.append(wrapper) self._reset_caches() ``` ``` ### register_provider ``` register_provider(plugin_class, extensions=None) ``` Register provider plugin class with optional provider extensions. Extensions are loaded by the provider wrapper (same language, hot path). Parameters: | Name | Type | Description | Default | | --- | --- | --- | --- | | `plugin_class` | `type` | Provider plugin class. | *required* | | `extensions` | `Optional[List[type]]` | Optional list of provider extension classes to register with the provider. | `None` | Source code in `core/python/agent_core/core.py` ``` def register_provider( self, plugin_class: type, extensions: Optional\[List[type]\] = None ) -> None: """Register provider plugin class with optional provider extensions. ``` Extensions are loaded by the provider wrapper (same language, hot path). Args: plugin_class: Provider plugin class. extensions: Optional list of provider extension classes to register with the provider. """ wrapper = ProviderWrapper(plugin_class, extensions or []) self._providers = [*self._providers, wrapper] self._reset_caches() ``` ``` ### register_tool ``` register_tool(plugin_class) ``` Register a tool plugin class. Parameters: | Name | Type | Description | Default | | --- | --- | --- | --- | | `plugin_class` | `type` | Tool plugin class. | *required* | Source code in `core/python/agent_core/core.py` ``` def register_tool(self, plugin_class: type) -> None: """Register a tool plugin class. ``` Args: plugin_class: Tool plugin class. """ wrapper = ToolWrapper(plugin_class) self._tools.append(wrapper) self._reset_caches() ``` ``` ### send_request ``` send_request( session, config, \*, request_id=None, cancellation=None, context=None ) ``` Send request and return a new session with the response. Runs provider call + finalize with features operating on provider-native data; converts provider-native finals to core at the end and appends them to the session. Parameters: | Name | Type | Description | Default | | --- | --- | --- | --- | | `session` | `Session` | Current session. | *required* | | `config` | `Dict[str, Any]` | Application-provided configuration for this request. | *required* | Returns: | Type | Description | | --- | --- | | `Session` | Tuple of (new_session, final_core_messages) where the new session includes the | | `List[Dict[str, Any]]` | appended final messages and retained provider-native history for this turn. | Raises: | Type | Description | | --- | --- | | `RuntimeError` | If no provider is registered. | Source code in `core/python/agent_core/core.py` ``` def send_request( self, session: Session, config: Dict[str, Any], \*, request_id: str | None = None, cancellation: Any | None = None, context: Optional\[Dict[str, Any]\] = None, ) -> Tuple\[Session, List\[Dict[str, Any]\]\]: """Send request and return a new session with the response. ``` Runs provider call + finalize with features operating on provider-native data; converts provider-native finals to core at the end and appends them to the session. Args: session: Current session. config: Application-provided configuration for this request. Returns: Tuple of (new_session, final_core_messages) where the new session includes the appended final messages and retained provider-native history for this turn. Raises: RuntimeError: If no provider is registered. """ if not self._providers: raise RuntimeError("No provider registered") provider, enabled_extensions, features, tools, _tags = ( self._resolve_plugins_for_config(config) ) features = sorted(features, key=lambda feat: getattr(feat, "priority", 100)) logger.debug( "plugins_resolved_for_config", provider=provider.name, extensions=[ext.name for ext in enabled_extensions], features=[feat.name for feat in features], tools=[tool.name for tool in tools], tags=_tags, ) provider.set_active_extensions(enabled_extensions) cancel_token: Any | None = None if request_id and cancellation is not None: try: cancel_token = cancellation.add_callback( lambda: provider.cancel_request(request_id) ) except Exception: cancel_token = None try: messages, native_messages, state, native_history_changed = ( self._pre_request( session, config, provider, features, tools, request_id=request_id, tags=_tags, models=self._get_models_for_config(provider, config), context=context, ) ) baseline_native_len = len(native_messages) request_context = self._build_request_context( config=state.get("config") or config, provider=provider, enabled_extensions=provider._iter_extensions(), enabled_features=features, enabled_tools=tools, tags=_tags, models=self._get_models_for_config(provider, config), available_tools=self.get_available_tools(config), tools=self._get_prepared_tools_for_config(config), session=session, request_id=request_id, stream=False, extra=context, ) logger.debug( "sending request", native_messages=str(native_messages)[:1000], request=str(state.get("request"))[:1000], ) _partials, final_native_1, native_messages, state = provider.call_api( native_messages, state, request_id=request_id, ) finally: if cancellation is not None and cancel_token is not None: try: cancellation.remove_callback(cancel_token) except Exception: pass # Provider finalize returns provider-native finals and full native history final_native_2, native_messages, state = provider.finalize( native_messages, state, context=request_context, ) # Combine provider-native finals from call_api and provider.finalize final_native = final_native_1 + final_native_2 # Feature finalize chain over provider-native finals (native cold path) for feature in features: final_native, native_messages, state = feature.finalize( final_native, native_messages, state, context=request_context, ) # Convert provider-native finals to core messages once finalization completes final_core = provider.from_native_messages( final_native, state, context=request_context, ) # Apply extension and feature from-native transforms (iterate to pass state to each) for ext in enabled_extensions: final_core = ext.from_native_messages( final_native, final_core, state, context=request_context, ) for feature in features: final_core = feature.from_native_messages( final_native, final_core, state, context=request_context, ) # Attach global native_indices for final core messages final_core = self._normalize_final_core_with_native_indices( final_core, final_native, baseline_native_len, ) final_core, native_messages, response_metadata_patch, _response_results = ( self.execute_response_lifecycle_actions( session, config, "response_finalize", provider=provider, features=features, state=state, final_messages=final_core, native_messages=native_messages, native_final_messages=final_native, stream=False, turn_native_start_index=baseline_native_len, request_context=request_context, extra_context=context, ) ) state_metadata_patch = ( state.get("session_metadata_patch") if isinstance(state.get("session_metadata_patch"), dict) else {} ) if state_metadata_patch: response_metadata_patch = { **state_metadata_patch, **(response_metadata_patch or {}), } # Build new session messages by appending new Message objects new_metadata = dict(session.metadata) if response_metadata_patch: new_metadata.update(response_metadata_patch) # Persist full provider-native history for the turn (implementation detail) new_metadata["native_messages"] = native_messages if native_history_changed: all_messages_after, _ = self._rebuild_core_from_native( native_messages, config, context=context, ) else: appended: List[Message] = [Message.from_dict(m) for m in final_core] all_messages_after = [*session.messages, *appended] new_metadata["native_messages_integrity"] = self._compute_native_integrity( all_messages_after ) new_session = Session(session.session_id, all_messages_after, new_metadata) logger.info( "send_request_complete", final_native=final_native, final_core=final_core, ) return new_session, final_core ``` ``` ### send_request_stream ``` send_request_stream( session, config, \*, request_id=None, cancellation=None, context=None ) ``` Stream request and yield partial events, then the final session. Uses provider streaming with optimized hot-path processing; features operate on the provider-native finals at finalize, then core converts once and appends to the session. Parameters: | Name | Type | Description | Default | | --- | --- | --- | --- | | `session` | `Session` | Current session. | *required* | | `config` | `Dict[str, Any]` | Application-provided configuration for this request. | *required* | Yields: | Type | Description | | --- | --- | | `Dict[str, Any]` | Event dictionaries: | | `Dict[str, Any]` | {"type": "partial", "message": core-like partial dict} | | `Dict[str, Any]` | {"type": "final", "session": new_session, "messages": final_core_messages} | Raises: | Type | Description | | --- | --- | | `RuntimeError` | If no provider is registered. | Source code in `core/python/agent_core/core.py` ``` def send_request_stream( self, session: Session, config: Dict[str, Any], \*, request_id: str | None = None, cancellation: Any | None = None, context: Optional\[Dict[str, Any]\] = None, ) -> Iterator\[Dict[str, Any]\]: """Stream request and yield partial events, then the final session. ``` Uses provider streaming with optimized hot-path processing; features operate on the provider-native finals at finalize, then core converts once and appends to the session. Args: session: Current session. config: Application-provided configuration for this request. Yields: Event dictionaries: - {"type": "partial", "message": core-like partial dict} - {"type": "final", "session": new_session, "messages": final_core_messages} Raises: RuntimeError: If no provider is registered. """ if not self._providers: raise RuntimeError("No provider registered") provider, enabled_extensions, features, tools, _tags = ( self._resolve_plugins_for_config(config) ) features = sorted(features, key=lambda feat: getattr(feat, "priority", 100)) logger.debug( "plugins_resolved_for_config", provider=provider.name, extensions=[ext.name for ext in enabled_extensions], features=[feat.name for feat in features], tools=[tool.name for tool in tools], tags=_tags, ) provider.set_active_extensions(enabled_extensions) cancel_token: Any | None = None if request_id and cancellation is not None: try: cancel_token = cancellation.add_callback( lambda: provider.cancel_request(request_id) ) except Exception: cancel_token = None try: messages, native_messages, state, native_history_changed = ( self._pre_request( session, config, provider, features, tools, request_id=request_id, tags=_tags, models=self._get_models_for_config(provider, config), context=context, ) ) baseline_native_len = len(native_messages) request_context = self._build_request_context( config=state.get("config") or config, provider=provider, enabled_extensions=provider._iter_extensions(), enabled_features=features, enabled_tools=tools, tags=_tags, models=self._get_models_for_config(provider, config), available_tools=self.get_available_tools(config), tools=self._get_prepared_tools_for_config(config), session=session, request_id=request_id, stream=True, extra=context, ) logger.debug( "start streaming request", native_messages=str(native_messages)[:1000], requrest=str(state.get("request", None))[:1000], ) aggregated_native_finals: List[Dict[str, Any]] = [] # Stream from provider using optimized streaming partial_count = 0 for event in provider.stream_messages( native_messages, state, request_id=request_id ): # logger.debug("streaming event received", _event=event) # the first positional argument is also named 'event', we cannot use that name if event["type"] == "partial": partial_count += 1 log_chunk_processing.debug( "partial message received", partial=event["message"] ) yield {"type": "partial", "message": event["message"]} elif event["type"] == "final_messages": # These are provider-native finals log_chunk_processing.debug( "final message received", final=event.get("messages") ) aggregated_native_finals.extend(event["messages"]) finally: if cancellation is not None and cancel_token is not None: try: cancellation.remove_callback(cancel_token) except Exception: pass # Finalize using the last accumulated native messages final_native, native_messages, state = provider.finalize( provider.get_last_native_messages(), provider.get_state(), context=request_context, ) combined_native_finals = [*aggregated_native_finals, *final_native] # Feature finalize chain (native) for feature in features: combined_native_finals, native_messages, state = feature.finalize( combined_native_finals, native_messages, state, context=request_context, ) logger.debug( "streaming complete", combined_native_finals=combined_native_finals ) # Convert provider-native finals to core messages final_core = provider.from_native_messages( combined_native_finals, state, context=request_context, ) # Apply extension and feature from-native transforms (iterate to pass state to each) for ext in enabled_extensions: final_core = ext.from_native_messages( combined_native_finals, final_core, state, context=request_context, ) for feature in features: final_core = feature.from_native_messages( combined_native_finals, final_core, state, context=request_context, ) # Attach global native_indices for final core messages final_core = self._normalize_final_core_with_native_indices( final_core, combined_native_finals, baseline_native_len, ) final_core, native_messages, response_metadata_patch, _response_results = ( self.execute_response_lifecycle_actions( session, config, "response_finalize", provider=provider, features=features, state=state, final_messages=final_core, native_messages=native_messages, native_final_messages=combined_native_finals, stream=True, turn_native_start_index=baseline_native_len, request_context=request_context, extra_context=context, ) ) state_metadata_patch = ( state.get("session_metadata_patch") if isinstance(state.get("session_metadata_patch"), dict) else {} ) if state_metadata_patch: response_metadata_patch = { **state_metadata_patch, **(response_metadata_patch or {}), } new_metadata = dict(session.metadata) if response_metadata_patch: new_metadata.update(response_metadata_patch) new_metadata["native_messages"] = native_messages if native_history_changed: # Rebuild core from the post-initialize native (after structural changes) all_messages_after, _ = self._rebuild_core_from_native( native_messages, config, context=context, ) else: appended: List[Message] = [Message.from_dict(m) for m in final_core] all_messages_after = [*session.messages, *appended] new_metadata["native_messages_integrity"] = self._compute_native_integrity( all_messages_after ) new_session = Session(session.session_id, all_messages_after, new_metadata) logger.debug("streaming request complete", final_message=final_core) yield {"type": "final", "session": new_session, "messages": final_core} ``` ``` ### send_request_stream_async ``` send_request_stream_async( session, config, \*, request_id=None, cancellation=None, context=None ) ``` Async version of send_request_stream. Parameters: | Name | Type | Description | Default | | --- | --- | --- | --- | | `session` | `Session` | Current session. | *required* | | `config` | `Dict[str, Any]` | Application-provided configuration for this request. | *required* | Yields: | Type | Description | | --- | --- | | `AsyncIterator[Dict[str, Any]]` | Event dictionaries analogous to send_request_stream, but from an async iterator. | Raises: | Type | Description | | --- | --- | | `RuntimeError` | If no provider is registered. | Source code in `core/python/agent_core/core.py` ``` async def send_request_stream_async( self, session: Session, config: Dict[str, Any], \*, request_id: str | None = None, cancellation: Any | None = None, context: Optional\[Dict[str, Any]\] = None, ) -> AsyncIterator\[Dict[str, Any]\]: """Async version of send_request_stream. ``` Args: session: Current session. config: Application-provided configuration for this request. Yields: Event dictionaries analogous to send_request_stream, but from an async iterator. Raises: RuntimeError: If no provider is registered. """ if not self._providers: raise RuntimeError("No provider registered") provider, enabled_extensions, features, tools, _tags = ( self._resolve_plugins_for_config(config) ) features = sorted(features, key=lambda feat: getattr(feat, "priority", 100)) logger.debug( "plugins_resolved_for_config", provider=provider.name, extensions=[ext.name for ext in enabled_extensions], features=[feat.name for feat in features], tools=[tool.name for tool in tools], tags=_tags, ) provider.set_active_extensions(enabled_extensions) cancel_token: Any | None = None if request_id and cancellation is not None: try: cancel_token = cancellation.add_callback( lambda: provider.cancel_request(request_id) ) except Exception: cancel_token = None try: messages, native_messages, state, native_history_changed = ( self._pre_request( session, config, provider, features, tools, request_id=request_id, tags=_tags, models=self._get_models_for_config(provider, config), context=context, ) ) baseline_native_len = len(native_messages) request_context = self._build_request_context( config=state.get("config") or config, provider=provider, enabled_extensions=provider._iter_extensions(), enabled_features=features, enabled_tools=tools, tags=_tags, models=self._get_models_for_config(provider, config), available_tools=self.get_available_tools(config), tools=self._get_prepared_tools_for_config(config), session=session, request_id=request_id, stream=True, extra=context, ) aggregated_native_finals: List[Dict[str, Any]] = [] # Stream from provider (async) using optimized streaming async for event in provider.stream_messages_async( native_messages, state, request_id=request_id, ): if event["type"] == "partial": yield {"type": "partial", "message": event["message"]} elif event["type"] == "final_messages": aggregated_native_finals.extend(event["messages"]) # native finals finally: if cancellation is not None and cancel_token is not None: try: cancellation.remove_callback(cancel_token) except Exception: pass final_native, native_messages, state = provider.finalize( provider.get_last_native_messages(), provider.get_state(), context=request_context, ) combined_native_finals = [*aggregated_native_finals, *final_native] # Feature finalize chain (native) for feature in features: combined_native_finals, native_messages, state = feature.finalize( combined_native_finals, native_messages, state, context=request_context, ) # Convert provider-native finals to core messages final_core = provider.from_native_messages( combined_native_finals, state, context=request_context, ) # Apply extension and feature from-native transforms (iterate to pass state to each) for ext in enabled_extensions: final_core = ext.from_native_messages( combined_native_finals, final_core, state, context=request_context, ) for feature in features: final_core = feature.from_native_messages( combined_native_finals, final_core, state, context=request_context, ) # Attach global native_indices for final core messages final_core = self._normalize_final_core_with_native_indices( final_core, combined_native_finals, baseline_native_len, ) final_core, native_messages, response_metadata_patch, _response_results = ( self.execute_response_lifecycle_actions( session, config, "response_finalize", provider=provider, features=features, state=state, final_messages=final_core, native_messages=native_messages, native_final_messages=combined_native_finals, stream=True, turn_native_start_index=baseline_native_len, request_context=request_context, extra_context=context, ) ) state_metadata_patch = ( state.get("session_metadata_patch") if isinstance(state.get("session_metadata_patch"), dict) else {} ) if state_metadata_patch: response_metadata_patch = { **state_metadata_patch, **(response_metadata_patch or {}), } new_metadata = dict(session.metadata) if response_metadata_patch: new_metadata.update(response_metadata_patch) new_metadata["native_messages"] = native_messages if native_history_changed: # Rebuild core from the post-initialize native (after structural changes) all_messages_after, _ = self._rebuild_core_from_native( native_messages, config, context=context, ) else: appended: List[Message] = [Message.from_dict(m) for m in final_core] all_messages_after = [*session.messages, *appended] new_metadata["native_messages_integrity"] = self._compute_native_integrity( all_messages_after ) new_session = Session(session.session_id, all_messages_after, new_metadata) yield {"type": "final", "session": new_session, "messages": final_core} ``` ``` ### slice_session ``` slice_session( session, config=None, \*, start=None, end=None, remove_indices=None, return_removed=False ) ``` Return a new session with messages sliced/removed. Operations are expressed in core message indices but, when possible, provider-native history is adjusted consistently and preserved. Source code in `core/python/agent_core/core.py` ``` def slice_session( self, session: Session, config: Optional\[Dict[str, Any]\] = None, \*, start: Optional[int] = None, end: Optional[int] = None, remove_indices: Optional\[List[int]\] = None, return_removed: bool = False, ) -> Session: """Return a new session with messages sliced/removed. ``` Operations are expressed in core message indices but, when possible, provider-native history is adjusted consistently and preserved. """ messages = session.messages message_count = len(messages) all_indices: Set[int] = set(range(message_count)) def _normalize_bound(bound: Optional[int], default: int) -> int: if bound is None: return default value = bound if value < 0: value = message_count + value if value < 0: return 0 if value > message_count: return message_count return value start_idx = _normalize_bound(start, 0) end_idx = _normalize_bound(end, message_count) range_indices: Optional[Set[int]] = None if start is not None or end is not None: if start_idx >= end_idx: range_indices = set() else: range_indices = set(range(start_idx, end_idx)) remove_set: Set[int] = set() if remove_indices: for idx in remove_indices: if not isinstance(idx, int): continue value = idx if value < 0: value = message_count + value if 0 <= value < message_count: remove_set.add(value) if range_indices is not None: keep_set: Set[int] = range_indices.difference(remove_set) else: keep_set = all_indices.difference(remove_set) remove_set: Set[int] = all_indices.difference(keep_set) if keep_set == all_indices: if not return_removed: return session # No messages removed; return an empty complement session that # mirrors the original metadata shape but without native history. empty = self._slice_session_core_only(session, set()) return (session, empty) # type: ignore[return-value] if ( config is None or not isinstance(session.metadata, dict) or len(self._providers) != 1 ): kept = self._slice_session_core_only(session, keep_set) if not return_removed: return kept removed = self._slice_session_core_only(session, remove_set) return (kept, removed) # type: ignore[return-value] original_native = session.metadata.get("native_messages") if not isinstance(original_native, list): kept = self._slice_session_core_only(session, keep_set) if not return_removed: return kept removed = self._slice_session_core_only(session, remove_set) return (kept, removed) # type: ignore[return-value] try: self._verify_native_integrity(session) except Exception: kept = self._slice_session_core_only(session, keep_set) if not return_removed: return kept removed = self._slice_session_core_only(session, remove_set) return (kept, removed) # type: ignore[return-value] native_len = len(original_native) core_to_native: Dict[int, List[int]] = {} native_to_core: Dict[int, Set[int]] = {} for idx, msg in enumerate(messages): md = msg.metadata if isinstance(msg.metadata, dict) else {} native_indices = md.get("native_indices") if native_indices is None: continue if not isinstance(native_indices, list) or not all( isinstance(j, int) for j in native_indices ): kept = self._slice_session_core_only(session, keep_set) if not return_removed: return kept removed = self._slice_session_core_only(session, remove_set) return (kept, removed) # type: ignore[return-value] normalized_indices: List[int] = [] for j in native_indices: if j < 0 or j >= native_len: kept = self._slice_session_core_only(session, keep_set) if not return_removed: return kept removed = self._slice_session_core_only(session, remove_set) return (kept, removed) # type: ignore[return-value] if j not in normalized_indices: normalized_indices.append(j) if not normalized_indices: continue core_to_native[idx] = normalized_indices for j in normalized_indices: owners = native_to_core.get(j) if owners is None: native_to_core[j] = {idx} else: owners.add(idx) for idx in keep_set: if idx not in core_to_native: kept = self._slice_session_core_only(session, keep_set) if not return_removed: return kept removed = self._slice_session_core_only(session, remove_set) return (kept, removed) # type: ignore[return-value] # Build native subsets for kept and removed halves in one pass so we # can support split-like operations without duplicating logic. native_candidates_keep: Set[int] = set() native_candidates_remove: Set[int] = set() for idx in keep_set: for j in core_to_native.get(idx, []): native_candidates_keep.add(j) for idx in remove_set: for j in core_to_native.get(idx, []): native_candidates_remove.add(j) if not native_candidates_keep: new_native_keep: List[Dict[str, Any]] = [] else: final_native_indices_keep = { j for j in native_candidates_keep if native_to_core.get(j, set()).issubset(keep_set) } sorted_keep = sorted(final_native_indices_keep) new_native_keep = [original_native[j] for j in sorted_keep] if not native_candidates_remove: new_native_remove: List[Dict[str, Any]] = [] else: final_native_indices_remove = { j for j in native_candidates_remove if native_to_core.get(j, set()).issubset(remove_set) } sorted_remove = sorted(final_native_indices_remove) new_native_remove = [original_native[j] for j in sorted_remove] try: # Skip initialize_request for session split operations. # This prevents features like SystemMessageFeature from adding # synthetic messages to partial sessions during split, which # would cause duplicates when the sessions are joined back. new_messages_keep, post_init_keep = self._rebuild_core_from_native( new_native_keep, config, skip_initialize=True ) if new_native_remove: new_messages_remove, post_init_remove = self._rebuild_core_from_native( new_native_remove, config, skip_initialize=True ) else: new_messages_remove = [] post_init_remove = [] except Exception: kept = self._slice_session_core_only(session, keep_set) if not return_removed: return kept removed = self._slice_session_core_only(session, remove_set) return (kept, removed) # type: ignore[return-value] new_metadata_keep = dict(session.metadata) new_metadata_keep["native_messages"] = post_init_keep new_metadata_keep["native_messages_integrity"] = self._compute_native_integrity( new_messages_keep ) kept_session = Session( session_id=session.session_id, messages=new_messages_keep, metadata=new_metadata_keep, ) if not return_removed: return kept_session new_metadata_remove = dict(session.metadata) new_metadata_remove["native_messages"] = post_init_remove new_metadata_remove["native_messages_integrity"] = ( self._compute_native_integrity(new_messages_remove) ) removed_session = Session( session_id=session.session_id, messages=new_messages_remove, metadata=new_metadata_remove, ) return (kept_session, removed_session) # type: ignore[return-value] ``` ``` ### stream_tool_call ``` stream_tool_call(tool_call, config, \*, context=None) ``` Stream partial results for a single tool call. Uses the same tool resolution logic as :meth:`execute_tool_calls` but invokes the optional :meth:`ToolPlugin.stream_tool` hook instead of the synchronous execute/format path. If no tool provides streaming for the given call, yields nothing. Parameters: | Name | Type | Description | Default | | --- | --- | --- | --- | | `tool_call` | `Dict[str, Any]` | Tool call dictionary to execute. | *required* | | `config` | `Dict[str, Any]` | Current resolved request configuration. | *required* | | `context` | `Optional[Dict[str, Any]]` | Optional context for tool execution. Layer 1 context (core, config, trigger_source) is enriched by this method if not already present. | `None` | Source code in `core/python/agent_core/core.py` ``` def stream_tool_call( self, tool_call: Dict[str, Any], config: Dict[str, Any], \*, context: Optional\[Dict[str, Any]\] = None, ) -> Iterator\[Dict[str, Any]\]: """Stream partial results for a single tool call. ``` Uses the same tool resolution logic as :meth:`execute_tool_calls` but invokes the optional :meth:`ToolPlugin.stream_tool` hook instead of the synchronous execute/format path. If no tool provides streaming for the given call, yields nothing. Args: tool_call: Tool call dictionary to execute. config: Current resolved request configuration. context: Optional context for tool execution. Layer 1 context (core, config, trigger_source) is enriched by this method if not already present. """ if not self._tools: return provider, extensions, tool_states = self._get_enabled_tool_states_for_config( config ) registry = self._get_tool_interop_registry_for_config( config, provider=provider, extensions=extensions, tool_states=tool_states, ) try: inspected_call = registry.inspect_call(tool_call) except Exception: return tool, state, prepared, _schema = self._resolve_tool_handler_for_call( inspected_call, tool_states, registry, ) if tool is None or state is None: return # Layer 1: Core-level context enrichment. tool_context = self._build_tool_runtime_context(config, context) try: iterator = tool.stream_tool( inspected_call.tool_name, inspected_call.payload, state, payload_kind=inspected_call.payload_kind, payload_format=inspected_call.payload_format, payload_metadata=inspected_call.payload_metadata, tool_call=inspected_call.raw, context=tool_context, prepared=prepared, ) iterator = iter(iterator) except Exception: return for chunk in iterator: if isinstance(chunk, dict): yield chunk ``` ``` ``` # Types and Protocols Core data types for the AI Agent Platform. This module defines immutable data structures following functional programming principles. All transformations return new instances rather than modifying existing ones. Examples: Creating immutable messages and sessions: ``` from agent_core.types import Message, Session msg = Message(role="user", content="Hello") session = Session(session_id="s1", messages=[msg]) data = session.to_dict() restored = Session.from_dict(data) ``` ## BasePlugin Bases: `Protocol` Common instance-level protocol for non-tool plugins. Applies to Provider, ProviderExtension, and Feature plugins. Unifies identity, configuration UI, and lifecycle method signatures that are identical across these plugin types. Methods should remain stateless and operate on explicit state parameters; plugin instances are short‑lived per request. ### get_config_schema ``` get_config_schema() ``` Return JSON schema describing this plugin's configuration. Returns: | Type | Description | | ---------------- | ----------------------- | | `Dict[str, Any]` | JSON schema dictionary. | Source code in `core/python/agent_core/types.py` ``` def get_config_schema(self) -> Dict[str, Any]: """Return JSON schema describing this plugin's configuration. Returns: JSON schema dictionary. """ return {} ``` ### get_ui_elements ``` get_ui_elements(config, context=None) ``` Return UI element definitions contributed by this plugin. The returned list describes configuration and display-oriented elements that applications can use to build UIs. Each element is a plain dictionary; the core treats these dictionaries as opaque and flattens them via :meth:`AgentCore.get_ui_schema`. Parameters: | Name | Type | Description | Default | | --------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------- | | `config` | `Dict[str, Any]` | Effective configuration for the current agent/request. | *required* | | `context` | `Optional[Dict[str, Any]]` | Optional derived context for schema generation. The core provides keys such as tags, models, available_tools, and tools here. | `None` | Note The canonical hook shape is `(config, context=None)`. For backward compatibility, adapters also accept legacy plugin implementations that define `get_ui_elements(self)`, `get_ui_elements(self, config)`, `get_ui_elements(self, config, tags, models)`, or `get_ui_elements(self, config, tags, models, context)`. Recommended element shapes: - **Configuration elements** (default `ui_type == "config"`): Used to describe editable settings for the plugin. Typical keys are:: ``` { "type": "checkbox" | "text" | "number" | "select" | ..., "key": "config_key", # stable configuration key "label": "Human label", # short caption for UIs "description": "Longer help text", # optional "options": [ # for select-like fields "value" | {"value": "v", "label": "Display"}, ], ... # plugin-specific extras are allowed } ``` When `"ui_type"` is missing or falsy, the core normalizes the element to `"config"` and will de-duplicate on `"key"` when flattening the global UI schema. - **Message footer elements** (`ui_type == "message_footer"`): Used by some applications (for example, the terminal client) to render a compact footer line under individual messages. A common shape is:: ``` { "ui_type": "message_footer", "data": "metadata.timestamp", # dotted JSON path "template": "{{data}}", # optional; defaults vary ... } ``` `"data"` is interpreted as a dotted path into the core message dictionary (e.g. `"metadata.timestamp"`). `"template"` is a simple string where `"{{data}}"` is replaced with the resolved value, but individual applications are free to use different conventions. - **Status bar elements** (`ui_type == "status_bar"`): Similar to `"message_footer"` but intended for persistent status bars (for example, values derived from the last assistant message):: ``` { "ui_type": "status_bar", "data": "metadata.total_cost", "template": "Total: {{data}}", ... } ``` The core will attach a `"plugin"` field (when absent) naming the contributing plugin class when it flattens UI elements. Plugins may include additional keys beyond those shown above; callers should treat the returned element dictionaries as immutable. Parameters: | Name | Type | Description | Default | | --------- | -------------------------- | ------------------------------------------------------ | ---------- | | `config` | `Dict[str, Any]` | Effective configuration for the current agent/request. | *required* | | `context` | `Optional[Dict[str, Any]]` | Optional derived context for schema generation. | `None` | Returns: | Type | Description | | ---------------------- | -------------------------------- | | `List[Dict[str, Any]]` | List of UI element dictionaries. | Source code in `core/python/agent_core/types.py` ``` def get_ui_elements( self, config: Dict[str, Any], context: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: """Return UI element definitions contributed by this plugin. The returned list describes configuration and display-oriented elements that applications can use to build UIs. Each element is a plain dictionary; the core treats these dictionaries as opaque and flattens them via :meth:`AgentCore.get_ui_schema`. Args: config: Effective configuration for the current agent/request. context: Optional derived context for schema generation. The core provides keys such as `tags`, `models`, `available_tools`, and `tools` here. Note: The canonical hook shape is `(config, context=None)`. For backward compatibility, adapters also accept legacy plugin implementations that define `get_ui_elements(self)`, `get_ui_elements(self, config)`, `get_ui_elements(self, config, tags, models)`, or `get_ui_elements(self, config, tags, models, context)`. Recommended element shapes: * **Configuration elements** (default ``ui_type == "config"``): Used to describe editable settings for the plugin. Typical keys are:: { "type": "checkbox" | "text" | "number" | "select" | ..., "key": "config_key", # stable configuration key "label": "Human label", # short caption for UIs "description": "Longer help text", # optional "options": [ # for select-like fields "value" | {"value": "v", "label": "Display"}, ], ... # plugin-specific extras are allowed } When ``"ui_type"`` is missing or falsy, the core normalizes the element to ``"config"`` and will de-duplicate on ``"key"`` when flattening the global UI schema. * **Message footer elements** (``ui_type == "message_footer"``): Used by some applications (for example, the terminal client) to render a compact footer line under individual messages. A common shape is:: { "ui_type": "message_footer", "data": "metadata.timestamp", # dotted JSON path "template": "{{data}}", # optional; defaults vary ... } ``"data"`` is interpreted as a dotted path into the core message dictionary (e.g. ``"metadata.timestamp"``). ``"template"`` is a simple string where ``"{{data}}"`` is replaced with the resolved value, but individual applications are free to use different conventions. * **Status bar elements** (``ui_type == "status_bar"``): Similar to ``"message_footer"`` but intended for persistent status bars (for example, values derived from the last assistant message):: { "ui_type": "status_bar", "data": "metadata.total_cost", "template": "Total: {{data}}", ... } The core will attach a ``"plugin"`` field (when absent) naming the contributing plugin class when it flattens UI elements. Plugins may include additional keys beyond those shown above; callers should treat the returned element dictionaries as immutable. Args: config: Effective configuration for the current agent/request. context: Optional derived context for schema generation. Returns: List of UI element dictionaries. """ return [] ``` ## FeaturePlugin Bases: `BasePlugin`, `Protocol` Protocol for feature plugins (cold path; shared provider state). Features operate on the cold path only (no per-chunk processing). They can transform both provider-native messages and core messages during finalize and may update the shared provider state during init and initialize_request. Attributes: | Name | Type | Description | | ---------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `priority` | | Optional execution priority (default: 100). Lower numbers run first. Features are sorted by priority within the feature execution chain. | Example Minimal feature that annotates finals: ``` class TagFeature: name = "tag" version = "1.0.0" priority = 100 # Optional: default priority def get_config_schema(self): return {"enabled": {"type": "boolean", "default": True}} def init(self, config, state): return state def to_native_messages(self, messages, native_messages): return native_messages def from_native_messages(self, native_messages, messages): return messages def initialize_request(self, native_messages, state): return native_messages, state def finalize(self, final_messages, native_messages, state): finals = [{**m, "metadata": {**m.get("metadata", {}), "tag": True}} for m in final_messages] return finals, native_messages, state ``` ### apply_completion ``` apply_completion(config, text, completion) ``` Optionally transform an accepted completion into a final snippet. Applications may call this hook when a user accepts one of the completion entries previously returned by :meth:`get_completions`. The default implementation returns an empty string, indicating that the feature does not wish to modify the completion. Parameters: | Name | Type | Description | Default | | ------------ | ---------------- | ---------------------------------------------------------------------------------------------------- | ---------- | | `config` | `Dict[str, Any]` | Application-supplied configuration for the current agent or request. | *required* | | `text` | `str` | Full buffer text after the completion has been applied (for example, input containing "@file:path"). | *required* | | `completion` | `Dict[str, Any]` | The completion descriptor dict that was accepted, as previously returned by :meth:get_completions. | *required* | Returns: | Type | Description | | ----- | --------------------------------------------------------------- | | `str` | A replacement string to insert into the buffer in place of the | | `str` | completion text, or an empty string/falsey value to indicate no | | `str` | special handling. | Source code in `core/python/agent_core/types.py` ``` def apply_completion( self, config: Dict[str, Any], text: str, completion: Dict[str, Any], ) -> str: """Optionally transform an accepted completion into a final snippet. Applications may call this hook when a user accepts one of the completion entries previously returned by :meth:`get_completions`. The default implementation returns an empty string, indicating that the feature does not wish to modify the completion. Args: config: Application-supplied configuration for the current agent or request. text: Full buffer text after the completion has been applied (for example, input containing ``"@file:path"``). completion: The completion descriptor dict that was accepted, as previously returned by :meth:`get_completions`. Returns: A replacement string to insert into the buffer in place of the completion text, or an empty string/falsey value to indicate no special handling. """ return "" ``` ### execute_action ``` execute_action( action_id, session, native_messages, params, context, state, ) ``` Execute a session-scoped feature action. The supported return contract matches provider-extension actions: features may return replacement `native_messages` and/or a `session_metadata` mapping that is merged into the session. For the core-owned `response_finalize` lifecycle, features may also return `final_messages` to replace the current turn's final core messages before they are emitted or appended to the session. The `context` parameter provides layered access to runtime capabilities: **Layer 1 (Core - always present):** - `core`: AgentCore instance for session/message operations - `config`: Current resolved request configuration - `trigger_source`: Where the action was triggered ("core", "application") - `session`: Serialized session dict (session.to_dict()) - `lifecycle`: Lifecycle trigger name (for lifecycle-triggered actions) - `final_messages`: Current turn final core messages for `response_finalize` - `native_final_messages`: Provider-native finals for the current turn for `response_finalize` - `native_messages`: Full provider-native history for `response_finalize` - `stream`: Whether the request is streaming for `response_finalize` - `turn_native_start_index`: Native-history starting offset for the current turn for `response_finalize` **Layer 2 (Application - when called from AgentApplication):** - `app` and `application`: AgentApplication instance - `base_config`: Full base configuration (all agents) - `session_asset_store`: Session asset store for file attachments - `runtime_state_root`: Persistent runtime-state root directory **Layer 3 (Terminal/Server - implementation-specific):** - Implementation-specific keys added by terminal or HTTP server Caller-supplied context is additive only. Reserved keys owned by the context builders are not overridden; on clashes the builder keeps the owned value and emits a warning. Features can check for enhanced capabilities using `context.get("app")`. Source code in `core/python/agent_core/types.py` ``` def execute_action( self, action_id: str, session: "Session", native_messages: List[Dict[str, Any]], params: Dict[str, Any], context: Optional[Dict[str, Any]], state: Dict[str, Any], ) -> Dict[str, Any]: """Execute a session-scoped feature action. The supported return contract matches provider-extension actions: features may return replacement ``native_messages`` and/or a ``session_metadata`` mapping that is merged into the session. For the core-owned ``response_finalize`` lifecycle, features may also return ``final_messages`` to replace the current turn's final core messages before they are emitted or appended to the session. The ``context`` parameter provides layered access to runtime capabilities: **Layer 1 (Core - always present):** - ``core``: AgentCore instance for session/message operations - ``config``: Current resolved request configuration - ``trigger_source``: Where the action was triggered ("core", "application") - ``session``: Serialized session dict (session.to_dict()) - ``lifecycle``: Lifecycle trigger name (for lifecycle-triggered actions) - ``final_messages``: Current turn final core messages for ``response_finalize`` - ``native_final_messages``: Provider-native finals for the current turn for ``response_finalize`` - ``native_messages``: Full provider-native history for ``response_finalize`` - ``stream``: Whether the request is streaming for ``response_finalize`` - ``turn_native_start_index``: Native-history starting offset for the current turn for ``response_finalize`` **Layer 2 (Application - when called from AgentApplication):** - ``app`` and ``application``: AgentApplication instance - ``base_config``: Full base configuration (all agents) - ``session_asset_store``: Session asset store for file attachments - ``runtime_state_root``: Persistent runtime-state root directory **Layer 3 (Terminal/Server - implementation-specific):** - Implementation-specific keys added by terminal or HTTP server Caller-supplied context is additive only. Reserved keys owned by the context builders are not overridden; on clashes the builder keeps the owned value and emits a warning. Features can check for enhanced capabilities using ``context.get("app")``. """ return {"native_messages": list(native_messages)} ``` ### finalize ``` finalize( final_messages, native_messages, state, *, context=None ) ``` Final cold-path processing after provider and extensions complete. Parameters: | Name | Type | Description | Default | | ----------------- | -------------------------- | ---------------------------------------------------------- | ---------- | | `final_messages` | `List[Dict[str, Any]]` | Provider-native final messages emitted this turn (deltas). | *required* | | `native_messages` | `List[Dict[str, Any]]` | Full provider-native message history for this turn. | *required* | | `state` | `Dict[str, Any]` | Current shared provider state. | *required* | | `context` | `Optional[Dict[str, Any]]` | Optional request context for this turn. | `None` | Returns: | Type | Description | | ------------------------------------------------------------------- | --------------------------------------------------------------------------- | | `Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]` | Tuple of (final_messages, native_messages, new_state), all provider-native. | Source code in `core/python/agent_core/types.py` ``` def finalize( self, final_messages: List[Dict[str, Any]], native_messages: List[Dict[str, Any]], state: Dict[str, Any], *, context: Optional[Dict[str, Any]] = None, ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]: """Final cold-path processing after provider and extensions complete. Args: final_messages: Provider-native final messages emitted this turn (deltas). native_messages: Full provider-native message history for this turn. state: Current shared provider state. context: Optional request context for this turn. Returns: Tuple of (final_messages, native_messages, new_state), all provider-native. """ return final_messages, native_messages, state ``` ### forbidden_tags ``` forbidden_tags() ``` Tags that must NOT be present for this feature to be enabled. If any of these tags are in the available tag set, the feature is disabled. This provides a negative constraint complementary to required_tags. Source code in `core/python/agent_core/types.py` ``` def forbidden_tags(self) -> List[str]: """Tags that must NOT be present for this feature to be enabled. If any of these tags are in the available tag set, the feature is disabled. This provides a negative constraint complementary to required_tags. """ return [] ``` ### from_native_messages ``` from_native_messages( native_messages, messages, state=None, *, context=None ) ``` Transform provider-native messages back into core messages. Note In the default `AgentCore` request flow, feature transforms run after the provider and provider extension `from_native_messages` transforms. Parameters: | Name | Type | Description | Default | | ----------------- | ---------------------- | -------------------------------------- | --------------------------------------------------------------------------- | | `native_messages` | `List[Dict[str, Any]]` | Provider-native messages to transform. | *required* | | `messages` | `List[Dict[str, Any]]` | Core session messages for context. | *required* | | `state` | \`Dict[str, Any] | None\` | Optional shared provider state (available when called via ProviderWrapper). | Returns: | Type | Description | | ---------------------- | ---------------------- | | `List[Dict[str, Any]]` | Updated core messages. | Source code in `core/python/agent_core/types.py` ``` def from_native_messages( self, native_messages: List[Dict[str, Any]], messages: List[Dict[str, Any]], state: Dict[str, Any] | None = None, *, context: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: """Transform provider-native messages back into core messages. Note: In the default ``AgentCore`` request flow, feature transforms run after the provider and provider extension ``from_native_messages`` transforms. Args: native_messages: Provider-native messages to transform. messages: Core session messages for context. state: Optional shared provider state (available when called via ProviderWrapper). Returns: Updated core messages. """ return list(messages) ``` ### get_actions ``` get_actions(state) ``` Return session-scoped action definitions for this feature. Feature actions follow the same session-scoped shape as provider extension actions. They are optional and are primarily used for lifecycle-triggered single-session mutations. Source code in `core/python/agent_core/types.py` ``` def get_actions(self, state: Dict[str, Any]) -> List[Dict[str, Any]]: """Return session-scoped action definitions for this feature. Feature actions follow the same session-scoped shape as provider extension actions. They are optional and are primarily used for lifecycle-triggered single-session mutations. """ return [] ``` ### get_completions ``` get_completions(config, text) ``` Return completion suggestions for the given input text. Each completion entry is a dictionary whose structure is application-defined. A common shape is:: ``` { "replacement": "text to insert", "start": 5, # 0-based index in input text "display": "label shown in UI", "display_meta": "extra description", } ``` Implementations may ignore `text` when not relevant and should return an empty list when no completions apply. Parameters: | Name | Type | Description | Default | | -------- | ---------------- | ------------------------------------------------------------------------------------------ | ---------- | | `config` | `Dict[str, Any]` | Application-supplied configuration for the current agent or request. | *required* | | `text` | `str` | Full text before the cursor (for example, the contents of an input line in a terminal UI). | *required* | Returns: | Type | Description | | ---------------------- | ------------------------------------------- | | `List[Dict[str, Any]]` | List of completion descriptor dictionaries. | Source code in `core/python/agent_core/types.py` ``` def get_completions( self, config: Dict[str, Any], text: str, ) -> List[Dict[str, Any]]: """Return completion suggestions for the given input text. Each completion entry is a dictionary whose structure is application-defined. A common shape is:: { "replacement": "text to insert", "start": 5, # 0-based index in input text "display": "label shown in UI", "display_meta": "extra description", } Implementations may ignore ``text`` when not relevant and should return an empty list when no completions apply. Args: config: Application-supplied configuration for the current agent or request. text: Full text before the cursor (for example, the contents of an input line in a terminal UI). Returns: List of completion descriptor dictionaries. """ return [] ``` ### get_models ``` get_models(config, models) ``` Statelessly transform or provide model descriptors. Note In the default `AgentCore` request flow, model discovery starts with the provider's `get_models` hook, then passes through provider extensions, and finally through feature `get_models` hooks. Source code in `core/python/agent_core/types.py` ``` def get_models( self, config: Dict[str, Any], models: List[Dict[str, Any]], ) -> List[Dict[str, Any]]: """Statelessly transform or provide model descriptors. Note: In the default ``AgentCore`` request flow, model discovery starts with the provider's ``get_models`` hook, then passes through provider extensions, and finally through feature ``get_models`` hooks. """ return list(models) ``` ### get_tags ``` get_tags(config, models) ``` Return capability/environment tags contributed by this feature. Note In the default `AgentCore` dependency-resolution flow, these tags are aggregated with tags from the provider, provider extensions, and tools to decide which plugins are enabled. Source code in `core/python/agent_core/types.py` ``` def get_tags( self, config: Dict[str, Any], models: List[Dict[str, Any]], ) -> List[str]: """Return capability/environment tags contributed by this feature. Note: In the default ``AgentCore`` dependency-resolution flow, these tags are aggregated with tags from the provider, provider extensions, and tools to decide which plugins are enabled. """ return [] ``` ### get_template ``` get_template(config) ``` Return a template string used by this feature. This hook is intended for applications that need a feature-specific textual template (for example, to construct editor snippets or autocomplete expansions). Implementations may return an empty string when no template is applicable. Parameters: | Name | Type | Description | Default | | -------- | ---------------- | -------------------------------------------------------------------- | ---------- | | `config` | `Dict[str, Any]` | Application-supplied configuration for the current agent or request. | *required* | Returns: | Type | Description | | ----- | ----------------------------------------------------------- | | `str` | Template string, or an empty string if the feature does not | | `str` | expose a template. | Source code in `core/python/agent_core/types.py` ``` def get_template(self, config: Dict[str, Any]) -> str: """Return a template string used by this feature. This hook is intended for applications that need a feature-specific textual template (for example, to construct editor snippets or autocomplete expansions). Implementations may return an empty string when no template is applicable. Args: config: Application-supplied configuration for the current agent or request. Returns: Template string, or an empty string if the feature does not expose a template. """ return "" ``` ### init ``` init(config, state) ``` Initialize with application config and shared provider state. Note In the default `AgentCore` request flow, feature init hooks run after the provider (and any active provider extensions) have initialized. Parameters: | Name | Type | Description | Default | | -------- | ---------------- | ----------------------------------- | ---------- | | `config` | `Dict[str, Any]` | Application-supplied configuration. | *required* | | `state` | `Dict[str, Any]` | Current shared provider state. | *required* | Returns: | Type | Description | | ---------------- | -------------------------------------------------- | | `Dict[str, Any]` | Updated shared provider state (or the same state). | Source code in `core/python/agent_core/types.py` ``` def init(self, config: Dict[str, Any], state: Dict[str, Any]) -> Dict[str, Any]: """Initialize with application config and shared provider state. Note: In the default ``AgentCore`` request flow, feature init hooks run after the provider (and any active provider extensions) have initialized. Args: config: Application-supplied configuration. state: Current shared provider state. Returns: Updated shared provider state (or the same state). """ return state ``` ### initialize_request ``` initialize_request(native_messages, state, *, context=None) ``` Prepare for a new request with provider-native messages. Note In the default `AgentCore` request flow, feature hooks run after the provider (and any provider extensions) have run their `initialize_request` hooks. Parameters: | Name | Type | Description | Default | | ----------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | `native_messages` | `List[Dict[str, Any]]` | Provider-native messages for this request. | *required* | | `state` | `Dict[str, Any]` | Current shared provider state. | *required* | | `context` | `Optional[Dict[str, Any]]` | Optional request context. The core provides keys such as core, config, request_id, tags, models, available_tools, tools, and tool_interop here. | `None` | Returns: | Type | Description | | ---------------------- | ---------------------------------------------------------------------------- | | `List[Dict[str, Any]]` | Tuple of (provider-native messages for this request, updated shared state). | | `Dict[str, Any]` | Implementations MAY return the same native_messages when only state changes. | Source code in `core/python/agent_core/types.py` ``` def initialize_request( self, native_messages: List[Dict[str, Any]], state: Dict[str, Any], *, context: Optional[Dict[str, Any]] = None, ) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: """Prepare for a new request with provider-native messages. Note: In the default ``AgentCore`` request flow, feature hooks run after the provider (and any provider extensions) have run their ``initialize_request`` hooks. Args: native_messages: Provider-native messages for this request. state: Current shared provider state. context: Optional request context. The core provides keys such as `core`, `config`, `request_id`, `tags`, `models`, `available_tools`, `tools`, and `tool_interop` here. Returns: Tuple of (provider-native messages for this request, updated shared state). Implementations MAY return the same native_messages when only state changes. """ return native_messages, state ``` ### is_enabled ``` is_enabled(config, tags, models, context) ``` Return whether this feature should be enabled given the current context. This method allows features to implement complex enablement logic that goes beyond simple tag matching. It receives the full runtime context including configuration, computed tags, model descriptors, and information about other enabled plugins. Parameters: | Name | Type | Description | Default | | --------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | `config` | `Dict[str, Any]` | Effective configuration for the current request. | *required* | | `tags` | `List[str]` | Capability tags computed for this config. | *required* | | `models` | `List[Dict[str, Any]]` | Model descriptors computed for this config. | *required* | | `context` | `Dict[str, Any]` | Additional context including: - "enabled_plugin_ids": dict mapping plugin kind to list of enabled plugin ids - "disabled_plugins": config-level disabled plugin id list - "force_enabled_plugins": config-level force-enabled plugin id list | *required* | Returns: | Name | Type | Description | | ------- | ---------------- | -------------------------------------------------------------- | | `True` | `Optional[bool]` | feature is enabled (bypass required_tags/forbidden_tags check) | | `False` | `Optional[bool]` | feature is disabled | | `None` | `Optional[bool]` | fall back to required_tags/forbidden_tags check (default) | Source code in `core/python/agent_core/types.py` ``` def is_enabled( self, config: Dict[str, Any], tags: List[str], models: List[Dict[str, Any]], context: Dict[str, Any], ) -> Optional[bool]: """Return whether this feature should be enabled given the current context. This method allows features to implement complex enablement logic that goes beyond simple tag matching. It receives the full runtime context including configuration, computed tags, model descriptors, and information about other enabled plugins. Args: config: Effective configuration for the current request. tags: Capability tags computed for this config. models: Model descriptors computed for this config. context: Additional context including: - "enabled_plugin_ids": dict mapping plugin kind to list of enabled plugin ids - "disabled_plugins": config-level disabled plugin id list - "force_enabled_plugins": config-level force-enabled plugin id list Returns: True: feature is enabled (bypass required_tags/forbidden_tags check) False: feature is disabled None: fall back to required_tags/forbidden_tags check (default) """ return None ``` ### prepare_tools ``` prepare_tools(config, tools, context=None) ``` Filter or transform the available tool catalog for a request. This hook runs before provider/extensions/features `init` hooks for the request so that the effective tool list can be threaded through the compatibility shim at `config["tools"]`. Parameters: | Name | Type | Description | Default | | --------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------- | ---------- | | `config` | `Dict[str, Any]` | Effective configuration for this request, including any UI-driven tool gating selections. | *required* | | `tools` | `List[ToolDescriptor]` | Normalized tool catalog for this request before policy filtering. | *required* | | `context` | `Optional[Dict[str, Any]]` | Request-preparation context. The core provides keys such as provider, tags, models, available_tools, and tools here. | `None` | Returns: | Type | Description | | ---------------------- | ---------------------------------------------------- | | `List[ToolDescriptor]` | The effective tool descriptor list for this request. | Source code in `core/python/agent_core/types.py` ``` def prepare_tools( self, config: Dict[str, Any], tools: List["ToolDescriptor"], context: Optional[Dict[str, Any]] = None, ) -> List["ToolDescriptor"]: """Filter or transform the available tool catalog for a request. This hook runs before provider/extensions/features ``init`` hooks for the request so that the effective tool list can be threaded through the compatibility shim at `config["tools"]`. Args: config: Effective configuration for this request, including any UI-driven tool gating selections. tools: Normalized tool catalog for this request before policy filtering. context: Request-preparation context. The core provides keys such as `provider`, `tags`, `models`, `available_tools`, and `tools` here. Returns: The effective tool descriptor list for this request. """ return list(tools) ``` ### required_tags ``` required_tags() ``` Tags that must be present for this feature to be enabled. Source code in `core/python/agent_core/types.py` ``` def required_tags(self) -> List[str]: """Tags that must be present for this feature to be enabled.""" return [] ``` ### to_native_messages ``` to_native_messages( messages, native_messages, state=None, *, context=None ) ``` Transform provider-native messages using visibility of core messages. Note When the core converts a single new message (e.g., via add_message), it passes a list containing only that single message in "messages". Implementations must handle both full-history lists and single-message lists. In the default `AgentCore` request flow, feature transforms run after the provider and provider extension `to_native_messages` transforms. Parameters: | Name | Type | Description | Default | | ----------------- | ---------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | | `messages` | `List[Dict[str, Any]]` | Core session messages for context (may be a single-element list for add_message). | *required* | | `native_messages` | `List[Dict[str, Any]]` | Current provider-native messages. | *required* | | `state` | \`Dict[str, Any] | None\` | Optional shared provider state (available when called via ProviderWrapper). | Returns: | Type | Description | | ---------------------- | --------------------------------- | | `List[Dict[str, Any]]` | Updated provider-native messages. | Source code in `core/python/agent_core/types.py` ``` def to_native_messages( self, messages: List[Dict[str, Any]], native_messages: List[Dict[str, Any]], state: Dict[str, Any] | None = None, *, context: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: """Transform provider-native messages using visibility of core messages. Note: When the core converts a single new message (e.g., via add_message), it passes a list containing only that single message in "messages". Implementations must handle both full-history lists and single-message lists. In the default ``AgentCore`` request flow, feature transforms run after the provider and provider extension ``to_native_messages`` transforms. Args: messages: Core session messages for context (may be a single-element list for add_message). native_messages: Current provider-native messages. state: Optional shared provider state (available when called via ProviderWrapper). Returns: Updated provider-native messages. """ return list(native_messages) ``` ## Message ``` Message( role, content, metadata=None, multipart_content=None, tool_result=None, ) ``` Immutable message structure representing a single conversation message. Attributes: | Name | Type | Description | | ---------- | -------------------------- | --------------------------------------------------------------------------- | | `role` | `MessageRole` | Message role ("system", "user", "assistant", or "tool"). | | `content` | `Any` | Message content text. | | `metadata` | `Optional[Dict[str, Any]]` | Optional metadata dict containing tool_calls, tool_call_id, citations, etc. | Examples: ``` >>> msg = Message(role="user", content="Hello!") >>> msg.role 'user' >>> msg.content 'Hello!' ``` ### from_dict ``` from_dict(data) ``` Create message from dictionary. Parameters: | Name | Type | Description | Default | | ------ | ---------------- | ----------------------------------------------------------- | ---------- | | `data` | `Dict[str, Any]` | Dictionary containing role, content, and optional metadata. | *required* | Returns: | Type | Description | | --------- | --------------------- | | `Message` | New Message instance. | Source code in `core/python/agent_core/types.py` ``` @classmethod def from_dict(cls, data: Dict[str, Any]) -> "Message": """Create message from dictionary. Args: data: Dictionary containing role, content, and optional metadata. Returns: New Message instance. """ multipart_content = data.get("multipartContent") if multipart_content is not None and not isinstance(multipart_content, list): raise TypeError("multipartContent must be a list or null") tool_result = data.get("toolResult") if tool_result is not None and not isinstance(tool_result, dict): raise TypeError("toolResult must be an object or null") return cls( role=data["role"], content=data["content"], metadata=deepcopy(data.get("metadata")), multipart_content=deepcopy(multipart_content), tool_result=deepcopy(tool_result), ) ``` ### to_dict ``` to_dict() ``` Convert message to dictionary format. Returns: | Type | Description | | ---------------- | ----------------------------------------- | | `Dict[str, Any]` | Dictionary representation of the message. | Source code in `core/python/agent_core/types.py` ``` def to_dict(self) -> Dict[str, Any]: """Convert message to dictionary format. Returns: Dictionary representation of the message. """ result: Dict[str, Any] = {"role": self.role, "content": self.content} if self.metadata: result["metadata"] = deepcopy(self.metadata) if self.multipart_content: result["multipartContent"] = deepcopy(self.multipart_content) if self.tool_result: result["toolResult"] = deepcopy(self.tool_result) return result ``` ## PartialMessage ``` PartialMessage(role, content, metadata=None) ``` Partial message type used during streaming (forwarded to frontend as-is). ## ProviderExtensionPlugin Bases: `BasePlugin`, `Protocol` Protocol for provider extension plugins (shared provider state, hot + cold path observers). Provider extensions run in the provider's language and observe/transform streaming chunks on the hot path and participate in finalize on the cold path. They do not own separate state; they receive and update the shared provider state. Attributes: | Name | Type | Description | | ---------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `priority` | | Optional execution priority (default: 100). Lower numbers run first. Extensions are sorted by priority within the extension execution chain. | Example Basic prefix extension: ``` from typing import Any, Dict, List, Tuple, Optional class PrefixExt: name = "prefix" version = "1.0.0" priority = 50 # Optional: run before extensions with default priority (100) def get_config_schema(self): return {"prefix": {"type": "string", "default": "[EXT]"}} def init(self, config, state): return state def process_chunk(self, native_chunk, partial_messages, final_messages, native_messages, state): pfx = state.get("config", {}).get("prefix", "[EXT]") finals = [{**m, "content": f"{pfx} {m['content']}"} for m in final_messages] return partial_messages, finals, native_messages, state def finalize(self, final_messages, native_messages, state): return final_messages, native_messages, state ``` ### accepted_tool_schema_formats ``` accepted_tool_schema_formats(config, state=None) ``` Return additional tool schema formats this extension can inject. Source code in `core/python/agent_core/types.py` ``` def accepted_tool_schema_formats( self, config: Dict[str, Any], state: Dict[str, Any] | None = None, ) -> List[str]: """Return additional tool schema formats this extension can inject.""" return [] ``` ### emitted_tool_call_formats ``` emitted_tool_call_formats(config, state=None) ``` Return additional tool-call formats this extension can surface. Source code in `core/python/agent_core/types.py` ``` def emitted_tool_call_formats( self, config: Dict[str, Any], state: Dict[str, Any] | None = None, ) -> List[str]: """Return additional tool-call formats this extension can surface.""" return [] ``` ### execute_action ``` execute_action( action_id, session, native_messages, params, context, state, ) ``` Execute a session-scoped provider-native action. The returned mapping is generic. Extensions should always return a full `native_messages` list representing the new provider-native history for the session. Optional control keys currently understood by the core include: - `session_metadata`: mapping merged into `session.metadata` For the core-owned `response_finalize` lifecycle, actions may also return `final_messages` to replace the current turn's in-flight final core messages before they are emitted or appended to the session. Additional keys are preserved and surfaced back to higher layers as an opaque result payload. The `context` parameter provides layered access to runtime capabilities (see FeaturePlugin.execute_action for details on context structure). Source code in `core/python/agent_core/types.py` ``` def execute_action( self, action_id: str, session: "Session", native_messages: List[Dict[str, Any]], params: Dict[str, Any], context: Optional[Dict[str, Any]], state: Dict[str, Any], ) -> Dict[str, Any]: """Execute a session-scoped provider-native action. The returned mapping is generic. Extensions should always return a full ``native_messages`` list representing the new provider-native history for the session. Optional control keys currently understood by the core include: - ``session_metadata``: mapping merged into ``session.metadata`` For the core-owned ``response_finalize`` lifecycle, actions may also return ``final_messages`` to replace the current turn's in-flight final core messages before they are emitted or appended to the session. Additional keys are preserved and surfaced back to higher layers as an opaque result payload. The ``context`` parameter provides layered access to runtime capabilities (see FeaturePlugin.execute_action for details on context structure). """ return {"native_messages": list(native_messages)} ``` ### finalize ``` finalize( final_messages, native_messages, state, *, context=None ) ``` Final cold-path processing after provider streaming/finalize. Parameters: | Name | Type | Description | Default | | ----------------- | -------------------------- | ---------------------------------------------------------- | ---------- | | `final_messages` | `List[Dict[str, Any]]` | Provider-native final messages emitted this turn (deltas). | *required* | | `native_messages` | `List[Dict[str, Any]]` | Full provider-native message history for this turn. | *required* | | `state` | `Dict[str, Any]` | Current shared provider state. | *required* | | `context` | `Optional[Dict[str, Any]]` | Optional request context for this turn. | `None` | Returns: | Type | Description | | ------------------------------------------------------------------- | --------------------------------------------------------------------------- | | `Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]` | Tuple of (final_messages, native_messages, new_state), all provider-native. | Source code in `core/python/agent_core/types.py` ``` def finalize( self, final_messages: List[Dict[str, Any]], native_messages: List[Dict[str, Any]], state: Dict[str, Any], *, context: Optional[Dict[str, Any]] = None, ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]: """Final cold-path processing after provider streaming/finalize. Args: final_messages: Provider-native final messages emitted this turn (deltas). native_messages: Full provider-native message history for this turn. state: Current shared provider state. context: Optional request context for this turn. Returns: Tuple of (final_messages, native_messages, new_state), all provider-native. """ return final_messages, native_messages, state ``` ### forbidden_tags ``` forbidden_tags() ``` Tags that must NOT be present for this extension to be enabled. If any of these tags are in the available tag set, the extension is disabled. This provides a negative constraint complementary to required_tags. Source code in `core/python/agent_core/types.py` ``` def forbidden_tags(self) -> List[str]: """Tags that must NOT be present for this extension to be enabled. If any of these tags are in the available tag set, the extension is disabled. This provides a negative constraint complementary to required_tags. """ return [] ``` ### from_native_messages ``` from_native_messages( native_messages, messages, state=None, *, context=None ) ``` Transform provider-native messages back into core messages. Note In the default `AgentCore` request flow, extension transforms run after the provider's `from_native_messages` conversion and before feature transforms. Parameters: | Name | Type | Description | Default | | ----------------- | -------------------------- | ----------------------------------------------------- | --------------------------------------------------------------------------- | | `native_messages` | `List[Dict[str, Any]]` | Provider-native messages to transform. | *required* | | `messages` | `List[Dict[str, Any]]` | Core session messages for context. | *required* | | `state` | \`Dict[str, Any] | None\` | Optional shared provider state (available when called via ProviderWrapper). | | `context` | `Optional[Dict[str, Any]]` | Optional request/runtime context for this conversion. | `None` | Returns: | Type | Description | | ---------------------- | ---------------------- | | `List[Dict[str, Any]]` | Updated core messages. | Source code in `core/python/agent_core/types.py` ``` def from_native_messages( self, native_messages: List[Dict[str, Any]], messages: List[Dict[str, Any]], state: Dict[str, Any] | None = None, *, context: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: """Transform provider-native messages back into core messages. Note: In the default ``AgentCore`` request flow, extension transforms run after the provider's ``from_native_messages`` conversion and before feature transforms. Args: native_messages: Provider-native messages to transform. messages: Core session messages for context. state: Optional shared provider state (available when called via ProviderWrapper). context: Optional request/runtime context for this conversion. Returns: Updated core messages. """ return list(messages) ``` ### get_actions ``` get_actions(state) ``` Return session-scoped action definitions for this extension. These actions are analogous to application-plugin actions, but they operate only on a single session's provider-native history. The core owns session loading/saving and passes the selected session, current provider-native history, validated action params, and shared provider state into :meth:`execute_action`. Action definitions are plain dictionaries. A common shape is:: ``` { "id": "compact_native_history", "label": "Compact native history", "description": "Provider-native session action.", "inputs": { "instructions": {"type": "string", "required": False}, }, } ``` Transport-specific inputs such as `session_id` are handled by higher layers and should not be declared here unless the extension itself needs them. Source code in `core/python/agent_core/types.py` ``` def get_actions(self, state: Dict[str, Any]) -> List[Dict[str, Any]]: """Return session-scoped action definitions for this extension. These actions are analogous to application-plugin actions, but they operate only on a single session's provider-native history. The core owns session loading/saving and passes the selected session, current provider-native history, validated action params, and shared provider state into :meth:`execute_action`. Action definitions are plain dictionaries. A common shape is:: { "id": "compact_native_history", "label": "Compact native history", "description": "Provider-native session action.", "inputs": { "instructions": {"type": "string", "required": False}, }, } Transport-specific inputs such as ``session_id`` are handled by higher layers and should not be declared here unless the extension itself needs them. """ return [] ``` ### get_models ``` get_models(config, models) ``` Statelessly transform or provide model descriptors. Implementations must not mutate the incoming models list; they should instead return a new list. Note In the default `AgentCore` request flow, model discovery starts with the provider's `get_models` hook and then passes through each extension's `get_models` hook. Source code in `core/python/agent_core/types.py` ``` def get_models( self, config: Dict[str, Any], models: List[Dict[str, Any]], ) -> List[Dict[str, Any]]: """Statelessly transform or provide model descriptors. Implementations must not mutate the incoming models list; they should instead return a new list. Note: In the default ``AgentCore`` request flow, model discovery starts with the provider's ``get_models`` hook and then passes through each extension's ``get_models`` hook. """ return list(models) ``` ### get_tags ``` get_tags(config, models) ``` Return capability/environment tags contributed by this extension. Note In the default `AgentCore` dependency-resolution flow, these tags are aggregated with tags from the provider, other extensions, features, and tools to decide which plugins are enabled. Source code in `core/python/agent_core/types.py` ``` def get_tags( self, config: Dict[str, Any], models: List[Dict[str, Any]], ) -> List[str]: """Return capability/environment tags contributed by this extension. Note: In the default ``AgentCore`` dependency-resolution flow, these tags are aggregated with tags from the provider, other extensions, features, and tools to decide which plugins are enabled. """ return [] ``` ### get_tool_interop_contribution ``` get_tool_interop_contribution(config, state=None) ``` Return extension-contributed tool interop accessors/adapters. Source code in `core/python/agent_core/types.py` ``` def get_tool_interop_contribution( self, config: Dict[str, Any], state: Dict[str, Any] | None = None, ) -> "ToolInteropContribution": """Return extension-contributed tool interop accessors/adapters.""" from .tool_interop import ToolInteropContribution return ToolInteropContribution() ``` ### init ``` init(config, state) ``` Initialize with application config and shared provider state. Note In the default `AgentCore` request flow, provider extensions are initialized after the provider's `init` hook. Parameters: | Name | Type | Description | Default | | -------- | ---------------- | ----------------------------------- | ---------- | | `config` | `Dict[str, Any]` | Application-supplied configuration. | *required* | | `state` | `Dict[str, Any]` | Current shared provider state. | *required* | Returns: | Type | Description | | ---------------- | -------------------------------------------------- | | `Dict[str, Any]` | Updated shared provider state (or the same state). | Source code in `core/python/agent_core/types.py` ``` def init(self, config: Dict[str, Any], state: Dict[str, Any]) -> Dict[str, Any]: """Initialize with application config and shared provider state. Note: In the default ``AgentCore`` request flow, provider extensions are initialized after the provider's ``init`` hook. Args: config: Application-supplied configuration. state: Current shared provider state. Returns: Updated shared provider state (or the same state). """ return state ``` ### initialize_request ``` initialize_request(native_messages, state, *, context=None) ``` Prepare for a new request with provider-native messages. Note In the default `AgentCore` request flow, this hook runs after the provider's `initialize_request` hook and before any feature `initialize_request` hooks. Parameters: | Name | Type | Description | Default | | ----------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | `native_messages` | `List[Dict[str, Any]]` | Provider-native messages for this request. | *required* | | `state` | `Dict[str, Any]` | Current shared provider state. | *required* | | `context` | `Optional[Dict[str, Any]]` | Optional request context. The core provides keys such as core, config, request_id, tags, models, available_tools, tools, and tool_interop here. | `None` | Returns: | Type | Description | | ---------------------- | ---------------------------------------------------------------------------- | | `List[Dict[str, Any]]` | Tuple of (provider-native messages for this request, updated shared state). | | `Dict[str, Any]` | Implementations MAY return the same native_messages when only state changes. | Source code in `core/python/agent_core/types.py` ``` def initialize_request( self, native_messages: List[Dict[str, Any]], state: Dict[str, Any], *, context: Optional[Dict[str, Any]] = None, ) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: """Prepare for a new request with provider-native messages. Note: In the default ``AgentCore`` request flow, this hook runs after the provider's ``initialize_request`` hook and before any feature ``initialize_request`` hooks. Args: native_messages: Provider-native messages for this request. state: Current shared provider state. context: Optional request context. The core provides keys such as `core`, `config`, `request_id`, `tags`, `models`, `available_tools`, `tools`, and `tool_interop` here. Returns: Tuple of (provider-native messages for this request, updated shared state). Implementations MAY return the same native_messages when only state changes. """ return native_messages, state ``` ### is_enabled ``` is_enabled(config, tags, models, context) ``` Return whether this extension should be enabled given the current context. This method allows extensions to implement complex enablement logic that goes beyond simple tag matching. It receives the full runtime context including configuration, computed tags, model descriptors, and information about other enabled plugins. Parameters: | Name | Type | Description | Default | | --------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | `config` | `Dict[str, Any]` | Effective configuration for the current request. | *required* | | `tags` | `List[str]` | Capability tags computed for this config. | *required* | | `models` | `List[Dict[str, Any]]` | Model descriptors computed for this config. | *required* | | `context` | `Dict[str, Any]` | Additional context including: - "enabled_plugin_ids": dict mapping plugin kind to list of enabled plugin ids - "disabled_plugins": config-level disabled plugin id list - "force_enabled_plugins": config-level force-enabled plugin id list | *required* | Returns: | Name | Type | Description | | ------- | ---------------- | ---------------------------------------------------------------- | | `True` | `Optional[bool]` | extension is enabled (bypass required_tags/forbidden_tags check) | | `False` | `Optional[bool]` | extension is disabled | | `None` | `Optional[bool]` | fall back to required_tags/forbidden_tags check (default) | Source code in `core/python/agent_core/types.py` ``` def is_enabled( self, config: Dict[str, Any], tags: List[str], models: List[Dict[str, Any]], context: Dict[str, Any], ) -> Optional[bool]: """Return whether this extension should be enabled given the current context. This method allows extensions to implement complex enablement logic that goes beyond simple tag matching. It receives the full runtime context including configuration, computed tags, model descriptors, and information about other enabled plugins. Args: config: Effective configuration for the current request. tags: Capability tags computed for this config. models: Model descriptors computed for this config. context: Additional context including: - "enabled_plugin_ids": dict mapping plugin kind to list of enabled plugin ids - "disabled_plugins": config-level disabled plugin id list - "force_enabled_plugins": config-level force-enabled plugin id list Returns: True: extension is enabled (bypass required_tags/forbidden_tags check) False: extension is disabled None: fall back to required_tags/forbidden_tags check (default) """ return None ``` ### process_chunk ``` process_chunk( native_chunk, partial_messages, final_messages, native_messages, state, ) ``` Observe/modify provider outputs for a streaming chunk. Called on the hot path for each provider-native chunk. May be called with native_chunk=None during provider finalize. Note In the default `AgentCore` request flow, extensions run after the provider's `process_chunk` hook. Multiple extensions are invoked in order; features are not invoked on the hot path. Parameters: | Name | Type | Description | Default | | ------------------ | -------------------------- | --------------------------------------------------------- | ---------- | | `native_chunk` | `Optional[Dict[str, Any]]` | Provider-native streaming chunk, or None during finalize. | *required* | | `partial_messages` | `List[Dict[str, Any]]` | Current list of partial provider-native messages. | *required* | | `final_messages` | `List[Dict[str, Any]]` | Current list of final provider-native messages. | *required* | | `native_messages` | `List[Dict[str, Any]]` | Current full provider-native message history. | *required* | | `state` | `Dict[str, Any]` | Current shared provider state. | *required* | Returns: | Type | Description | | ---------------------- | ------------------------------------------------------------------------------------------------ | | `List[Dict[str, Any]]` | Tuple of (updated_partial_messages, updated_final_messages, updated_native_messages, new_state). | | `List[Dict[str, Any]]` | updated_native_messages MUST be full history (not a delta). | Source code in `core/python/agent_core/types.py` ``` def process_chunk( self, native_chunk: Optional[Dict[str, Any]], partial_messages: List[Dict[str, Any]], final_messages: List[Dict[str, Any]], native_messages: List[Dict[str, Any]], state: Dict[str, Any], ) -> Tuple[ List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any] ]: """Observe/modify provider outputs for a streaming chunk. Called on the hot path for each provider-native chunk. May be called with native_chunk=None during provider finalize. Note: In the default ``AgentCore`` request flow, extensions run after the provider's ``process_chunk`` hook. Multiple extensions are invoked in order; features are not invoked on the hot path. Args: native_chunk: Provider-native streaming chunk, or None during finalize. partial_messages: Current list of partial provider-native messages. final_messages: Current list of final provider-native messages. native_messages: Current full provider-native message history. state: Current shared provider state. Returns: Tuple of (updated_partial_messages, updated_final_messages, updated_native_messages, new_state). updated_native_messages MUST be full history (not a delta). """ return partial_messages, final_messages, native_messages, state ``` ### required_tags ``` required_tags() ``` Tags that must be present for this extension to be enabled. Source code in `core/python/agent_core/types.py` ``` def required_tags(self) -> List[str]: """Tags that must be present for this extension to be enabled.""" return [] ``` ### to_native_messages ``` to_native_messages( messages, native_messages, state=None, *, context=None ) ``` Transform provider-native messages using visibility of core messages. Note When the core converts a single new message (e.g., via add_message), it passes a list containing only that single message in "messages". Implementations must handle both full-history lists and single-message lists. In the default `AgentCore` request flow, extension transforms run after the provider's `to_native_messages` conversion and before feature transforms. Parameters: | Name | Type | Description | Default | | ----------------- | -------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | | `messages` | `List[Dict[str, Any]]` | Core session messages for context (may be a single-element list for add_message). | *required* | | `native_messages` | `List[Dict[str, Any]]` | Current provider-native messages. | *required* | | `state` | \`Dict[str, Any] | None\` | Optional shared provider state (available when called via ProviderWrapper). | | `context` | `Optional[Dict[str, Any]]` | Optional request/runtime context for this conversion. | `None` | Returns: | Type | Description | | ---------------------- | --------------------------------- | | `List[Dict[str, Any]]` | Updated provider-native messages. | Source code in `core/python/agent_core/types.py` ``` def to_native_messages( self, messages: List[Dict[str, Any]], native_messages: List[Dict[str, Any]], state: Dict[str, Any] | None = None, *, context: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: """Transform provider-native messages using visibility of core messages. Note: When the core converts a single new message (e.g., via add_message), it passes a list containing only that single message in "messages". Implementations must handle both full-history lists and single-message lists. In the default ``AgentCore`` request flow, extension transforms run after the provider's ``to_native_messages`` conversion and before feature transforms. Args: messages: Core session messages for context (may be a single-element list for add_message). native_messages: Current provider-native messages. state: Optional shared provider state (available when called via ProviderWrapper). context: Optional request/runtime context for this conversion. Returns: Updated provider-native messages. """ return list(native_messages) ``` ## ProviderPlugin Bases: `BasePlugin`, `Protocol` Protocol for provider plugins (hot + cold paths, shared provider state). Providers handle API I/O with LLMs and convert between core messages and provider-native messages. They run chunk processing on the hot path and participate in per-turn finalization on the cold path. Example Minimal echo-like provider: ``` from typing import Any, Dict, List, Tuple, Iterator class EchoProvider: name = "echo" version = "1.0.0" def get_config_schema(self) -> Dict[str, Any]: return {"model": {"type": "string", "default": "echo-1"}} def get_ui_elements(self, config: Dict[str, Any], tags: List[str], models: List[Dict[str, Any]]) -> List[Dict[str, Any]]: # optional return [] def init(self, config: Dict[str, Any]) -> Dict[str, Any]: # Provider state is an opaque dict; this minimal example # only stores configuration. return {"config": config} def initialize_request( self, native_messages: List[Dict[str, Any]], state: Dict[str, Any], ) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: return native_messages, state def to_native_messages(self, messages: List[Dict[str, Any]], state: Dict[str, Any]) -> List[Dict[str, Any]]: return list(messages) def from_native_messages(self, native_messages: List[Dict[str, Any]], state: Dict[str, Any]) -> List[Dict[str, Any]]: return list(native_messages) def call_api(self, native_messages: List[Dict[str, Any]], state: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]: content = f"Echo: {next((m['content'] for m in reversed(native_messages) if m.get('role')=='user'), '')}" msg = {"role": "assistant", "content": content} # Return (partial_messages, final_messages, native_messages, new_state) return ([], [msg], [*native_messages, msg], state) def stream_api(self, native_messages: List[Dict[str, Any]], state: Dict[str, Any]) -> Iterator[Dict[str, Any]]: yield from [] ``` ### accepted_tool_schema_formats ``` accepted_tool_schema_formats(config, state=None) ``` Return schema formats this provider can send without further adaptation. Source code in `core/python/agent_core/types.py` ``` def accepted_tool_schema_formats( self, config: Dict[str, Any], state: Dict[str, Any] | None = None, ) -> List[str]: """Return schema formats this provider can send without further adaptation.""" return [] ``` ### call_api ``` call_api(native_messages, state, *, request_id=None) ``` Make a non-streaming API call with provider-native messages. Parameters: | Name | Type | Description | Default | | ----------------- | ---------------------- | ------------------------------- | ---------- | | `native_messages` | `List[Dict[str, Any]]` | Provider-native input messages. | *required* | | `state` | `Dict[str, Any]` | Current shared provider state. | *required* | Returns: | Type | Description | | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | | `Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]` | Tuple of (partial_messages, final_messages, native_messages, new_state). | Source code in `core/python/agent_core/types.py` ``` def call_api( self, native_messages: List[Dict[str, Any]], state: Dict[str, Any], *, request_id: str | None = None, ) -> Tuple[ List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any] ]: """Make a non-streaming API call with provider-native messages. Args: native_messages: Provider-native input messages. state: Current shared provider state. Returns: Tuple of (partial_messages, final_messages, native_messages, new_state). """ return [], [], native_messages, state ``` ### cancel_request ``` cancel_request(request_id) ``` Best-effort cancellation for an in-flight request. Providers that track active runtime requests by `request_id` should attempt to cancel the matching request and return `True` when a matching in-flight request was found. Returning `False` indicates the provider did not recognize the request id or had nothing active to cancel. Source code in `core/python/agent_core/types.py` ``` def cancel_request(self, request_id: str) -> bool: """Best-effort cancellation for an in-flight request. Providers that track active runtime requests by ``request_id`` should attempt to cancel the matching request and return ``True`` when a matching in-flight request was found. Returning ``False`` indicates the provider did not recognize the request id or had nothing active to cancel. """ return False ``` ### emitted_tool_call_formats ``` emitted_tool_call_formats(config, state=None) ``` Return tool-call formats this provider may emit in responses. Source code in `core/python/agent_core/types.py` ``` def emitted_tool_call_formats( self, config: Dict[str, Any], state: Dict[str, Any] | None = None, ) -> List[str]: """Return tool-call formats this provider may emit in responses.""" return [] ``` ### finalize ``` finalize(native_messages, state, *, context=None) ``` Perform final processing after streaming completes. The provider runs first in the finalize chain. Both final_messages and native_messages are provider-native. final_messages contains only the new messages for this turn; native_messages MUST be full history. Active provider extensions run their `finalize` hooks next (in order), followed by feature `finalize` hooks. Parameters: | Name | Type | Description | Default | | ----------------- | -------------------------- | ------------------------------------------------------------------------------ | ---------- | | `native_messages` | `List[Dict[str, Any]]` | Full provider-native message history (all prior turns plus any for this turn). | *required* | | `state` | `Dict[str, Any]` | Current shared provider state. | *required* | | `context` | `Optional[Dict[str, Any]]` | Optional request context for this turn. | `None` | Returns: | Type | Description | | ------------------------------------------------------------------- | ------------------------------------------------------ | | `Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]` | Tuple of (final_messages, native_messages, new_state). | Source code in `core/python/agent_core/types.py` ``` def finalize( self, native_messages: List[Dict[str, Any]], state: Dict[str, Any], *, context: Optional[Dict[str, Any]] = None, ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]: """Perform final processing after streaming completes. The provider runs first in the finalize chain. Both final_messages and native_messages are provider-native. final_messages contains only the new messages for this turn; native_messages MUST be full history. Active provider extensions run their ``finalize`` hooks next (in order), followed by feature ``finalize`` hooks. Args: native_messages: Full provider-native message history (all prior turns plus any for this turn). state: Current shared provider state. context: Optional request context for this turn. Returns: Tuple of (final_messages, native_messages, new_state). """ return [], native_messages, state ``` ### from_native_messages ``` from_native_messages( native_messages, state, *, context=None ) ``` Convert provider-native messages into core message dictionaries. Note In the default `AgentCore` request flow, this conversion runs first. Active provider extensions and features may then apply stateless transforms via their own `from_native_messages` hooks. This base implementation intentionally ignores provider-specific reasoning fields. Providers and extensions that work with reasoning/thinking should surface that information via metadata (e.g., in a provider extension), not as a top-level field on the core message. Parameters: | Name | Type | Description | Default | | ----------------- | -------------------------- | ----------------------------------------------------- | ---------- | | `native_messages` | `List[Dict[str, Any]]` | Provider-native messages to convert. | *required* | | `state` | `Dict[str, Any]` | Current shared provider state. | *required* | | `context` | `Optional[Dict[str, Any]]` | Optional request/runtime context for this conversion. | `None` | Returns: | Type | Description | | ---------------------- | ------------------------------------------- | | `List[Dict[str, Any]]` | Core messages derived from native messages. | Source code in `core/python/agent_core/types.py` ``` def from_native_messages( self, native_messages: List[Dict[str, Any]], state: Dict[str, Any], *, context: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: """Convert provider-native messages into core message dictionaries. Note: In the default ``AgentCore`` request flow, this conversion runs first. Active provider extensions and features may then apply stateless transforms via their own ``from_native_messages`` hooks. This base implementation intentionally ignores provider-specific reasoning fields. Providers and extensions that work with reasoning/thinking should surface that information via metadata (e.g., in a provider extension), not as a top-level field on the core message. Args: native_messages: Provider-native messages to convert. state: Current shared provider state. context: Optional request/runtime context for this conversion. Returns: Core messages derived from native messages. """ finals: List[Dict[str, Any]] = [] for m in native_messages: if not isinstance(m, dict): continue if not ("role" in m or "content" in m): continue msg: Dict[str, Any] = { "role": m.get("role", "assistant"), "content": m.get("content", ""), } finals.append({**msg, "metadata": {}}) return finals ``` ### get_models ``` get_models(config) ``` Return model descriptors available for this provider and config. Optional; providers may return an empty list when model enumeration is not supported. Source code in `core/python/agent_core/types.py` ``` def get_models(self, config: Dict[str, Any]) -> List[Dict[str, Any]]: """Return model descriptors available for this provider and config. Optional; providers may return an empty list when model enumeration is not supported. """ return [] ``` ### get_tags ``` get_tags(config, models) ``` Return capability/environment tags for this provider. Note In the default `AgentCore` dependency-resolution flow, these tags are aggregated with tags from provider extensions, features, and tools to decide which plugins are enabled for a request. Implementations must be pure and deterministic for a given (config, models) pair. Source code in `core/python/agent_core/types.py` ``` def get_tags( self, config: Dict[str, Any], models: List[Dict[str, Any]], ) -> List[str]: """Return capability/environment tags for this provider. Note: In the default ``AgentCore`` dependency-resolution flow, these tags are aggregated with tags from provider extensions, features, and tools to decide which plugins are enabled for a request. Implementations must be pure and deterministic for a given (config, models) pair. """ return [] ``` ### get_tool_interop_contribution ``` get_tool_interop_contribution(config, state=None) ``` Return provider-contributed tool interop accessors/adapters. Source code in `core/python/agent_core/types.py` ``` def get_tool_interop_contribution( self, config: Dict[str, Any], state: Dict[str, Any] | None = None, ) -> "ToolInteropContribution": """Return provider-contributed tool interop accessors/adapters.""" from .tool_interop import ToolInteropContribution return ToolInteropContribution() ``` ### init ``` init(config) ``` Initialize provider state from application config. Note In the default `AgentCore` request flow, this hook is called once per request. Provider extensions run their `init` hooks after the provider initializes, and features run their `init` hooks after the provider+extensions init chain completes. Parameters: | Name | Type | Description | Default | | -------- | ---------------- | ---------------------------------------------------- | ---------- | | `config` | `Dict[str, Any]` | Application-supplied configuration for the provider. | *required* | Returns: | Type | Description | | ---------------- | ------------------------------------------------------------ | | `Dict[str, Any]` | Initial shared provider state owned by the provider wrapper. | Source code in `core/python/agent_core/types.py` ``` def init(self, config: Dict[str, Any]) -> Dict[str, Any]: """Initialize provider state from application config. Note: In the default ``AgentCore`` request flow, this hook is called once per request. Provider extensions run their ``init`` hooks after the provider initializes, and features run their ``init`` hooks after the provider+extensions init chain completes. Args: config: Application-supplied configuration for the provider. Returns: Initial shared provider state owned by the provider wrapper. """ return {"config": config} ``` ### initialize_request ``` initialize_request( native_messages, state, *, request_id=None, context=None ) ``` Prepare for a new request with provider-native messages. Note In the default `AgentCore` request flow, this hook runs first in the `initialize_request` chain. Active provider extensions run their `initialize_request` hooks next (in order), and feature `initialize_request` hooks run after the provider (and extensions) complete. Parameters: | Name | Type | Description | Default | | ----------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | `native_messages` | `List[Dict[str, Any]]` | Provider-native messages for this request. | *required* | | `state` | `Dict[str, Any]` | Current shared provider state. | *required* | | `context` | `Optional[Dict[str, Any]]` | Optional request context. The core provides keys such as core, config, request_id, tags, models, available_tools, tools, and tool_interop here. | `None` | Returns: | Type | Description | | ---------------------- | ---------------------------------------------------------------------------- | | `List[Dict[str, Any]]` | Tuple of (provider-native messages for this request, updated shared state). | | `Dict[str, Any]` | Implementations MAY return the same native_messages when only state changes. | Source code in `core/python/agent_core/types.py` ``` def initialize_request( self, native_messages: List[Dict[str, Any]], state: Dict[str, Any], *, request_id: str | None = None, context: Optional[Dict[str, Any]] = None, ) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: """Prepare for a new request with provider-native messages. Note: In the default ``AgentCore`` request flow, this hook runs first in the ``initialize_request`` chain. Active provider extensions run their ``initialize_request`` hooks next (in order), and feature ``initialize_request`` hooks run after the provider (and extensions) complete. Args: native_messages: Provider-native messages for this request. state: Current shared provider state. context: Optional request context. The core provides keys such as `core`, `config`, `request_id`, `tags`, `models`, `available_tools`, `tools`, and `tool_interop` here. Returns: Tuple of (provider-native messages for this request, updated shared state). Implementations MAY return the same native_messages when only state changes. """ return native_messages, state ``` ### process_chunk ``` process_chunk(native_chunk, native_messages, state) ``` Process a streaming chunk from the provider on the hot path. Note In the default `AgentCore` request flow, the provider processes each chunk first; provider extensions are then invoked (in order) to observe and adjust partials/finals/native history. Parameters: | Name | Type | Description | Default | | ----------------- | ---------------------- | ----------------------------------------- | ---------- | | `native_chunk` | `Dict[str, Any]` | Provider-native streaming chunk. | *required* | | `native_messages` | `List[Dict[str, Any]]` | Provider-native input messages (context). | *required* | | `state` | `Dict[str, Any]` | Current shared provider state. | *required* | Returns: | Type | Description | | ---------------------- | ------------------------------------------------------------------------ | | `List[Dict[str, Any]]` | Tuple of (partial_messages, final_messages, native_messages, new_state). | | `List[Dict[str, Any]]` | native_messages MUST be the full provider-native history (not a delta). | Source code in `core/python/agent_core/types.py` ``` def process_chunk( self, native_chunk: Dict[str, Any], native_messages: List[Dict[str, Any]], state: Dict[str, Any], ) -> Tuple[ List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any] ]: """Process a streaming chunk from the provider on the hot path. Note: In the default ``AgentCore`` request flow, the provider processes each chunk first; provider extensions are then invoked (in order) to observe and adjust partials/finals/native history. Args: native_chunk: Provider-native streaming chunk. native_messages: Provider-native input messages (context). state: Current shared provider state. Returns: Tuple of (partial_messages, final_messages, native_messages, new_state). native_messages MUST be the full provider-native history (not a delta). """ return [], [], native_messages, state ``` ### stream_api ``` stream_api(native_messages, state, *, request_id=None) ``` Make a streaming API call; yields provider-native chunks. Parameters: | Name | Type | Description | Default | | ----------------- | ---------------------- | ------------------------------- | ---------- | | `native_messages` | `List[Dict[str, Any]]` | Provider-native input messages. | *required* | | `state` | `Dict[str, Any]` | Current shared provider state. | *required* | Yields: | Type | Description | | ---------------- | --------------------------------- | | `Dict[str, Any]` | Provider-native streaming chunks. | Source code in `core/python/agent_core/types.py` ``` def stream_api( self, native_messages: List[Dict[str, Any]], state: Dict[str, Any], *, request_id: str | None = None, ) -> Iterator[Dict[str, Any]]: """Make a streaming API call; yields provider-native chunks. Args: native_messages: Provider-native input messages. state: Current shared provider state. Yields: Provider-native streaming chunks. """ return iter([]) ``` ### to_display_format ``` to_display_format(message, state) ``` Convert a message to a display-friendly format for UIs. Parameters: | Name | Type | Description | Default | | --------- | ---------------- | ---------------------------------- | ---------- | | `message` | `Dict[str, Any]` | Core message dictionary to format. | *required* | | `state` | `Dict[str, Any]` | Current shared provider state. | *required* | Returns: | Type | Description | | ---------------- | ------------------------------------ | | `Dict[str, Any]` | Display-friendly message dictionary. | Source code in `core/python/agent_core/types.py` ``` def to_display_format( self, message: Dict[str, Any], state: Dict[str, Any] ) -> Dict[str, Any]: """Convert a message to a display-friendly format for UIs. Args: message: Core message dictionary to format. state: Current shared provider state. Returns: Display-friendly message dictionary. """ return message ``` ### to_native_messages ``` to_native_messages(messages, state, *, context=None) ``` Convert core messages to this provider's native wire format. Note When the core converts a single new message (e.g., via add_message), it passes a list containing only that single message in "messages". Implementations must handle both full-history lists and single-message lists. In the default `AgentCore` request flow, this conversion runs first. Active provider extensions and features may then apply stateless transforms via their own `to_native_messages` hooks. Parameters: | Name | Type | Description | Default | | ---------- | -------------------------- | --------------------------------------------------------------------- | ---------- | | `messages` | `List[Dict[str, Any]]` | Core session messages (may be a single-element list for add_message). | *required* | | `state` | `Dict[str, Any]` | Current shared provider state. | *required* | | `context` | `Optional[Dict[str, Any]]` | Optional request/runtime context for this conversion. | `None` | Returns: | Type | Description | | ---------------------- | ------------------------------------------------ | | `List[Dict[str, Any]]` | Provider-native messages suitable for API calls. | Source code in `core/python/agent_core/types.py` ``` def to_native_messages( self, messages: List[Dict[str, Any]], state: Dict[str, Any], *, context: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: """Convert core messages to this provider's native wire format. Note: When the core converts a single new message (e.g., via add_message), it passes a list containing only that single message in "messages". Implementations must handle both full-history lists and single-message lists. In the default ``AgentCore`` request flow, this conversion runs first. Active provider extensions and features may then apply stateless transforms via their own ``to_native_messages`` hooks. Args: messages: Core session messages (may be a single-element list for add_message). state: Current shared provider state. context: Optional request/runtime context for this conversion. Returns: Provider-native messages suitable for API calls. """ return [ {"role": m.get("role", "user"), "content": m.get("content", "")} for m in messages if isinstance(m, dict) ] ``` ## ProviderRequestCancelled ``` ProviderRequestCancelled(request_id=None) ``` Bases: `RuntimeError` Raised when a provider request is cancelled by request id. Source code in `core/python/agent_core/types.py` ``` def __init__(self, request_id: str | None = None): super().__init__("Provider request cancelled") self.request_id = request_id ``` ## Session ``` Session(session_id, messages=list(), metadata=dict()) ``` Immutable session data structure (pure data container). Contains only data, no transformation logic. All transformations are performed by AgentCore functions that return new sessions. Attributes: | Name | Type | Description | | ------------ | ---------------- | ----------------------------------- | | `session_id` | `str` | Unique identifier for this session. | | `messages` | `List[Message]` | List of messages. | | `metadata` | `Dict[str, Any]` | Optional session-level metadata. | Examples: ``` >>> session = Session(session_id="test", messages=[]) >>> len(session.messages) 0 ``` ### from_dict ``` from_dict(data) ``` Create session from dictionary. Parameters: | Name | Type | Description | Default | | ------ | ---------------- | ------------------------------------------------------------------ | ---------- | | `data` | `Dict[str, Any]` | Dictionary containing session_id, messages, and optional metadata. | *required* | Returns: | Type | Description | | --------- | --------------------- | | `Session` | New Session instance. | Source code in `core/python/agent_core/types.py` ``` @classmethod def from_dict(cls, data: Dict[str, Any]) -> "Session": """Create session from dictionary. Args: data: Dictionary containing session_id, messages, and optional metadata. Returns: New Session instance. """ messages = [Message.from_dict(msg) for msg in data.get("messages", [])] return cls( session_id=data["session_id"], messages=messages, metadata=deepcopy(data.get("metadata", {})), ) ``` ### to_dict ``` to_dict() ``` Convert session to dictionary format. Returns: | Type | Description | | ---------------- | ----------------------------------------- | | `Dict[str, Any]` | Dictionary representation of the session. | Source code in `core/python/agent_core/types.py` ``` def to_dict(self) -> Dict[str, Any]: """Convert session to dictionary format. Returns: Dictionary representation of the session. """ return { "session_id": self.session_id, "messages": [msg.to_dict() for msg in self.messages], "metadata": deepcopy(self.metadata), } ``` ## ToolDescriptor ``` ToolDescriptor( plugin_name, tool_name, schema, metadata=dict() ) ``` Normalized metadata for a tool schema exposed to UI and requests. ## ToolPlugin Bases: `BasePlugin`, `Protocol` Protocol for tool plugins (own tool state; unchanged by shared state design). Tools provide function schemas and handle execution with their own state, independent from the provider shared state. Example Minimal calculator tool: ``` class DummyCalc: name = "calculator" version = "1.0.0" def get_tool_schemas(self, state): return [{"type": "function", "function": {"name": "add", "parameters": {"type": "object", "properties": {"a": {"type": "number"}, "b": {"type": "number"}}, "required": ["a","b"]}}}] def init(self, config): return {"config": config} def execute_tool(self, tool_name, arguments, state): if tool_name == "add": return {"success": True, "result": arguments.get("a", 0) + arguments.get("b", 0)} return {"success": False, "error": "unknown"} def format_tool_result(self, result, state): return f"Result: {result['result']}" if result.get("success") else f"Error: {result.get('error')}" ``` ### can_handle_tool_call ``` can_handle_tool_call( tool_name, payload, state, *, payload_kind=None, payload_format=None, payload_metadata=None, tool_call=None, tool_schema=None, prepared=None ) ``` Return whether this tool can handle the inspected tool call. `None` means no opinion and allows legacy name-based fallback. Source code in `core/python/agent_core/types.py` ``` def can_handle_tool_call( self, tool_name: Optional[str], payload: Any, state: Dict[str, Any], *, payload_kind: Optional[str] = None, payload_format: Optional[str] = None, payload_metadata: Optional[Dict[str, Any]] = None, tool_call: Optional[Dict[str, Any]] = None, tool_schema: Optional[Dict[str, Any]] = None, prepared: Optional[Dict[str, Any]] = None, ) -> Optional[bool]: """Return whether this tool can handle the inspected tool call. ``None`` means no opinion and allows legacy name-based fallback. """ return None ``` ### execute_tool ``` execute_tool( tool_name, payload, state, *, payload_kind=None, payload_format=None, payload_metadata=None, tool_call=None, cancellation=None, context=None, prepared=None ) ``` Execute a tool call using the final payload object. Parameters: | Name | Type | Description | Default | | ------------------ | -------------------------- | ---------------------------------------------------------------------------------- | ---------- | | `tool_name` | `Optional[str]` | Name of the tool/function when available. | *required* | | `payload` | `Any` | Final payload for the tool call, such as a dict or raw text. | *required* | | `state` | `Dict[str, Any]` | Current tool state. | *required* | | `payload_kind` | `Optional[str]` | Optional semantic payload kind such as "object" or "text". | `None` | | `payload_format` | `Optional[str]` | Optional format identifier for the payload itself. | `None` | | `payload_metadata` | `Optional[Dict[str, Any]]` | Optional extra payload metadata from the accessor. | `None` | | `tool_call` | `Optional[Dict[str, Any]]` | Optional original tool-call dict. | `None` | | `context` | `Optional[Dict[str, Any]]` | Optional layered context providing access to runtime capabilities. | `None` | | `prepared` | `Optional[Dict[str, Any]]` | Optional JSON-like prepared data returned by :meth:prepare or :meth:prepare_async. | `None` | The `context` parameter provides layered access to runtime capabilities: **Layer 1 (Core - always present when called from AgentCore):** - `core`: AgentCore instance for session/message operations and LLM calls - `config`: Current resolved request configuration - `trigger_source`: Where the tool was triggered ("core") - `session`: Serialized session dict (session.to_dict()) when available **Layer 2 (Application - when called from AgentApplication):** - `app` and `application`: AgentApplication instance - `base_config`: Full base configuration (all agents) - `session_asset_store`: Session asset store for file attachments - `runtime_state_root`: Persistent runtime-state root directory Caller-supplied context is additive only. Reserved keys owned by the context builders are not overridden; on clashes the builder keeps the owned value and emits a warning. Tools can use context to make LLM calls:: ``` core = (context or {}).get("core") if core: temp_session = core.create_session() events = core.stream_request(temp_session, config) ``` Returns: | Type | Description | | ---------------- | ------------------------------------------------------------------------ | | `Dict[str, Any]` | Result dictionary, e.g., {"success": bool, "result": any, "error": str}. | Source code in `core/python/agent_core/types.py` ``` def execute_tool( self, tool_name: Optional[str], payload: Any, state: Dict[str, Any], *, payload_kind: Optional[str] = None, payload_format: Optional[str] = None, payload_metadata: Optional[Dict[str, Any]] = None, tool_call: Optional[Dict[str, Any]] = None, cancellation: Any | None = None, context: Optional[Dict[str, Any]] = None, prepared: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """Execute a tool call using the final payload object. Args: tool_name: Name of the tool/function when available. payload: Final payload for the tool call, such as a dict or raw text. state: Current tool state. payload_kind: Optional semantic payload kind such as ``"object"`` or ``"text"``. payload_format: Optional format identifier for the payload itself. payload_metadata: Optional extra payload metadata from the accessor. tool_call: Optional original tool-call dict. context: Optional layered context providing access to runtime capabilities. prepared: Optional JSON-like prepared data returned by :meth:`prepare` or :meth:`prepare_async`. The ``context`` parameter provides layered access to runtime capabilities: **Layer 1 (Core - always present when called from AgentCore):** - ``core``: AgentCore instance for session/message operations and LLM calls - ``config``: Current resolved request configuration - ``trigger_source``: Where the tool was triggered ("core") - ``session``: Serialized session dict (session.to_dict()) when available **Layer 2 (Application - when called from AgentApplication):** - ``app`` and ``application``: AgentApplication instance - ``base_config``: Full base configuration (all agents) - ``session_asset_store``: Session asset store for file attachments - ``runtime_state_root``: Persistent runtime-state root directory Caller-supplied context is additive only. Reserved keys owned by the context builders are not overridden; on clashes the builder keeps the owned value and emits a warning. Tools can use context to make LLM calls:: core = (context or {}).get("core") if core: temp_session = core.create_session() events = core.stream_request(temp_session, config) Returns: Result dictionary, e.g., {"success": bool, "result": any, "error": str}. """ return {"success": False, "error": f"Unknown tool: {tool_name}"} ``` ### execute_tool_async ``` execute_tool_async( tool_name, payload, state, *, payload_kind=None, payload_format=None, payload_metadata=None, tool_call=None, cancellation=None, context=None, prepared=None ) ``` Optional async execution hook for tool authors using async libraries. Source code in `core/python/agent_core/types.py` ``` async def execute_tool_async( self, tool_name: Optional[str], payload: Any, state: Dict[str, Any], *, payload_kind: Optional[str] = None, payload_format: Optional[str] = None, payload_metadata: Optional[Dict[str, Any]] = None, tool_call: Optional[Dict[str, Any]] = None, cancellation: Any | None = None, context: Optional[Dict[str, Any]] = None, prepared: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """Optional async execution hook for tool authors using async libraries.""" return self.execute_tool( tool_name, payload, state, payload_kind=payload_kind, payload_format=payload_format, payload_metadata=payload_metadata, tool_call=tool_call, cancellation=cancellation, context=context, prepared=prepared, ) ``` ### forbidden_tags ``` forbidden_tags() ``` Tags that must NOT be present for this tool to be exposed. If any of these tags are in the available tag set, the tool is disabled. This provides a negative constraint complementary to required_tags. Source code in `core/python/agent_core/types.py` ``` def forbidden_tags(self) -> List[str]: """Tags that must NOT be present for this tool to be exposed. If any of these tags are in the available tag set, the tool is disabled. This provides a negative constraint complementary to required_tags. """ return [] ``` ### format_tool_call_preview ``` format_tool_call_preview( tool_name, payload, state, *, payload_kind=None, payload_format=None, payload_metadata=None, tool_call=None, prepared=None ) ``` Generate a pre-execution preview for a tool call. This hook is invoked when an assistant message containing tool calls is finalized, before the tools are executed. It should return a brief summary of what the tool call will do. The preview is intended for human-facing UIs (e.g. a collapsible list of tool calls) and should not be used for model-facing tool output. Parameters: | Name | Type | Description | Default | | ----------- | -------------------------- | ---------------------------------------------------------------------------------- | ---------- | | `tool_name` | `Optional[str]` | Name of the tool/function when available. | *required* | | `payload` | `Any` | Final payload for the tool call. | *required* | | `state` | `Dict[str, Any]` | Current tool state. | *required* | | `prepared` | `Optional[Dict[str, Any]]` | Optional JSON-like prepared data returned by :meth:prepare or :meth:prepare_async. | `None` | Returns: | Type | Description | | ----- | -------------------------------------------------------------------- | | `str` | A short string (preferably single-line). Return an empty string when | | `str` | no preview is available. | Source code in `core/python/agent_core/types.py` ``` def format_tool_call_preview( self, tool_name: Optional[str], payload: Any, state: Dict[str, Any], *, payload_kind: Optional[str] = None, payload_format: Optional[str] = None, payload_metadata: Optional[Dict[str, Any]] = None, tool_call: Optional[Dict[str, Any]] = None, prepared: Optional[Dict[str, Any]] = None, ) -> str: """Generate a pre-execution preview for a tool call. This hook is invoked when an assistant message containing tool calls is finalized, before the tools are executed. It should return a brief summary of what the tool call will do. The preview is intended for human-facing UIs (e.g. a collapsible list of tool calls) and should not be used for model-facing tool output. Args: tool_name: Name of the tool/function when available. payload: Final payload for the tool call. state: Current tool state. prepared: Optional JSON-like prepared data returned by :meth:`prepare` or :meth:`prepare_async`. Returns: A short string (preferably single-line). Return an empty string when no preview is available. """ return "" ``` ### format_tool_result ``` format_tool_result(result, state) ``` Format a tool result dictionary for LLM consumption/display. Parameters: | Name | Type | Description | Default | | -------- | ---------------- | --------------------------------- | ---------- | | `result` | `Dict[str, Any]` | Tool execution result dictionary. | *required* | | `state` | `Dict[str, Any]` | Current tool state. | *required* | Returns: | Type | Description | | ----- | ------------------------------------------------------------------- | | `Any` | Human-readable string representation or an explicit provider-native | | `Any` | tool result payload. | Source code in `core/python/agent_core/types.py` ``` def format_tool_result(self, result: Dict[str, Any], state: Dict[str, Any]) -> Any: """Format a tool result dictionary for LLM consumption/display. Args: result: Tool execution result dictionary. state: Current tool state. Returns: Human-readable string representation or an explicit provider-native tool result payload. """ if result.get("success"): return f"Result: {result.get('result')}" return f"Error: {result.get('error')}" ``` ### get_tags ``` get_tags(config, models) ``` Return capability/environment tags contributed by this tool. Note In the default `AgentCore` dependency-resolution flow, these tags are aggregated with tags from the provider, provider extensions, and features to decide which tools are exposed. Source code in `core/python/agent_core/types.py` ``` def get_tags( self, config: Dict[str, Any], models: List[Dict[str, Any]], ) -> List[str]: """Return capability/environment tags contributed by this tool. Note: In the default ``AgentCore`` dependency-resolution flow, these tags are aggregated with tags from the provider, provider extensions, and features to decide which tools are exposed. """ return [] ``` ### get_tool_interop_contribution ``` get_tool_interop_contribution(state) ``` Return tool-contributed tool interop accessors/adapters. Source code in `core/python/agent_core/types.py` ``` def get_tool_interop_contribution( self, state: Dict[str, Any], ) -> "ToolInteropContribution": """Return tool-contributed tool interop accessors/adapters.""" from .tool_interop import ToolInteropContribution return ToolInteropContribution() ``` ### get_tool_schemas ``` get_tool_schemas(state, *, prepared=None) ``` Return tool schemas provided by this tool. Parameters: | Name | Type | Description | Default | | ---------- | -------------------------- | ---------------------------------------------------------------------------------- | ---------- | | `state` | `Dict[str, Any]` | Current tool state (if needed by the tool). | *required* | | `prepared` | `Optional[Dict[str, Any]]` | Optional JSON-like prepared data returned by :meth:prepare or :meth:prepare_async. | `None` | Returns: | Type | Description | | ---------------------- | -------------------------------------------------------------------- | | `List[Dict[str, Any]]` | List of tool definition dictionaries in any format understood by the | | `List[Dict[str, Any]]` | active interop accessors/adapters. | Source code in `core/python/agent_core/types.py` ``` def get_tool_schemas( self, state: Dict[str, Any], *, prepared: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: """Return tool schemas provided by this tool. Args: state: Current tool state (if needed by the tool). prepared: Optional JSON-like prepared data returned by :meth:`prepare` or :meth:`prepare_async`. Returns: List of tool definition dictionaries in any format understood by the active interop accessors/adapters. """ return [] ``` ### init ``` init(config) ``` Initialize internal tool state from application config. Note In the default `AgentCore` request flow, tools are initialized before the provider so their tool schemas can be injected into the provider config under the `"tools"` key. Parameters: | Name | Type | Description | Default | | -------- | ---------------- | ------------------------------------------------ | ---------- | | `config` | `Dict[str, Any]` | Application-supplied configuration for the tool. | *required* | Returns: | Type | Description | | ---------------- | ------------------------------ | | `Dict[str, Any]` | Initial tool state dictionary. | Source code in `core/python/agent_core/types.py` ``` def init(self, config: Dict[str, Any]) -> Dict[str, Any]: """Initialize internal tool state from application config. Note: In the default ``AgentCore`` request flow, tools are initialized before the provider so their tool schemas can be injected into the provider config under the ``"tools"`` key. Args: config: Application-supplied configuration for the tool. Returns: Initial tool state dictionary. """ return {"config": config} ``` ### is_enabled ``` is_enabled(config, tags, models, context) ``` Return whether this tool should be enabled given the current context. This method allows tools to implement complex enablement logic that goes beyond simple tag matching. It receives the full runtime context including configuration, computed tags, model descriptors, and information about other enabled plugins. Parameters: | Name | Type | Description | Default | | --------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | `config` | `Dict[str, Any]` | Effective configuration for the current request. | *required* | | `tags` | `List[str]` | Capability tags computed for this config. | *required* | | `models` | `List[Dict[str, Any]]` | Model descriptors computed for this config. | *required* | | `context` | `Dict[str, Any]` | Additional context including: - "enabled_plugin_ids": dict mapping plugin kind to list of enabled plugin ids - "disabled_plugins": config-level disabled plugin id list - "force_enabled_plugins": config-level force-enabled plugin id list | *required* | Returns: | Name | Type | Description | | ------- | ---------------- | ----------------------------------------------------------- | | `True` | `Optional[bool]` | tool is enabled (bypass required_tags/forbidden_tags check) | | `False` | `Optional[bool]` | tool is disabled | | `None` | `Optional[bool]` | fall back to required_tags/forbidden_tags check (default) | Source code in `core/python/agent_core/types.py` ``` def is_enabled( self, config: Dict[str, Any], tags: List[str], models: List[Dict[str, Any]], context: Dict[str, Any], ) -> Optional[bool]: """Return whether this tool should be enabled given the current context. This method allows tools to implement complex enablement logic that goes beyond simple tag matching. It receives the full runtime context including configuration, computed tags, model descriptors, and information about other enabled plugins. Args: config: Effective configuration for the current request. tags: Capability tags computed for this config. models: Model descriptors computed for this config. context: Additional context including: - "enabled_plugin_ids": dict mapping plugin kind to list of enabled plugin ids - "disabled_plugins": config-level disabled plugin id list - "force_enabled_plugins": config-level force-enabled plugin id list Returns: True: tool is enabled (bypass required_tags/forbidden_tags check) False: tool is disabled None: fall back to required_tags/forbidden_tags check (default) """ return None ``` ### prepare ``` prepare(config, state, *, context=None) ``` Perform optional long-running preparation for later cheap hooks. `prepare` is separate from :meth:`init` so callers can keep schema discovery and preview helpers cheap by default. Implementations may do I/O, subprocess startup, discovery, or cache hydration here, but should return only JSON-like prepared data. Source code in `core/python/agent_core/types.py` ``` def prepare( self, config: Dict[str, Any], state: Dict[str, Any], *, context: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """Perform optional long-running preparation for later cheap hooks. ``prepare`` is separate from :meth:`init` so callers can keep schema discovery and preview helpers cheap by default. Implementations may do I/O, subprocess startup, discovery, or cache hydration here, but should return only JSON-like prepared data. """ return {} ``` ### prepare_async ``` prepare_async(config, state, *, context=None) ``` Async variant of :meth:`prepare` with identical semantics. Source code in `core/python/agent_core/types.py` ``` async def prepare_async( self, config: Dict[str, Any], state: Dict[str, Any], *, context: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """Async variant of :meth:`prepare` with identical semantics.""" return self.prepare(config, state, context=context) ``` ### required_tags ``` required_tags() ``` Tags that must be present for this tool to be exposed. Source code in `core/python/agent_core/types.py` ``` def required_tags(self) -> List[str]: """Tags that must be present for this tool to be exposed.""" return [] ``` ### stream_tool ``` stream_tool( tool_name, payload, state, *, payload_kind=None, payload_format=None, payload_metadata=None, tool_call=None, cancellation=None, context=None, prepared=None ) ``` Optional streaming execution hook for tools. Default implementation yields nothing. Concrete tools may override this to provide incremental, display-oriented results for long-running operations. Parameters: | Name | Type | Description | Default | | ------------------ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ---------------------------- | | `tool_name` | `Optional[str]` | Name of the tool/function when available. | *required* | | `payload` | `Any` | Final payload for the tool call. | *required* | | `state` | `Dict[str, Any]` | Current tool state. | *required* | | `payload_kind` | `Optional[str]` | Optional semantic payload kind. | `None` | | `payload_format` | `Optional[str]` | Optional format identifier for the payload. | `None` | | `payload_metadata` | `Optional[Dict[str, Any]]` | Optional extra payload metadata. | `None` | | `tool_call` | `Optional[Dict[str, Any]]` | Optional original tool-call dict. | `None` | | `cancellation` | \`Any | None\` | Optional cancellation token. | | `context` | `Optional[Dict[str, Any]]` | Optional layered context providing access to runtime capabilities. See :meth:execute_tool for context structure documentation. | `None` | | `prepared` | `Optional[Dict[str, Any]]` | Optional JSON-like prepared data returned by :meth:prepare or :meth:prepare_async. | `None` | Yields: | Type | Description | | ---------------- | --------------------------------------------------------------- | | `Dict[str, Any]` | Dictionary chunks with partial results ({"part": ...}) or final | | `Dict[str, Any]` | result ({"success": bool, ...}). | Source code in `core/python/agent_core/types.py` ``` def stream_tool( self, tool_name: Optional[str], payload: Any, state: Dict[str, Any], *, payload_kind: Optional[str] = None, payload_format: Optional[str] = None, payload_metadata: Optional[Dict[str, Any]] = None, tool_call: Optional[Dict[str, Any]] = None, cancellation: Any | None = None, context: Optional[Dict[str, Any]] = None, prepared: Optional[Dict[str, Any]] = None, ) -> Iterator[Dict[str, Any]]: """Optional streaming execution hook for tools. Default implementation yields nothing. Concrete tools may override this to provide incremental, display-oriented results for long-running operations. Args: tool_name: Name of the tool/function when available. payload: Final payload for the tool call. state: Current tool state. payload_kind: Optional semantic payload kind. payload_format: Optional format identifier for the payload. payload_metadata: Optional extra payload metadata. tool_call: Optional original tool-call dict. cancellation: Optional cancellation token. context: Optional layered context providing access to runtime capabilities. See :meth:`execute_tool` for context structure documentation. prepared: Optional JSON-like prepared data returned by :meth:`prepare` or :meth:`prepare_async`. Yields: Dictionary chunks with partial results ({"part": ...}) or final result ({"success": bool, ...}). """ return iter(()) ``` ### stream_tool_async ``` stream_tool_async( tool_name, payload, state, *, payload_kind=None, payload_format=None, payload_metadata=None, tool_call=None, cancellation=None, context=None, prepared=None ) ``` Optional async streaming hook for tool authors using async libraries. Source code in `core/python/agent_core/types.py` ``` async def stream_tool_async( self, tool_name: Optional[str], payload: Any, state: Dict[str, Any], *, payload_kind: Optional[str] = None, payload_format: Optional[str] = None, payload_metadata: Optional[Dict[str, Any]] = None, tool_call: Optional[Dict[str, Any]] = None, cancellation: Any | None = None, context: Optional[Dict[str, Any]] = None, prepared: Optional[Dict[str, Any]] = None, ) -> AsyncIterator[Dict[str, Any]]: """Optional async streaming hook for tool authors using async libraries.""" for chunk in self.stream_tool( tool_name, payload, state, payload_kind=payload_kind, payload_format=payload_format, payload_metadata=payload_metadata, tool_call=tool_call, cancellation=cancellation, context=context, prepared=prepared, ): yield chunk ``` ### to_display_format ``` to_display_format(text, result, state) ``` Optional display conversion hook for tool results. Default implementation wraps the formatted text in a simple `{"type": "text", "content": text}` payload suitable for UIs. Tools may optionally include a `"single_line"` field in the returned dict to provide a compact summary for collapsed/short views: `{"type": "text", "content": , "single_line": }`. The `single_line` field should contain a brief preview (e.g., just the command for shell tools, or a list of changed files for patch tools) that UIs can display when tool results are collapsed or shown in short mode. When expanded, UIs should use `content` for the full result. Source code in `core/python/agent_core/types.py` ``` def to_display_format( self, text: str, result: Dict[str, Any], state: Dict[str, Any], ) -> Dict[str, Any]: """Optional display conversion hook for tool results. Default implementation wraps the formatted text in a simple ``{"type": "text", "content": text}`` payload suitable for UIs. Tools may optionally include a ``"single_line"`` field in the returned dict to provide a compact summary for collapsed/short views: ``{"type": "text", "content": , "single_line": }``. The ``single_line`` field should contain a brief preview (e.g., just the command for shell tools, or a list of changed files for patch tools) that UIs can display when tool results are collapsed or shown in short mode. When expanded, UIs should use ``content`` for the full result. """ return {"type": "text", "content": text} ``` # Provider Wrapper Provider plugin wrapper and streaming mixin. Manages provider plugin state and provides typed interface for provider operations. ## ProviderWrapper ``` ProviderWrapper(plugin_class, extension_classes=None) ``` Bases: `ProviderPlugin` Wrapper for provider plugin managing state during streaming. Holds provider state and manages provider extensions in the same language (hot path). Initialize wrapper with provider plugin class and optional extensions. Parameters: | Name | Type | Description | Default | | ------------------- | ---------------------- | ------------------------------------------------------------ | ---------- | | `plugin_class` | `type` | Provider plugin class (instance methods; stateless behavior) | *required* | | `extension_classes` | `Optional[List[type]]` | Optional list of provider extension classes | `None` | Source code in `core/python/agent_core/plugin/provider.py` ``` def __init__( self, plugin_class: type, extension_classes: Optional[List[type]] = None, ) -> None: """Initialize wrapper with provider plugin class and optional extensions. Args: plugin_class: Provider plugin class (instance methods; stateless behavior) extension_classes: Optional list of provider extension classes """ global logger, log_chunk_processing logger = get_logger("provider") log_chunk_processing = get_logger("chunk_processing") self.plugin_class = plugin_class self.plugin = ProviderDefaultsAdapter(plugin_class()) self.state: Optional[Dict[str, Any]] = None self.name = getattr(plugin_class, "name", plugin_class.__name__) self.version = getattr(plugin_class, "version", "unknown") self._extensions: List[ExtensionWrapper] = [] self._active_extensions: Optional[List[ExtensionWrapper]] = None self._last_native_messages: List[Dict[str, Any]] = [] self._request_context: Optional[Dict[str, Any]] = None if extension_classes: for ext_cls in extension_classes: self._extensions.append(ExtensionWrapper(ext_cls)) ``` ### extensions ``` extensions ``` Get extension wrappers. Returns: | Type | Description | | ------------------------ | ---------------------------------------------- | | `List[ExtensionWrapper]` | List of registered ExtensionWrapper instances. | ### accepted_tool_schema_formats ``` accepted_tool_schema_formats(config, state=None) ``` Return schema formats this provider can send directly. Source code in `core/python/agent_core/plugin/provider.py` ``` def accepted_tool_schema_formats( self, config: Dict[str, Any], state: Dict[str, Any] | None = None, ) -> List[str]: """Return schema formats this provider can send directly.""" return self.plugin.accepted_tool_schema_formats(config, state) ``` ### add_extensions ``` add_extensions(extension_classes) ``` Add provider extensions (same language). Parameters: | Name | Type | Description | Default | | ------------------- | ------------ | ------------------------------------------ | ---------- | | `extension_classes` | `List[type]` | Extension classes to add to this provider. | *required* | Source code in `core/python/agent_core/plugin/provider.py` ``` def add_extensions(self, extension_classes: List[type]) -> None: """Add provider extensions (same language). Args: extension_classes: Extension classes to add to this provider. """ for ext_cls in extension_classes: self._extensions.append(ExtensionWrapper(ext_cls)) ``` ### apply_extension_transforms ``` apply_extension_transforms(messages, native_messages) ``` Apply extension transforms to provider-native messages. Parameters: | Name | Type | Description | Default | | ----------------- | ---------------------- | -------------------------------------- | ---------- | | `messages` | `List[Dict[str, Any]]` | Core messages (context). | *required* | | `native_messages` | `List[Dict[str, Any]]` | Provider-native messages to transform. | *required* | Returns: | Type | Description | | ---------------------- | ------------------------------------------------------------------- | | `List[Dict[str, Any]]` | Transformed provider-native messages after applying each extension. | Source code in `core/python/agent_core/plugin/provider.py` ``` def apply_extension_transforms( self, messages: List[Dict[str, Any]], native_messages: List[Dict[str, Any]] ) -> List[Dict[str, Any]]: """Apply extension transforms to provider-native messages. Args: messages: Core messages (context). native_messages: Provider-native messages to transform. Returns: Transformed provider-native messages after applying each extension. """ state = self._get_state() result = native_messages for ext in self._iter_extensions(): result = ext.to_native_messages(messages, result, state) return result ``` ### call_api ``` call_api(native_messages, state, *, request_id=None) ``` Make non-streaming API call and allow extensions to finalize outputs. Initializes the native history baseline, calls the provider, updates wrapper state, and returns full native history for subsequent steps. Parameters: | Name | Type | Description | Default | | ----------------- | ---------------------- | -------------------------------------------------------------- | ---------- | | `native_messages` | `List[Dict[str, Any]]` | Provider-native inputs. | *required* | | `state` | `Dict[str, Any]` | Shared provider state (optional; wrapper state used if empty). | *required* | Returns: | Type | Description | | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | | `Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]` | Tuple of (partial_messages, final_messages, full_native_history, new_state). | Source code in `core/python/agent_core/plugin/provider.py` ``` def call_api( self, native_messages: List[Dict[str, Any]], state: Dict[str, Any], *, request_id: str | None = None, ) -> Tuple[ List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any], ]: """Make non-streaming API call and allow extensions to finalize outputs. Initializes the native history baseline, calls the provider, updates wrapper state, and returns full native history for subsequent steps. Args: native_messages: Provider-native inputs. state: Shared provider state (optional; wrapper state used if empty). Returns: Tuple of (partial_messages, final_messages, full_native_history, new_state). """ if self.state is None: raise RuntimeError(f"Provider {self.name} not initialized") working_state = state or self.state # Initialize history baseline self._last_native_messages = list(native_messages) partials, final_messages, native_returned, new_state = self.plugin.call_api( self._last_native_messages, working_state, request_id=request_id, ) # Merge to full history and track full_history = self._merge_history(self._last_native_messages, native_returned) # Let extensions finalize non-streaming finals against the full history. for ext in self._iter_extensions(): final_messages, full_history, new_state = ext.finalize( final_messages, full_history, new_state, context=self._request_context, ) self.state = new_state self._last_native_messages = full_history return partials, final_messages, full_history, self._get_state() ``` ### emitted_tool_call_formats ``` emitted_tool_call_formats(config, state=None) ``` Return tool-call formats this provider may emit. Source code in `core/python/agent_core/plugin/provider.py` ``` def emitted_tool_call_formats( self, config: Dict[str, Any], state: Dict[str, Any] | None = None, ) -> List[str]: """Return tool-call formats this provider may emit.""" return self.plugin.emitted_tool_call_formats(config, state) ``` ### execute_extension_action ``` execute_extension_action( plugin_id, action_id, session, native_messages, params, context, state, ) ``` Dispatch a session-scoped action to the matching active extension. Source code in `core/python/agent_core/plugin/provider.py` ``` def execute_extension_action( self, plugin_id: str, action_id: str, session: Session, native_messages: List[Dict[str, Any]], params: Dict[str, Any], context: Optional[Dict[str, Any]], state: Dict[str, Any], ) -> Dict[str, Any]: """Dispatch a session-scoped action to the matching active extension.""" for ext in self._iter_extensions(): if ext.name != plugin_id: continue return ext.execute_action( action_id, session, native_messages, params, context, state ) raise KeyError(f"Unknown provider extension {plugin_id!r}") ``` ### finalize ``` finalize(native_messages, state, *, context=None) ``` Finalize provider turn and let extensions enhance outputs. Parameters: | Name | Type | Description | Default | | ----------------- | ---------------------- | ---------------------------------------------------------------------- | ---------- | | `native_messages` | `List[Dict[str, Any]]` | Full provider-native history for this turn (baseline if no streaming). | *required* | | `state` | `Dict[str, Any]` | Shared provider state (optional; wrapper state used if empty). | *required* | Returns: | Type | Description | | ------------------------------------------------------------------- | -------------------------------------------------------------------------- | | `Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]` | Tuple of (final_provider_native_messages, full_native_history, new_state). | Source code in `core/python/agent_core/plugin/provider.py` ``` def finalize( self, native_messages: List[Dict[str, Any]], state: Dict[str, Any], *, context: Optional[Dict[str, Any]] = None, ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]: """Finalize provider turn and let extensions enhance outputs. Args: native_messages: Full provider-native history for this turn (baseline if no streaming). state: Shared provider state (optional; wrapper state used if empty). Returns: Tuple of (final_provider_native_messages, full_native_history, new_state). """ if self.state is None: raise RuntimeError(f"Provider {self.name} not initialized") working_state = state or self.state # Use full history baseline baseline = self._last_native_messages or list(native_messages) active_context = context if isinstance(context, dict) else self._request_context final_messages, native_returned, new_state = self.plugin.finalize( baseline, working_state, context=active_context, ) # Merge and allow extensions to modify full history full_history = self._merge_history(baseline, native_returned) for ext in self._iter_extensions(): final_messages, full_history, new_state = ext.finalize( final_messages, full_history, new_state, context=active_context, ) self.state = new_state # track latest full history self._last_native_messages = full_history return final_messages, full_history, self._get_state() ``` ### from_native_messages ``` from_native_messages( native_messages, state, *, context=None ) ``` Convert provider-native messages back to core messages. Parameters: | Name | Type | Description | Default | | ----------------- | ---------------------- | -------------------------------------------------------------- | ---------- | | `native_messages` | `List[Dict[str, Any]]` | Provider-native messages to convert. | *required* | | `state` | `Dict[str, Any]` | Shared provider state (optional; wrapper state used if empty). | *required* | Returns: | Type | Description | | ---------------------- | ------------------- | | `List[Dict[str, Any]]` | Core message dicts. | Source code in `core/python/agent_core/plugin/provider.py` ``` def from_native_messages( self, native_messages: List[Dict[str, Any]], state: Dict[str, Any], *, context: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: """Convert provider-native messages back to core messages. Args: native_messages: Provider-native messages to convert. state: Shared provider state (optional; wrapper state used if empty). Returns: Core message dicts. """ if self.state is None: raise RuntimeError(f"Provider {self.name} not initialized") working_state = state or self.state return self.plugin.from_native_messages( native_messages, working_state, context=context, ) ``` ### get_config_schema ``` get_config_schema() ``` Get provider configuration schema. Returns: | Type | Description | | ---------------- | ---------------------------------------------------- | | `Dict[str, Any]` | JSON schema mapping for the provider, or empty dict. | Source code in `core/python/agent_core/plugin/provider.py` ``` def get_config_schema(self) -> Dict[str, Any]: """Get provider configuration schema. Returns: JSON schema mapping for the provider, or empty dict. """ return self.plugin.get_config_schema() ``` ### get_extension_actions ``` get_extension_actions(state) ``` Return session-scoped action definitions for active extensions. Source code in `core/python/agent_core/plugin/provider.py` ``` def get_extension_actions( self, state: Dict[str, Any], ) -> Dict[str, List[Dict[str, Any]]]: """Return session-scoped action definitions for active extensions.""" return {ext.name: ext.get_actions(state) for ext in self._iter_extensions()} ``` ### get_extension_config_schemas ``` get_extension_config_schemas() ``` Get config schemas for all extensions. Returns: | Type | Description | | ---------------- | ----------------------------------------------- | | `Dict[str, Any]` | Mapping from extension name to its JSON schema. | Source code in `core/python/agent_core/plugin/provider.py` ``` def get_extension_config_schemas(self) -> Dict[str, Any]: """Get config schemas for all extensions. Returns: Mapping from extension name to its JSON schema. """ return {ext.name: ext.get_config_schema() for ext in self._extensions} ``` ### get_extension_ui_elements ``` get_extension_ui_elements(config, context=None) ``` Get UI schemas for registered extensions. Uses the same priority-sorted extension order as the request pipeline. Source code in `core/python/agent_core/plugin/provider.py` ``` def get_extension_ui_elements( self, config: Dict[str, Any], context: Optional[Dict[str, Any]] = None, ) -> Dict[str, List[Dict[str, Any]]]: """Get UI schemas for registered extensions. Uses the same priority-sorted extension order as the request pipeline. """ return { ext.name: ext.get_ui_elements(config, context) for ext in self._iter_extensions() } ``` ### get_last_native_messages ``` get_last_native_messages() ``` Return the latest accumulated provider-native messages tracked during streaming. Returns: | Type | Description | | ---------------------- | -------------------------------------------------------------------------- | | `List[Dict[str, Any]]` | Full provider-native history after the most recent chunk or finalize step. | Source code in `core/python/agent_core/plugin/provider.py` ``` def get_last_native_messages(self) -> List[Dict[str, Any]]: """Return the latest accumulated provider-native messages tracked during streaming. Returns: Full provider-native history after the most recent chunk or finalize step. """ # type: ignore[attr-defined] return getattr(self, "_last_native_messages", []) ``` ### get_models ``` get_models(config) ``` Delegate model discovery to the underlying provider via adapter. Source code in `core/python/agent_core/plugin/provider.py` ``` def get_models(self, config: Dict[str, Any]) -> List[Dict[str, Any]]: """Delegate model discovery to the underlying provider via adapter.""" return self.plugin.get_models(config) ``` ### get_state ``` get_state() ``` Expose the current shared state for orchestration. Returns: | Type | Description | | ---------------- | ---------------------- | | `Dict[str, Any]` | Shared provider state. | Source code in `core/python/agent_core/plugin/provider.py` ``` def get_state(self) -> Dict[str, Any]: """Expose the current shared state for orchestration. Returns: Shared provider state. """ return self._get_state() ``` ### get_tags ``` get_tags(config, models) ``` Delegate tag computation to the underlying provider via adapter. Source code in `core/python/agent_core/plugin/provider.py` ``` def get_tags( self, config: Dict[str, Any], models: List[Dict[str, Any]], ) -> List[str]: """Delegate tag computation to the underlying provider via adapter.""" return self.plugin.get_tags(config, models) ``` ### get_tool_interop_contribution ``` get_tool_interop_contribution(config, state=None) ``` Return provider-contributed tool interop accessors/adapters. Source code in `core/python/agent_core/plugin/provider.py` ``` def get_tool_interop_contribution( self, config: Dict[str, Any], state: Dict[str, Any] | None = None, ) -> ToolInteropContribution: """Return provider-contributed tool interop accessors/adapters.""" return self.plugin.get_tool_interop_contribution(config, state) ``` ### get_ui_elements ``` get_ui_elements(config, context=None) ``` Get provider UI elements. Source code in `core/python/agent_core/plugin/provider.py` ``` def get_ui_elements( self, config: Dict[str, Any], context: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: """Get provider UI elements.""" return self.plugin.get_ui_elements(config, context) ``` ### init ``` init(config) ``` Initialize provider and extensions using shared state owned by provider wrapper. Parameters: | Name | Type | Description | Default | | -------- | ---------------- | ---------------------- | ---------- | | `config` | `Dict[str, Any]` | Provider configuration | *required* | Returns: | Type | Description | | ---------------- | -------------------------------------------------------------------- | | `Dict[str, Any]` | Shared provider state after extensions have updated it (if they do). | Source code in `core/python/agent_core/plugin/provider.py` ``` def init(self, config: Dict[str, Any]) -> Dict[str, Any]: """Initialize provider and extensions using shared state owned by provider wrapper. Args: config: Provider configuration Returns: Shared provider state after extensions have updated it (if they do). """ self.state = self.plugin.init(config) # Let extensions update shared state during init pipe = pipeline(config) self.state = pipe( [ext.init for ext in self._iter_extensions()], self._get_state() ) return self._get_state() ``` ### initialize_request ``` initialize_request( native_messages, state, *, request_id=None, context=None ) ``` Run provider and extensions initialize_request with native messages for a new turn. Parameters: | Name | Type | Description | Default | | ----------------- | ---------------------- | -------------------------------------------------------------- | ---------- | | `native_messages` | `List[Dict[str, Any]]` | Provider-native messages to initialize the new turn. | *required* | | `state` | `Dict[str, Any]` | Shared provider state (optional; wrapper state used if empty). | *required* | Returns: | Type | Description | | --------------------------------------------- | ------------------------------------------------------------------------------------- | | `Tuple[List[Dict[str, Any]], Dict[str, Any]]` | Tuple of (full provider-native history for this turn, updated shared provider state). | Source code in `core/python/agent_core/plugin/provider.py` ``` def initialize_request( self, native_messages: List[Dict[str, Any]], state: Dict[str, Any], *, request_id: str | None = None, context: Optional[Dict[str, Any]] = None, ) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: """Run provider and extensions initialize_request with native messages for a new turn. Args: native_messages: Provider-native messages to initialize the new turn. state: Shared provider state (optional; wrapper state used if empty). Returns: Tuple of (full provider-native history for this turn, updated shared provider state). """ if self.state is None: raise RuntimeError(f"Provider {self.name} not initialized") working_state = state or self.state self._request_context = context if isinstance(context, dict) else None # Start full native history at request boundary self._last_native_messages = list(native_messages) native_after_provider, new_state = self.plugin.initialize_request( native_messages, working_state, request_id=request_id, context=context, ) # Sort extensions by priority (lower numbers run first, default: 100) sorted_extensions = sorted( self._iter_extensions(), key=lambda ext: getattr(ext, "priority", 100) ) native_after_exts = native_after_provider for ext in sorted_extensions: native_after_exts, new_state = ext.initialize_request( native_after_exts, new_state, context=context, ) if isinstance(context, dict): new_state = {**new_state, "_request_context": context} self.state = new_state self._last_native_messages = list(native_after_exts) return native_after_exts, self._get_state() ``` ### process_chunk ``` process_chunk(native_chunk, native_messages, state) ``` Process provider chunk and invoke extensions (hot path). Converts partial messages to core using provider + extensions when possible. Parameters: | Name | Type | Description | Default | | ----------------- | ---------------------- | -------------------------------------------------------------- | ---------- | | `native_chunk` | `Dict[str, Any]` | Provider-native streaming chunk. | *required* | | `native_messages` | `List[Dict[str, Any]]` | Full provider-native history up to this chunk. | *required* | | `state` | `Dict[str, Any]` | Shared provider state (optional; wrapper state used if empty). | *required* | Returns: | Type | Description | | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | | `Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]` | Tuple of (partial_core_messages, final_messages, full_native_history, new_state). | Source code in `core/python/agent_core/plugin/provider.py` ``` def process_chunk( self, native_chunk: Dict[str, Any], native_messages: List[Dict[str, Any]], state: Dict[str, Any], ) -> Tuple[ List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any], ]: """Process provider chunk and invoke extensions (hot path). Converts partial messages to core using provider + extensions when possible. Args: native_chunk: Provider-native streaming chunk. native_messages: Full provider-native history up to this chunk. state: Shared provider state (optional; wrapper state used if empty). Returns: Tuple of (partial_core_messages, final_messages, full_native_history, new_state). """ if self.state is None: raise RuntimeError(f"Provider {self.name} not initialized") working_state = state or self.state # Use the provided full native history directly full_history = list(native_messages) log_chunk_processing.debug("processing chunk", chunk=native_chunk) partial_messages, final_messages, full_history, new_state = ( self.plugin.process_chunk(native_chunk, full_history, working_state) ) # Let extensions transform partials/finals/native history in order ext_state = new_state pipe = pipeline(native_chunk) partial_messages, final_messages, full_history, ext_state = pipe( [ext.process_chunk for ext in self._iter_extensions()], partial_messages, final_messages, full_history, ext_state, ) self.state = ext_state log_chunk_processing.debug("processed chunk", partials=partial_messages) # Convert partial messages (provider-native) to core using the same # from_native pipeline as final messages (provider first, then active # extensions). Features are cold-path only and are not applied here. core_partials: List[Dict[str, Any]] = [] if partial_messages: try: pipe = pipeline(partial_messages) core_partials = pipe( [self.plugin.from_native_messages] + [ext.from_native_messages for ext in self._iter_extensions()], ext_state, ) except Exception as e: logger.warning( "failed to convert partials to core, using as-is", error=str(e) ) core_partials = partial_messages # Track latest full history self._last_native_messages = full_history log_chunk_processing.debug( "converted partials to core", core_partials=core_partials ) return core_partials, final_messages, full_history, self._get_state() ``` ### reset_state ``` reset_state() ``` Discard internal shared state after request. Source code in `core/python/agent_core/plugin/provider.py` ``` def reset_state(self) -> None: """Discard internal shared state after request.""" self.state = None self._last_native_messages = [] self._active_extensions = None ``` ### set_active_extensions ``` set_active_extensions(extensions) ``` Restrict active extensions for the current request. Source code in `core/python/agent_core/plugin/provider.py` ``` def set_active_extensions(self, extensions: List[ExtensionWrapper]) -> None: """Restrict active extensions for the current request.""" self._active_extensions = list(extensions) ``` ### set_state ``` set_state(state) ``` Replace the current shared state (used by features). Parameters: | Name | Type | Description | Default | | ------- | ---------------- | --------------------------------- | ---------- | | `state` | `Dict[str, Any]` | New shared provider state to set. | *required* | Raises: | Type | Description | | -------------- | ------------------------------------ | | `RuntimeError` | If wrapper has not been initialized. | Source code in `core/python/agent_core/plugin/provider.py` ``` def set_state(self, state: Dict[str, Any]) -> None: """Replace the current shared state (used by features). Args: state: New shared provider state to set. Raises: RuntimeError: If wrapper has not been initialized. """ if self.state is None: raise RuntimeError(f"Provider {self.name} not initialized") self.state = state ``` ### stream_api ``` stream_api(native_messages, state, *, request_id=None) ``` Make streaming API call (state-validated). Parameters: | Name | Type | Description | Default | | ----------------- | ---------------------- | -------------------------------------------------------------- | ---------- | | `native_messages` | `List[Dict[str, Any]]` | Provider-native inputs. | *required* | | `state` | `Dict[str, Any]` | Shared provider state (optional; wrapper state used if empty). | *required* | Yields: | Type | Description | | ---------------- | --------------------------------- | | `Dict[str, Any]` | Provider-native streaming chunks. | Source code in `core/python/agent_core/plugin/provider.py` ``` def stream_api( self, native_messages: List[Dict[str, Any]], state: Dict[str, Any], *, request_id: str | None = None, ) -> Iterator[Dict[str, Any]]: """Make streaming API call (state-validated). Args: native_messages: Provider-native inputs. state: Shared provider state (optional; wrapper state used if empty). Yields: Provider-native streaming chunks. """ if self.state is None: raise RuntimeError(f"Provider {self.name} not initialized") working_state = state or self.state yield from self.plugin.stream_api( native_messages, working_state, request_id=request_id ) ``` ### stream_api_async ``` stream_api_async( native_messages, state, *, request_id=None ) ``` Make async streaming API call, preferring native async if available. Parameters: | Name | Type | Description | Default | | ----------------- | ---------------------- | -------------------------------------------------------------- | ---------- | | `native_messages` | `List[Dict[str, Any]]` | Provider-native inputs. | *required* | | `state` | `Dict[str, Any]` | Shared provider state (optional; wrapper state used if empty). | *required* | Yields: | Type | Description | | ------------------------------- | --------------------------------- | | `AsyncIterator[Dict[str, Any]]` | Provider-native streaming chunks. | Source code in `core/python/agent_core/plugin/provider.py` ``` async def stream_api_async( self, native_messages: List[Dict[str, Any]], state: Dict[str, Any], *, request_id: str | None = None, ) -> AsyncIterator[Dict[str, Any]]: """Make async streaming API call, preferring native async if available. Args: native_messages: Provider-native inputs. state: Shared provider state (optional; wrapper state used if empty). Yields: Provider-native streaming chunks. """ if self.state is None: raise RuntimeError(f"Provider {self.name} not initialized") working_state = state or self.state if hasattr(self.plugin, "stream_api_async"): async for chunk in self.plugin.stream_api_async( # type: ignore[attr-defined] native_messages, working_state, request_id=request_id, ): yield chunk return # Fallback to sync stream for chunk in self.plugin.stream_api( native_messages, working_state, request_id=request_id ): yield chunk ``` ### stream_messages ``` stream_messages(native_messages, state, *, request_id=None) ``` Stream provider results while processing chunks internally. Events yielded - {"type": "partial", "message": } - {"type": "final_messages", "messages": } Parameters: | Name | Type | Description | Default | | ----------------- | ---------------------- | ---------------------------------------------------------- | ---------- | | `native_messages` | `List[Dict[str, Any]]` | Provider-native input messages for this request. | *required* | | `state` | `Dict[str, Any]` | Current shared provider state (or wrapper state if empty). | *required* | Yields: | Type | Description | | ---------------- | -------------------------------------------------------------------------------- | | `Dict[str, Any]` | Dict events representing partials and batches of final provider-native messages. | Raises: | Type | Description | | -------------- | -------------------------------------------------- | | `RuntimeError` | If the wrapper is not initialized (missing state). | Source code in `core/python/agent_core/plugin/provider.py` ``` def stream_messages( self, native_messages: List[Dict[str, Any]], state: Dict[str, Any], *, request_id: str | None = None, ) -> Iterator[Dict[str, Any]]: """Stream provider results while processing chunks internally. Events yielded: - {"type": "partial", "message": } - {"type": "final_messages", "messages": } Args: native_messages: Provider-native input messages for this request. state: Current shared provider state (or wrapper state if empty). Yields: Dict events representing partials and batches of final provider-native messages. Raises: RuntimeError: If the wrapper is not initialized (missing state). """ # Resolve initial state via wrapper; error if uninitialized try: working_state = state or self._get_state() # type: ignore[attr-defined] except Exception: raise RuntimeError(f"Provider {self.name} not initialized") # type: ignore[attr-defined] # Initialize full native history for this turn self._last_native_messages = list(native_messages) # type: ignore[attr-defined] current_native = self._last_native_messages chunk_count = 0 for native_chunk in self.plugin.stream_api( current_native, working_state, request_id=request_id, ): # type: ignore[attr-defined] chunk_count += 1 partials, finals, current_native, working_state = self.process_chunk( # type: ignore[misc] native_chunk, current_native, working_state ) # track latest native messages (full history) self._last_native_messages = current_native # type: ignore[attr-defined] # yield partials first for p in partials: yield {"type": "partial", "message": p} if finals: yield {"type": "final_messages", "messages": finals} logger.debug("stream_messages complete") ``` ### stream_messages_async ``` stream_messages_async( native_messages, state, *, request_id=None ) ``` Async variant of stream_messages. Prefers the provider's native async streaming when available, falling back to sync streaming otherwise. Parameters: | Name | Type | Description | Default | | ----------------- | ---------------------- | ---------------------------------------------------------- | ---------- | | `native_messages` | `List[Dict[str, Any]]` | Provider-native input messages for this request. | *required* | | `state` | `Dict[str, Any]` | Current shared provider state (or wrapper state if empty). | *required* | Yields: | Type | Description | | ------------------------------- | -------------------------------------------------------------------------------- | | `AsyncIterator[Dict[str, Any]]` | Dict events representing partials and batches of final provider-native messages. | Raises: | Type | Description | | -------------- | -------------------------------------------------- | | `RuntimeError` | If the wrapper is not initialized (missing state). | Source code in `core/python/agent_core/plugin/provider.py` ``` async def stream_messages_async( self, native_messages: List[Dict[str, Any]], state: Dict[str, Any], *, request_id: str | None = None, ) -> AsyncIterator[Dict[str, Any]]: """Async variant of stream_messages. Prefers the provider's native async streaming when available, falling back to sync streaming otherwise. Args: native_messages: Provider-native input messages for this request. state: Current shared provider state (or wrapper state if empty). Yields: Dict events representing partials and batches of final provider-native messages. Raises: RuntimeError: If the wrapper is not initialized (missing state). """ # Resolve initial state via wrapper; error if uninitialized try: working_state = state or self._get_state() # type: ignore[attr-defined] except Exception: raise RuntimeError(f"Provider {self.name} not initialized") # type: ignore[attr-defined] current_native = list(native_messages) if hasattr(self.plugin, "stream_api_async"): # type: ignore[attr-defined] async for native_chunk in self.plugin.stream_api_async( # type: ignore[attr-defined] current_native, working_state, request_id=request_id, ): partials, finals, current_native, working_state = self.process_chunk( # type: ignore[misc] native_chunk, current_native, working_state ) self._last_native_messages = current_native # type: ignore[attr-defined] for p in partials: yield {"type": "partial", "message": p} if finals: yield {"type": "final_messages", "messages": finals} return # Fallback to sync stream for native_chunk in self.plugin.stream_api( current_native, working_state, request_id=request_id, ): # type: ignore[attr-defined] partials, finals, current_native, working_state = self.process_chunk( # type: ignore[misc] native_chunk, current_native, working_state ) self._last_native_messages = current_native # type: ignore[attr-defined] for p in partials: yield {"type": "partial", "message": p} if finals: yield {"type": "final_messages", "messages": finals} ``` ### to_display_format ``` to_display_format(message, state) ``` Format a message for display if supported; otherwise return as-is. Parameters: | Name | Type | Description | Default | | --------- | ---------------- | -------------------------------------------------------------- | ---------- | | `message` | `Dict[str, Any]` | Core message to format. | *required* | | `state` | `Dict[str, Any]` | Shared provider state (optional; wrapper state used if empty). | *required* | Returns: | Type | Description | | ---------------- | ------------------------------------------------------------------- | | `Dict[str, Any]` | Display-friendly message dictionary or the input message unchanged. | Source code in `core/python/agent_core/plugin/provider.py` ``` def to_display_format( self, message: Dict[str, Any], state: Dict[str, Any] ) -> Dict[str, Any]: """Format a message for display if supported; otherwise return as-is. Args: message: Core message to format. state: Shared provider state (optional; wrapper state used if empty). Returns: Display-friendly message dictionary or the input message unchanged. """ if self.state is None: raise RuntimeError(f"Provider {self.name} not initialized") working_state = state or self.state return self.plugin.to_display_format(message, working_state) ``` ### to_native_messages ``` to_native_messages(messages, state, *, context=None) ``` Convert core messages to provider's native format (provider-first). Parameters: | Name | Type | Description | Default | | ---------- | ---------------------- | -------------------------------------------------------------- | ---------- | | `messages` | `List[Dict[str, Any]]` | Core message dicts. | *required* | | `state` | `Dict[str, Any]` | Shared provider state (optional; wrapper state used if empty). | *required* | Returns: | Type | Description | | ---------------------- | ----------------------------- | | `List[Dict[str, Any]]` | Provider-native message list. | Source code in `core/python/agent_core/plugin/provider.py` ``` def to_native_messages( self, messages: List[Dict[str, Any]], state: Dict[str, Any], *, context: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: """Convert core messages to provider's native format (provider-first). Args: messages: Core message dicts. state: Shared provider state (optional; wrapper state used if empty). Returns: Provider-native message list. """ if self.state is None: raise RuntimeError(f"Provider {self.name} not initialized") working_state = state or self.state return self.plugin.to_native_messages( messages, working_state, context=context, ) ``` # OpenAI-Compatible Provider OpenAI-Compatible Provider Plugin (plugins/providers/openai/python/openai_provider.py) Minimal provider for OpenAI-compatible Chat Completions endpoints (e.g., Ollama at /v1). Loads configuration only from the provided config dict (no environment variable usage). ## OpenAICompatibleProvider ``` OpenAICompatibleProvider() ``` Bases: `ProviderPlugin` Source code in `core/python/plugins/openai_provider.py` ``` def __init__(self) -> None: self._inflight_lock = threading.Lock() self._inflight_requests: Dict[str, _InflightRequest] = {} ``` ### extract_delta ``` extract_delta(native_chunk) ``` Extract a provider-native delta dictionary from a streaming chunk. Returns the first choice object when present, or an empty dict. Source code in `core/python/plugins/openai_provider.py` ``` def extract_delta(self, native_chunk: Dict[str, Any]) -> Dict[str, Any]: """Extract a provider-native delta dictionary from a streaming chunk. Returns the first choice object when present, or an empty dict. """ if "choices" in native_chunk and native_chunk["choices"]: return native_chunk["choices"][0] return {} ``` ### finalize ``` finalize(native_messages, state) ``` Finalize streaming by emitting the accumulated partial as a final. When streaming was used, `state["partial"]` holds the merged assistant message (content, role, reasoning, tool_calls, etc.) built up across chunks by the provider and any extensions. Non-streaming calls leave it as `None`. Source code in `core/python/plugins/openai_provider.py` ``` def finalize( self, native_messages: List[Dict[str, Any]], state: Dict[str, Any], ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]: """Finalize streaming by emitting the accumulated partial as a final. When streaming was used, `state["partial"]` holds the merged assistant message (content, role, reasoning, tool_calls, etc.) built up across chunks by the provider and any extensions. Non-streaming calls leave it as ``None``. """ current_partial = state.get("partial", None) if current_partial: return [current_partial], [*native_messages, current_partial], state return [], native_messages, state ``` ### from_native_messages ``` from_native_messages(native_messages, state) ``` Convert provider-native finals to core messages. This provider-level conversion intentionally ignores any provider-specific reasoning fields. Reasoning content should be surfaced by dedicated extensions (e.g., thinking extensions) via message metadata rather than as a top-level field. Source code in `core/python/plugins/openai_provider.py` ``` def from_native_messages( self, native_messages: List[Dict[str, Any]], state: Dict[str, Any] ) -> List[Dict[str, Any]]: """Convert provider-native finals to core messages. This provider-level conversion intentionally ignores any provider-specific reasoning fields. Reasoning content should be surfaced by dedicated extensions (e.g., thinking extensions) via message metadata rather than as a top-level field. """ normalized: List[Dict[str, Any]] = [] for m in native_messages: if not isinstance(m, dict): continue if not ("role" in m or "content" in m): # Ignore non-message shapes (like full completion objects) continue raw_internal = m.get("_metadata") or {} internal_md = dict(raw_internal) if isinstance(raw_internal, dict) else {} internal_md.pop("native_indices", None) msg = { "role": m.get("role", "assistant"), "content": m.get("content", ""), } normalized.append({**msg, "metadata": internal_md}) return normalized ``` ### get_models ``` get_models(config) ``` Return minimal model descriptors for this provider. Currently returns the configured `model` (if any) as a single ModelDescriptor with only an `id` field. Source code in `core/python/plugins/openai_provider.py` ``` def get_models(self, config: Dict[str, Any]) -> List[Dict[str, Any]]: """Return minimal model descriptors for this provider. Currently returns the configured `model` (if any) as a single ModelDescriptor with only an `id` field. """ model = config.get("model") if isinstance(model, str) and model: return [{"id": model}] return [] ``` ### get_tags ``` get_tags(config, models) ``` Return capability tags for the OpenAI-compatible provider. This implementation is intentionally simple and conservative; it assumes chat-completions style models with tool calling and streaming support. Source code in `core/python/plugins/openai_provider.py` ``` def get_tags( self, config: Dict[str, Any], models: List[Dict[str, Any]], ) -> List[str]: """Return capability tags for the OpenAI-compatible provider. This implementation is intentionally simple and conservative; it assumes chat-completions style models with tool calling and streaming support. """ tags: List[str] = ["provider:openai_compatible"] tags.append("supports_streaming") tags.append("supports_tools") tags.append("supports_thinking") return tags ``` ### process_chunk ``` process_chunk(native_chunk, native_messages, state) ``` Process a streaming chunk into provider-native partials. Pattern: - Extract a delta from the chunk - Reduce it into a partial fragment and update the accumulator - Do not emit finals or modify history; :meth:`finalize` is responsible for producing the final message from the accumulated partial. Source code in `core/python/plugins/openai_provider.py` ``` def process_chunk( self, native_chunk: Dict[str, Any], native_messages: List[Dict[str, Any]], state: Dict[str, Any], ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]: """Process a streaming chunk into provider-native partials. Pattern: - Extract a delta from the chunk - Reduce it into a partial fragment and update the accumulator - Do not emit finals or modify history; :meth:`finalize` is responsible for producing the final message from the accumulated partial. """ partial_msg, accumulated = self.process_delta( delta=self.extract_delta(native_chunk), accumulated=state.get("partial", {}) ) partials = [partial_msg] if partial_msg else [] finals: List[Dict[str, Any]] = [] new_state = {**state, "partial": accumulated} return partials, finals, native_messages, new_state ``` ### process_delta ``` process_delta(delta, accumulated) ``` Reduce a delta dict into a new partial and accumulator. Returns the current partial fragment from this chunk and an updated accumulator used later by :meth:`finalize` to build the final message. Source code in `core/python/plugins/openai_provider.py` ``` def process_delta(self, delta: Dict[str, Any], accumulated): """Reduce a delta dict into a new partial and accumulator. Returns the current partial fragment from this chunk and an updated accumulator used later by :meth:`finalize` to build the final message. """ # Normalize accumulator once base = accumulated or {} # Prefer full message objects, then deltas content = delta.get("message") or delta.get("delta") or None if content is None: return None, base new_accumulated = self._merge_delta_content( base, content, override=["role"], accumulate=["content"], ) return content, new_accumulated ``` # Tool Host Protocol (NDJSON) This repo supports out-of-process tool plugins executed by external “tool hosts”. The first implementation is the Node.js tool host used for JavaScript/TypeScript tools. ## Transport - A tool host is a subprocess. - The client writes newline-delimited JSON (NDJSON) to the host’s stdin. - The host writes NDJSON to stdout. - Stdout is reserved for protocol messages; tool hosts/plugins should write logs to stderr. ## Message shapes All messages include a protocol version field: ``` { "v": 1, "id": "uuid", "method": "execute_tool", "params": { ... } } ``` ### Request ``` { "v": 1, "id": "uuid", "method": "", "params": { ... } } ``` - `v`: protocol version (currently `1`). - `id`: request id (string). - `method`: RPC method name (snake_case). - `params`: JSON object. ### Response ``` { "v": 1, "id": "uuid", "ok": true, "result": { "value": , "state": } } { "v": 1, "id": "uuid", "ok": false, "error": { "type": "Error", "detail": "...", "stack": "..." } } ``` When a request includes `params.state` (or when calling `init`), the host should also include an updated `result.state` so the client can round-trip tool state. ### Streaming events Tool execution can emit streaming events before the final response: ``` { "v": 1, "id": "uuid", "event": { "type": "part", "payload": { ... } } } ``` For Node.js tools, events are produced by calling the `emit(event)` callback. The Python core maps `event.type == "part"` into partial chunks yielded as: ``` { "part": } ``` ## Required methods (v1) - `init({"config": }) -> state` - `get_tool_schemas({"state": }) -> [schema]` - `execute_tool({"tool_name": "...", "arguments": , "state": }) -> result` Note: the in-process Python tool API is now payload-first and can execute raw text / freeform tool calls. The v1 host protocol remains object-arguments-based for compatibility with existing Node-hosted tools. If host-side freeform/custom tool execution is added later, this page should be updated as part of that protocol change. The `execute_tool` result is expected to be the same shape as a Python tool result: ``` { "success": true, "result": } { "success": false, "error": "..." } ``` ## Optional methods - `get_config_schema() -> object` - `get_ui_elements() -> [object]` - `get_tags({"config": , "models": [object]}) -> [string]` - `required_tags() -> [string]` - `format_tool_result({"result": , "state": }) -> string | provider-native envelope` `format_tool_result` should normally return a string. A tool host may opt into structured provider-native tool output by returning an explicit JSON object with `type: "provider_native_tool_result"` and a supported `format`, such as `openai.chat_completions` or `openai.responses`. The Python host preserves only that explicit envelope shape as structured model-facing content; ordinary objects are compatibility text. - `format_tool_call_preview({"tool_name": "...", "arguments": , "state": }) -> string` - `to_display_format({"text": "...", "result": , "state": }) -> object` ## Node.js implementation The tool host runner lives at `packages/tool-host-node/host.mjs`. The Python proxy tool plugin lives at `core/python/agent_core/tool_hosts/node_tool_plugin.py`. # Default Configuration Generated from `application/python/agent_terminal_app/default_config/config.json`. ``` { "plugin_cache_dir": "${env:CONFIG_DIR}/.plugin_cache/plugins", "mixins": { "provider_defaults": { "timeout": 300, "allowed_paths": [ ".", ".." ], "max_file_size": 5242880, "max_output_chars": 10000, "working_directory": ".", "enable_reasoning": true, "reasoning_effort": "medium" }, "codex_mcp_defaults": { "mcp_enabled": false, "mcp_reuse_runtime": true, "mcp_fail_on_startup_error": false, "mcp_servers": { "filesystem": { "enabled": false, "transport": "stdio", "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-filesystem", "${env:WORKING_DIR}" ], "cwd": "${env:WORKING_DIR}", "startup_timeout_seconds": 30, "tool_timeout_seconds": 30 } } }, "claude_mcp_defaults": { "mcp_enabled": false, "mcp_reuse_runtime": true, "mcp_fail_on_startup_error": false, "mcp_servers": { "filesystem": { "enabled": false, "transport": "stdio", "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-filesystem", "${env:WORKING_DIR}" ], "cwd": "${env:WORKING_DIR}", "startup_timeout_seconds": 30, "tool_timeout_seconds": 30 } } }, "codex_developer_message": { "codex_personality": "default", "skills_enabled": true, "skills_roots": [ ".agents/skills" ], "skills_follow_symlinks": false, "skills_max_scan_depth": 6, "skills_max_dirs_per_root": 2000, "system_message_enabled": true, "codex_developer_messages": [ { "model_regex": "^(openai/)?gpt-5\\.5$", "personality": "default", "text": "${file:${env:CONFIG_DIR}/codex/gpt-5.5/instructions_default.md}" }, { "model_regex": "^(openai/)?gpt-5\\.5$", "personality": "friendly", "text": "${file:${env:CONFIG_DIR}/codex/gpt-5.5/instructions_friendly.md}" }, { "model_regex": "^(openai/)?gpt-5\\.5$", "personality": "pragmatic", "text": "${file:${env:CONFIG_DIR}/codex/gpt-5.5/instructions_pragmatic.md}" }, { "model_regex": "^(openai/)?gpt-5\\.4$", "personality": "default", "text": "${file:${env:CONFIG_DIR}/codex/gpt-5.4/instructions_default.md}" }, { "model_regex": "^(openai/)?gpt-5\\.4$", "personality": "friendly", "text": "${file:${env:CONFIG_DIR}/codex/gpt-5.4/instructions_friendly.md}" }, { "model_regex": "^(openai/)?gpt-5\\.4$", "personality": "pragmatic", "text": "${file:${env:CONFIG_DIR}/codex/gpt-5.4/instructions_pragmatic.md}" }, { "model_regex": "^(openai/)?gpt-5\\.3-codex$", "personality": "default", "text": "${file:${env:CONFIG_DIR}/codex/gpt-5.3-codex/instructions_default.md}" }, { "model_regex": "^(openai/)?gpt-5\\.3-codex$", "personality": "friendly", "text": "${file:${env:CONFIG_DIR}/codex/gpt-5.3-codex/instructions_friendly.md}" }, { "model_regex": "^(openai/)?gpt-5\\.3-codex$", "personality": "pragmatic", "text": "${file:${env:CONFIG_DIR}/codex/gpt-5.3-codex/instructions_pragmatic.md}" }, { "model_regex": "^(openai/)?gpt-5\\.2-codex$", "personality": "default", "text": "${file:${env:CONFIG_DIR}/codex/gpt-5.2-codex/instructions_default.md}" }, { "model_regex": "^(openai/)?gpt-5\\.2-codex$", "personality": "friendly", "text": "${file:${env:CONFIG_DIR}/codex/gpt-5.2-codex/instructions_friendly.md}" }, { "model_regex": "^(openai/)?gpt-5\\.2-codex$", "personality": "pragmatic", "text": "${file:${env:CONFIG_DIR}/codex/gpt-5.2-codex/instructions_pragmatic.md}" }, { "model_regex": "^(openai/)?gpt-5\\.1-codex$", "text": "${file:${env:CONFIG_DIR}/codex/gpt-5.1-codex/instructions_default.md}" }, { "model_regex": "^(openai/)?gpt-5\\.2$", "text": "${file:${env:CONFIG_DIR}/codex/gpt-5.2/instructions_default.md}" }, { "model_regex": "^(openai/)?gpt-5\\.1$", "text": "${file:${env:CONFIG_DIR}/codex/gpt-5.1/instructions_default.md}" }, { "model_regex": "^(openai/)?gpt-5$", "text": "${file:${env:CONFIG_DIR}/codex/gpt-5/instructions_default.md}" }, { "model_regex": ".*", "text": "${file:${env:CONFIG_DIR}/codex/gpt-5/instructions_default.md}" } ], "system_message": { "template": "${file:${env:CONFIG_DIR}/system_messages/codex/template.md}", "runtime_code": "${env:CONFIG_DIR}/system_messages/helpers/select_codex_developer_message.py", "variables": { "CODEX_DEVELOPER_MESSAGE": { "inline_code": "select_codex_developer_message()" }, "CODEX_PROJECT_INSTRUCTIONS": { "code": "${env:CONFIG_DIR}/system_messages/helpers/render_codex_project_instructions.py", "runtime_code": "${env:CONFIG_DIR}/system_messages/helpers/codex_runtime.py" }, "CODEX_SKILLS_SECTION": { "text": "" }, "CODEX_ENVIRONMENT_CONTEXT": { "code": "${env:CONFIG_DIR}/system_messages/helpers/render_codex_environment_context.py", "runtime_code": "${env:CONFIG_DIR}/system_messages/helpers/codex_runtime.py" }, "TASKS": { "text": "${file:${env:CONFIG_DIR}/TASKS_v6.md}", "runtime_code": "${env:CONFIG_DIR}/system_messages/helpers/tasks_ui.py", "condition": { "inline_code": "include_tasks()" } } } } }, "cloud_script_author_codex_message": { "system_message_enabled": true, "system_message": { "template": "{{CODEX_DEVELOPER_MESSAGE}}\n\n{{CODEX_PROJECT_INSTRUCTIONS}}\n\n# Cloud Script Authoring\nYou create Crystal Lattice cloud-agent project hooks and optional project config.\n\nRead and follow the bundled authoring guide before writing files:\n`{{CLOUD_SCRIPT_AUTHORING_GUIDE_PATH}}`\n\nUse the bundled default profile scripts as examples:\n`{{CLOUD_AGENT_DEFAULT_PROFILES_PATH}}`\n\nUse AGENTS.md, README files, package manifests, lockfiles, and tests to infer the project's fresh-checkout setup and validation command. If runtime image changes are needed, follow the guide's container profile section. Do not print secrets.\n\nBefore your final response, run cheap syntax and JSON checks for generated files. Run `crystal-lattice validate-cloud --workspace-dir ` when Docker is available and practical.\n\n{{CODEX_ENVIRONMENT_CONTEXT}}\n\n{{TASKS}}", "runtime_code": "${env:CONFIG_DIR}/system_messages/helpers/select_codex_developer_message.py", "variables": { "CODEX_DEVELOPER_MESSAGE": { "inline_code": "select_codex_developer_message()" }, "CODEX_PROJECT_INSTRUCTIONS": { "code": "${env:CONFIG_DIR}/system_messages/helpers/render_codex_project_instructions.py", "runtime_code": "${env:CONFIG_DIR}/system_messages/helpers/codex_runtime.py" }, "CLOUD_SCRIPT_AUTHORING_GUIDE_PATH": { "text": "${env:BUILTIN_PLUGINS}/cloud-agent-app/docs/cloud-script-authoring.md" }, "CLOUD_AGENT_DEFAULT_PROFILES_PATH": { "text": "${env:CONFIG_DIR}/cloud-agent/profiles" }, "CODEX_ENVIRONMENT_CONTEXT": { "code": "${env:CONFIG_DIR}/system_messages/helpers/render_codex_environment_context.py", "runtime_code": "${env:CONFIG_DIR}/system_messages/helpers/codex_runtime.py" }, "TASKS": { "text": "${file:${env:CONFIG_DIR}/TASKS_v6.md}", "runtime_code": "${env:CONFIG_DIR}/system_messages/helpers/tasks_ui.py", "condition": { "inline_code": "include_tasks()" } } } } }, "qwen_system_message": { "system_message_enabled": true, "system_message": { "template": "{{INTRODUCTION_AND_CORE_MANDATES}}\n\n{{PRIMARY_WORKFLOWS}}\n\n{{OPERATIONAL_GUIDELINES}}\n\n{{GIT_SECTION}}\n\n{{TASKS}}\n\n{{AGENTS_SECTION}}", "runtime_code": "${env:CONFIG_DIR}/system_messages/helpers/runtime.py", "variables": { "INTRODUCTION_AND_CORE_MANDATES": { "files": [ "${env:CONFIG_DIR}/system_messages/qwen/introduction-and-core-mandates.md" ] }, "PRIMARY_WORKFLOWS": { "files": [ "${env:CONFIG_DIR}/system_messages/qwen/primary-workflows.md" ] }, "OPERATIONAL_GUIDELINES": { "files": [ "${env:CONFIG_DIR}/system_messages/qwen/operational-guidelines.md" ] }, "GIT_SECTION": { "files": [ "${env:CONFIG_DIR}/system_messages/qwen/git-section.md" ], "condition": { "inline_code": "is_git_repo()" } }, "TASKS": { "text": "${file:${env:CONFIG_DIR}/TASKS_v6.md}", "runtime_code": "${env:CONFIG_DIR}/system_messages/helpers/tasks_ui.py", "condition": { "inline_code": "include_tasks()" } }, "AGENTS_SECTION": { "code": "${env:CONFIG_DIR}/system_messages/helpers/render_agents_context_section.py" } } } }, "gemini_system_message": { "system_message_enabled": true, "system_message": { "template": "{{PREAMBLE}}\n\n{{CORE_MANDATES}}\n\n{{WORKFLOW_SECTION}}\n\n{{OPERATIONAL_GUIDELINES}}\n\n{{GIT_SECTION}}\n\n{{CONTEXT_FILES_SECTION}}\n\n{{TASKS}}", "runtime_code": "${env:CONFIG_DIR}/system_messages/helpers/runtime.py", "variables": { "PREAMBLE": { "files": [ "${env:CONFIG_DIR}/system_messages/gemini/preamble.md" ] }, "CORE_MANDATES": { "files": [ "${env:CONFIG_DIR}/system_messages/gemini/core-mandates.md" ] }, "WORKFLOW_SECTION": { "files": [ "${env:CONFIG_DIR}/system_messages/gemini/workflow-section.md" ] }, "OPERATIONAL_GUIDELINES": { "files": [ "${env:CONFIG_DIR}/system_messages/gemini/operational-guidelines.md" ] }, "GIT_SECTION": { "files": [ "${env:CONFIG_DIR}/system_messages/gemini/git-section.md" ], "condition": { "inline_code": "is_git_repo()" } }, "CONTEXT_FILES_SECTION": { "code": "${env:CONFIG_DIR}/system_messages/helpers/render_agents_context_section.py" }, "TASKS": { "text": "${file:${env:CONFIG_DIR}/TASKS_v6.md}", "runtime_code": "${env:CONFIG_DIR}/system_messages/helpers/tasks_ui.py", "condition": { "inline_code": "include_tasks()" } } } } }, "grok_system_message": { "system_message_enabled": true, "system_message": { "template": "{{INTRODUCTION}}\n\n{{AVAILABLE_TOOLS_SECTION}}\n\n{{TOOL_USAGE_RULES}}\n\n{{WORKFLOW_AND_RESPONSE_GUIDANCE}}\n\n{{TASKS}}\n\n{{AGENTS_SECTION}}\n\n{{WORKING_DIRECTORY_LINE}}", "runtime_code": "${env:CONFIG_DIR}/system_messages/helpers/runtime.py", "variables": { "INTRODUCTION": { "files": [ "${env:CONFIG_DIR}/system_messages/grok/introduction.md" ] }, "AVAILABLE_TOOLS_SECTION": { "files": [ "${env:CONFIG_DIR}/system_messages/grok/available-tools-section.md" ] }, "TOOL_USAGE_RULES": { "files": [ "${env:CONFIG_DIR}/system_messages/grok/tool-usage-rules.md" ] }, "WORKFLOW_AND_RESPONSE_GUIDANCE": { "files": [ "${env:CONFIG_DIR}/system_messages/grok/response-guidelines.md" ] }, "TASKS": { "text": "${file:${env:CONFIG_DIR}/TASKS_v6.md}", "runtime_code": "${env:CONFIG_DIR}/system_messages/helpers/tasks_ui.py", "condition": { "inline_code": "include_tasks()" } }, "AGENTS_SECTION": { "code": "${env:CONFIG_DIR}/system_messages/helpers/render_agents_context_section.py" }, "WORKING_DIRECTORY_LINE": { "inline_code": "f'Current working directory: {cwd}'" } } } }, "kimi_system_message": { "system_message_enabled": true, "system_message": { "template": "{{INTRODUCTION}}\n\n{{PROMPT_AND_TOOL_USE}}\n\n{{GENERAL_GUIDELINES_FOR_CODING}}\n\n{{GENERAL_GUIDELINES_FOR_RESEARCH_AND_DATA_PROCESSING}}\n\n{{WORKING_ENVIRONMENT_SECTION}}\n\n{{PROJECT_INFORMATION_SECTION}}\n\n{{TASKS}}\n\n{{ULTIMATE_REMINDERS}}", "runtime_code": "${env:CONFIG_DIR}/system_messages/helpers/runtime.py", "variables": { "INTRODUCTION": { "files": [ "${env:CONFIG_DIR}/system_messages/kimi/introduction.md" ] }, "PROMPT_AND_TOOL_USE": { "files": [ "${env:CONFIG_DIR}/system_messages/kimi/prompt-and-tool-use.md" ] }, "GENERAL_GUIDELINES_FOR_CODING": { "files": [ "${env:CONFIG_DIR}/system_messages/kimi/general-guidelines-for-coding.md" ] }, "GENERAL_GUIDELINES_FOR_RESEARCH_AND_DATA_PROCESSING": { "files": [ "${env:CONFIG_DIR}/system_messages/kimi/general-guidelines-for-research-and-data-processing.md" ] }, "WORKING_ENVIRONMENT_SECTION": { "code": "${env:CONFIG_DIR}/system_messages/helpers/render_kimi_working_environment_section.py" }, "PROJECT_INFORMATION_SECTION": { "code": "${env:CONFIG_DIR}/system_messages/helpers/render_kimi_project_information_section.py" }, "TASKS": { "text": "${file:${env:CONFIG_DIR}/TASKS_v6.md}", "runtime_code": "${env:CONFIG_DIR}/system_messages/helpers/tasks_ui.py", "condition": { "inline_code": "include_tasks()" } }, "ULTIMATE_REMINDERS": { "files": [ "${env:CONFIG_DIR}/system_messages/kimi/ultimate-reminders.md" ] } } } }, "claude_system_message": { "system_message_enabled": true, "system_message": { "template": "{{INTRODUCTION}}\n\n{{WORKFLOW}}\n\n{{TOOL_USAGE}}\n\n{{TONE_AND_STYLE}}\n\n{{MEMORY}}\n\n{{ENVIRONMENT_SECTION}}\n\n{{CONTEXT_MANAGEMENT}}\n\n{{TASKS}}\n\n{{AGENTS_SECTION}}", "runtime_code": "${env:CONFIG_DIR}/system_messages/helpers/claude_runtime.py", "variables": { "INTRODUCTION": { "files": [ "${env:BUILTIN_PLUGINS}/claude-tools/system_messages/claude/introduction.md" ] }, "WORKFLOW": { "files": [ "${env:BUILTIN_PLUGINS}/claude-tools/system_messages/claude/workflow.md" ] }, "TOOL_USAGE": { "files": [ "${env:BUILTIN_PLUGINS}/claude-tools/system_messages/claude/tool-usage.md" ] }, "TONE_AND_STYLE": { "files": [ "${env:BUILTIN_PLUGINS}/claude-tools/system_messages/claude/tone-and-style.md" ] }, "MEMORY": { "files": [ "${env:BUILTIN_PLUGINS}/claude-tools/system_messages/claude/memory.md" ] }, "ENVIRONMENT_SECTION": { "code": "${env:CONFIG_DIR}/system_messages/helpers/render_claude_environment_section.py" }, "CONTEXT_MANAGEMENT": { "files": [ "${env:BUILTIN_PLUGINS}/claude-tools/system_messages/claude/context-management.md" ] }, "TASKS": { "text": "${file:${env:CONFIG_DIR}/TASKS_v6.md}", "runtime_code": "${env:CONFIG_DIR}/system_messages/helpers/tasks_ui.py", "condition": { "inline_code": "include_tasks()" } }, "AGENTS_SECTION": { "code": "${env:CONFIG_DIR}/system_messages/helpers/render_agents_context_section.py" } } } } }, "providers": { "openrouter": { "mixin_refs": [ "provider_defaults" ], "provider": "openrouter", "api_key": "${env:OPENROUTER_API_KEY}", "base_url": "https://openrouter.ai/api/v1" }, "openrouter_image_generation": { "mixin_refs": [ "provider_defaults" ], "provider": "openrouter_image_generation", "api_key": "${env:OPENROUTER_API_KEY}", "base_url": "https://openrouter.ai/api/v1", "model": "black-forest-labs/flux.2-klein-4b", "timeout": 180, "aspect_ratio": "1:1" }, "openai": { "mixin_refs": [ "provider_defaults" ], "provider": "openai_responses", "api_key": "${env:OPENAI_API_KEY}", "base_url": "https://api.openai.com/v1", "auth_mode": "auto", "allowed_paths": [ ".", "..", "${env:CONFIG_DIR}" ], "enable_flex_processing": true, "enable_fast_mode": false, "reasoning_summary": "detailed", "enable_prompt_cache_retention_24h": true, "system_message_as_instructions": true, "strip_leading_system_or_developer_message": true, "show_estimated_cached_tokens": true, "show_openai_auth_mode": true }, "ollama": { "mixin_refs": [ "provider_defaults" ], "provider": "reference_openai_compatible", "base_url": "http://localhost:11434/v1", "model": "qwen3:0.6b", "reasoning_field": "reasoning" }, "fireworks": { "mixin_refs": [ "provider_defaults" ], "provider": "reference_openai_compatible", "base_url": "https://api.fireworks.ai/inference/v1", "api_key": "${env:FIREWORKS_API_KEY}", "model": "fireworks/gpt-oss-20b", "min_request_interval_seconds": 1, "rate_limit_retry_delays_seconds": [ 1, 2, 4 ], "reasoning_field": "reasoning_content" }, "zai": { "mixin_refs": [ "provider_defaults" ], "provider": "reference_openai_compatible", "base_url": "https://api.z.ai/api/paas/v4", "endpoint_options": { "General": "https://api.z.ai/api/paas/v4", "Coding": "https://api.z.ai/api/coding/paas/v4" }, "api_key": "${env:ZAI_API_KEY}", "model": "glm-4.5-air", "retry_on_timeout": [ 0, 5, 30 ], "retry_on_status": { "500": [ 0, 5, 30 ] } } }, "plugins": [ "path:${env:BUILTIN_PLUGINS}/openrouter", "plugins.tool_gating_feature.ToolGatingFeature", "path:${env:BUILTIN_PLUGINS}/feature-request-options", "path:${env:BUILTIN_PLUGINS}/feature-system-message", "path:${env:BUILTIN_PLUGINS}/timestamp-extension", "path:${env:BUILTIN_PLUGINS}/feature-file-context", "path:${env:BUILTIN_PLUGINS}/feature-web-context", "path:${env:BUILTIN_PLUGINS}/chatgpt-auth-app", "path:${env:BUILTIN_PLUGINS}/session-actions-app", "path:${env:BUILTIN_PLUGINS}/summarize-range-app", "path:${env:BUILTIN_PLUGINS}/session-title-app", "path:${env:BUILTIN_PLUGINS}/generate-title-app", "path:${env:BUILTIN_PLUGINS}/session-message-count-meta-app", "path:${env:BUILTIN_PLUGINS}/session-pinned-app", "path:${env:BUILTIN_PLUGINS}/session-timestamp-meta-app", "path:${env:BUILTIN_PLUGINS}/rebuild-native-app", "path:${env:BUILTIN_PLUGINS}/mcp-runtime-app", "path:${env:BUILTIN_PLUGINS}/session-ordering-buckets-app", "path:${env:BUILTIN_PLUGINS}/session-asset-attachments", "path:${env:BUILTIN_PLUGINS}/reference-openai-compatible-provider", "path:${env:BUILTIN_PLUGINS}/openai_responses", "path:${env:BUILTIN_PLUGINS}/cloud-agent-app" ], "application": { "tool_output_line_limit": 20, "tool_output_char_limit": 2000, "tool_output_short_one_line": true, "session_actions": { "allow_delete": true }, "summarize_range_prompt": "Provide a detailed but concise summary of our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.", "summarize_range_settings_overrides": { "reasoningEffort": "medium" }, "generate_title_settings_overrides": { "model": "openai/gpt-5-mini", "reasoningEffort": "low" }, "generate_title_max_messages": 999, "chatgpt_auth": {}, "cloud_agent": { "orchestrator_url": "${env:CLOUD_ORCHESTRATOR_URL}", "orchestrator_token": "${env:CLOUD_ORCHESTRATOR_TOKEN}", "workspace_dir": ".", "transfer_method": "managed_ssh", "project_config_path": "${env:WORKING_DIR}/.cloud-agent/cloud-agent.json", "startup_poll_timeout_seconds": 300, "default_container_profile": "default-runtime", "default_transfer_up_profile": "working-tree-current-state", "default_sync_down_profile": "working-tree-patch", "container_profiles": { "default-runtime": { "label": "Default Crystal Lattice cloud agent runtime", "mode": "dockerfile", "image": "cloud-agent-runtime:latest", "dockerfile": "Dockerfile", "context": [ { "source": "${env:BUILTIN_PLUGINS}/cloud-agent-app/src/cloud_agent_app/bundled_runtime/python/Dockerfile", "target": "Dockerfile" }, { "source": "${env:BUILTIN_PLUGINS}/cloud-agent-app/src/cloud_agent_app/bundled_runtime/python/runtime.py", "target": "runtime.py" }, { "source": "${env:CLOUD_RUNTIME_BUILD_CONTEXT}/python_lib", "target": "python_lib" }, { "source": "${env:CLOUD_RUNTIME_BUILD_CONTEXT}/cloud-runtime-plugin-bundle", "target": "cloud-runtime-plugin-bundle" }, { "source": "${env:CLOUD_RUNTIME_BUILD_CONTEXT}/plugin_sources", "target": "plugin_sources" } ] } }, "transfer_up_profiles": { "working-tree-current-state": { "label": "Working tree current state", "local_stage_script": "${env:WORKING_DIR}/.cloud-agent/local-stage", "local_transfer_script": "${env:CONFIG_DIR}/cloud-agent/profiles/transfer_up/working-tree-current-state/local_transfer_script", "cloud_setup_script": "${env:CONFIG_DIR}/cloud-agent/profiles/transfer_up/working-tree-current-state/cloud_setup_script", "install_script": "${env:WORKING_DIR}/.cloud-agent/install-cloud", "validation_script": "${env:WORKING_DIR}/.cloud-agent/validate-cloud", "compatible_sync_down_profiles": [ "working-tree-patch" ] }, "working-tree-current-state-branch": { "label": "Working tree current state with branch export", "local_stage_script": "${env:WORKING_DIR}/.cloud-agent/local-stage", "local_transfer_script": "${env:CONFIG_DIR}/cloud-agent/profiles/transfer_up/working-tree-current-state-branch/local_transfer_script", "cloud_setup_script": "${env:CONFIG_DIR}/cloud-agent/profiles/transfer_up/working-tree-current-state-branch/cloud_setup_script", "install_script": "${env:WORKING_DIR}/.cloud-agent/install-cloud", "validation_script": "${env:WORKING_DIR}/.cloud-agent/validate-cloud", "compatible_sync_down_profiles": [ "branch-import" ] }, "clean-head": { "label": "Clean committed HEAD", "local_stage_script": "${env:WORKING_DIR}/.cloud-agent/local-stage", "local_transfer_script": "${env:CONFIG_DIR}/cloud-agent/profiles/transfer_up/clean-head/local_transfer_script", "cloud_setup_script": "${env:CONFIG_DIR}/cloud-agent/profiles/transfer_up/clean-head/cloud_setup_script", "install_script": "${env:WORKING_DIR}/.cloud-agent/install-cloud", "validation_script": "${env:WORKING_DIR}/.cloud-agent/validate-cloud", "compatible_sync_down_profiles": [ "branch-import" ] } }, "sync_down_profiles": { "working-tree-patch": { "label": "Apply cloud patch to current worktree", "cloud_export_script": "${env:CONFIG_DIR}/cloud-agent/profiles/sync_down/working-tree-patch/cloud_export_script", "cloud_mark_synced_script": "${env:CONFIG_DIR}/cloud-agent/profiles/sync_down/working-tree-patch/cloud_mark_synced_script", "local_apply_script": "${env:CONFIG_DIR}/cloud-agent/profiles/sync_down/working-tree-patch/local_apply_script", "compatible_transfer_up_profiles": [ "working-tree-current-state" ] }, "branch-import": { "label": "Import committed cloud branch with optional safety snapshots", "cloud_export_script": "${env:CONFIG_DIR}/cloud-agent/profiles/sync_down/branch-import/cloud_export_script", "cloud_mark_synced_script": "${env:CONFIG_DIR}/cloud-agent/profiles/sync_down/branch-import/cloud_mark_synced_script", "local_apply_script": "${env:CONFIG_DIR}/cloud-agent/profiles/sync_down/branch-import/local_apply_script", "compatible_transfer_up_profiles": [ "working-tree-current-state-branch", "clean-head" ] } }, "managed_ssh": { "adapter_script": null, "ssh_path": "ssh", "rsync_path": "rsync", "extra_rsync_args": [] }, "config_assets": { "include_config_dir": true, "exclude": [ ".env", ".env.*", ".plugin_cache", "logs", "sessions", "auth", "state" ], "include_excluded": [], "max_top_level_entry_bytes": 104857600 }, "runtime_env": { "include_config_env": true, "include": [], "exclude": [ "CLOUD_ORCHESTRATOR_URL", "CLOUD_ORCHESTRATOR_TOKEN", "SERVER_HOST", "SERVER_PORT", "BRIDGE_URL", "BRIDGE_LOCAL_HTTP_BASE", "OPENAI_BASE_URL", "CONFIG_DIR", "BUILTIN_PLUGINS", "PLUGIN_CACHE_DIR" ], "env_files": [] } } }, "agents": { "cloud-script-author": { "mixin_refs": [ "codex_developer_message", "codex_mcp_defaults", "cloud_script_author_codex_message" ], "provider": "openai", "model": "gpt-5.4-mini", "allowed_paths": [ ".", "..", "${env:BUILTIN_PLUGINS}/cloud-agent-app", "${env:CONFIG_DIR}/cloud-agent/profiles" ], "plugins": [ "path:${env:BUILTIN_PLUGINS}/codex-tools" ], "shell_tool_mode": "auto" }, "codex-openrouter": { "mixin_refs": [ "codex_developer_message", "codex_mcp_defaults" ], "provider": "openrouter", "model": "openai/gpt-5.4-mini", "plugins": [ "path:${env:BUILTIN_PLUGINS}/codex-tools" ], "shell_tool_mode": "auto" }, "codex-openai": { "mixin_refs": [ "codex_developer_message", "codex_mcp_defaults" ], "provider": "openai", "model": "gpt-5.4-mini", "plugins": [ "path:${env:BUILTIN_PLUGINS}/codex-tools" ], "shell_tool_mode": "auto" }, "codex-openai-api": { "mixin_refs": [ "codex_developer_message", "codex_mcp_defaults" ], "provider": "openai", "model": "gpt-5.4", "plugins": [ "path:${env:BUILTIN_PLUGINS}/codex-tools" ], "shell_tool_mode": "auto", "auth_mode": "api", "system_message_as_instructions": false, "strip_leading_system_or_developer_message": false }, "gpt-5-openrouter": { "mixin_refs": [ "codex_developer_message", "codex_mcp_defaults" ], "provider": "openrouter", "model": "openai/gpt-5", "plugins": [ "path:${env:BUILTIN_PLUGINS}/codex-tools" ], "shell_tool_mode": "shell", "intercept_apply_patch": true, "disabled_plugins": [ "apply_patch" ] }, "gpt-5-openai": { "mixin_refs": [ "codex_developer_message", "codex_mcp_defaults" ], "provider": "openai", "model": "gpt-5", "plugins": [ "path:${env:BUILTIN_PLUGINS}/codex-tools" ], "shell_tool_mode": "shell", "intercept_apply_patch": true, "disabled_plugins": [ "apply_patch" ] }, "qwen": { "mixin_refs": [ "qwen_system_message" ], "provider": "openrouter", "model": "qwen/qwen3.5-flash-02-23", "plugins": [ "path:${env:BUILTIN_PLUGINS}/qwen-tools" ], "disabled_plugins": [ "apply_patch" ] }, "gemini": { "mixin_refs": [ "gemini_system_message" ], "provider": "openrouter", "model": "google/gemini-2.5-flash-lite", "plugins": [ "path:${env:BUILTIN_PLUGINS}/gemini-tools" ], "disabled_plugins": [ "apply_patch" ], "gemini_api_key": "${env:GEMINI_API_KEY}", "tavily_api_key": "${env:TAVILY_API_KEY}", "web_search_enabled": true, "web_search_provider": "gemini_api" }, "grok": { "mixin_refs": [ "grok_system_message" ], "provider": "openrouter", "model": "x-ai/grok-4.1-fast", "plugins": [ "path:${env:BUILTIN_PLUGINS}/grok-tools", "path:${env:BUILTIN_PLUGINS}/qwen-tools" ], "disabled_plugins": [ "qwen_read_file_tool", "qwen_write_file_tool", "qwen_edit_tool" ] }, "kimi": { "mixin_refs": [ "kimi_system_message" ], "provider": "openrouter", "model": "moonshotai/kimi-k2.5", "plugins": [ "path:${env:BUILTIN_PLUGINS}/kimi-tools" ], "disabled_plugins": [ "apply_patch" ], "gemini_api_key": "${env:GEMINI_API_KEY}", "tavily_api_key": "${env:TAVILY_API_KEY}", "web_search_enabled": true, "web_search_provider": "gemini_api" }, "claude": { "mixin_refs": [ "claude_system_message", "claude_mcp_defaults" ], "provider": "openrouter", "model": "anthropic/claude-haiku-4.5", "plugins": [ "path:${env:BUILTIN_PLUGINS}/claude-tools" ], "reasoning_max_tokens": 16000, "request_options": { "provider": { "order": [ "anthropic" ], "allow_fallbacks": false } }, "gemini_api_key": "${env:GEMINI_API_KEY}", "tavily_api_key": "${env:TAVILY_API_KEY}", "web_search_enabled": true, "web_search_provider": "gemini_api" }, "glm": { "mixin_refs": [ "gemini_system_message" ], "provider": "openrouter", "model": "z-ai/glm-5", "plugins": [ "path:${env:BUILTIN_PLUGINS}/gemini-tools" ], "disabled_plugins": [ "apply_patch" ], "gemini_api_key": "${env:GEMINI_API_KEY}", "tavily_api_key": "${env:TAVILY_API_KEY}", "web_search_enabled": true, "web_search_provider": "gemini_api", "request_options": { "provider": { "order": [ "z-ai" ], "allow_fallbacks": false } } }, "ollama": { "provider": "ollama", "model": "qwen3:0.6b" }, "fireworks": { "provider": "fireworks", "model": "fireworks/gpt-oss-20b", "request_options": { "reasoning_history": "preserved" }, "request_options_ui": { "reasoning_history": [ "disabled", "interleaved", "preserved" ] } }, "zai": { "provider": "zai", "model": "glm-4.5-air", "request_options": { "thinking": { "type": "enabled", "clear_thinking": false } }, "request_options_ui": { "thinking": { "clear_thinking": "boolean" } } }, "openrouter-image": { "provider": "openrouter_image_generation" } } } ```