From 5f95726218d914d4f2a4c829212aeca205644884 Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:56:57 +0100 Subject: [PATCH 1/6] fix: make SkillMDLoader tests hermetic (#2) * fix: make SkillMDLoader tests hermetic via auto-discovery mocks Patch _discover_via_entry_points and _discover_via_package_data in test_load_invalidated_when_mtime_changes and test_invalidate_cache_forces_reparse so they do not pick up SKILL.md files installed in the workspace environment, making the assertions deterministic regardless of installed packages. * docs: NLnet/NGI0 funding attribution --- README.md | 8 ++++++++ test/test_loader.py | 28 +++++++++++++++++----------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 6f5a876..d2c18de 100644 --- a/README.md +++ b/README.md @@ -155,3 +155,11 @@ messages = ctx.build_conversation_context(utterance, session_id="s1") ## License Apache 2.0 — see [LICENSE](LICENSE). + +## Credits + +Developed by [TigreGotico](https://tigregotico.pt) for [OpenVoiceOS](https://openvoiceos.org). + +Funded by [NGI0 Commons Fund](https://nlnet.nl/project/OpenVoiceOS) / [NLnet](https://nlnet.nl) +under grant agreement No [101135429](https://cordis.europa.eu/project/id/101135429), +through the European Commission's [Next Generation Internet](https://ngi.eu) programme. diff --git a/test/test_loader.py b/test/test_loader.py index 16537f6..0127917 100644 --- a/test/test_loader.py +++ b/test/test_loader.py @@ -122,22 +122,28 @@ def test_load_invalidated_when_mtime_changes(self, tmp_path: "Path") -> None: p = tmp_path / "SKILL.md" p.write_text(VALID_SKILL_MD, encoding="utf-8") loader = SkillMDLoader(extra_paths=[str(p)]) - first = loader.load() - # Touch the file to update mtime. - time.sleep(0.01) - p.write_text(VALID_SKILL_MD.replace("web-search", "updated-search"), encoding="utf-8") - second = loader.load() - assert second[0].name == "updated-search" - assert first[0].name != second[0].name + # Suppress auto-discovery so only the extra_path entry is loaded. + with patch("ovos_agentic_loop.skills.loader._discover_via_entry_points", return_value=[]), \ + patch("ovos_agentic_loop.skills.loader._discover_via_package_data", return_value=[]): + first = loader.load() + # Touch the file to update mtime. + time.sleep(0.01) + p.write_text(VALID_SKILL_MD.replace("web-search", "updated-search"), encoding="utf-8") + second = loader.load() + assert any(e.name == "updated-search" for e in second) + assert not any(e.name == "updated-search" for e in first) def test_invalidate_cache_forces_reparse(self, tmp_path: "Path") -> None: p = tmp_path / "SKILL.md" p.write_text(VALID_SKILL_MD, encoding="utf-8") loader = SkillMDLoader(extra_paths=[str(p)]) - loader.load() - loader.invalidate_cache() - assert loader._cache is None - entries = loader.load() + # Suppress auto-discovery so the result count is deterministic. + with patch("ovos_agentic_loop.skills.loader._discover_via_entry_points", return_value=[]), \ + patch("ovos_agentic_loop.skills.loader._discover_via_package_data", return_value=[]): + loader.load() + loader.invalidate_cache() + assert loader._cache is None + entries = loader.load() assert len(entries) == 1 From 6ba81071092610f6879e13d32cae74c8910357a8 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:57:07 +0000 Subject: [PATCH 2/6] Increment Version to 0.1.1a1 --- ovos_agentic_loop/version.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ovos_agentic_loop/version.py b/ovos_agentic_loop/version.py index ba0231d..43e3eb2 100644 --- a/ovos_agentic_loop/version.py +++ b/ovos_agentic_loop/version.py @@ -13,8 +13,8 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 VERSION_MINOR = 1 -VERSION_BUILD = 0 -VERSION_ALPHA = 0 +VERSION_BUILD = 1 +VERSION_ALPHA = 1 # END_VERSION_BLOCK __version__ = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}" + (f"a{VERSION_ALPHA}" if VERSION_ALPHA else "") From b08d677bb31b4accdafd807d55b6606ba79c59e1 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:57:26 +0000 Subject: [PATCH 3/6] Update Changelog --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2ecd35f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +## [0.1.1a1](https://github.com/TigreGotico/ovos-agentic-loop/tree/0.1.1a1) (2026-06-10) + +[Full Changelog](https://github.com/TigreGotico/ovos-agentic-loop/compare/0.1.0...0.1.1a1) + +**Merged pull requests:** + +- fix: make SkillMDLoader tests hermetic [\#2](https://github.com/TigreGotico/ovos-agentic-loop/pull/2) ([JarbasAl](https://github.com/JarbasAl)) + + + +\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* From 0e8111b0e33ebf3202c9ecb367e55e5f5b41fd6c Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:29:25 +0100 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20NativeToolCallEngine=20=E2=80=94=20?= =?UTF-8?q?provider-native=20tool-calling=20loop=20(#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: NativeToolCallEngine — provider-native tool-calling loop Add an agent loop that uses the brain's native function-calling: it passes the ToolBox objects to continue_chat(tools=...), reads structured AgentMessage.tool_calls back, executes them via the existing ToolBox machinery, and feeds results back as MessageRole.TOOL messages until the brain answers. Subclasses ReActLoopEngine and falls back to the ReAct text loop when the brain lacks supports_tools, so it works with any brain. Registered as opm.agents.chat entry point ovos-native-toolcall-loop. Tests, docs (native-toolcall-loop.md + loop-architectures/index), and an example. CI installs the ovos-plugin-manager tool-calling branch until it releases. Co-Authored-By: Claude Opus 4.8 * test: real-stack native tool-call e2e + fix example toolbox init Add test_native_toolcall_e2e: drives NativeToolCallEngine with a real OpenAIChatEngine brain (HTTP mocked) and the real MathToolBox, asserting a full tool round-trip where evaluate_expression actually computes 12*9=108 and the result is serialized back as a TOOL message. Only the LLM HTTP is mocked. Adds ovos-openai-plugin as a test brain (CI installs the opm-agents branch). Also fixes the example: MathToolBox() takes no toolbox_id. Co-Authored-By: Claude Opus 4.8 * fix(deps): pin released ovos-plugin-manager>=2.7.0a1; drop opm CI git-ref The tool-calling contract NativeToolCallEngine relies on (ToolCall, tools=, supports_tools) is published in ovos-plugin-manager 2.7.0a1, so pin the floor in pyproject and drop the opm git-ref from build/coverage. The real-stack e2e brain still installs ovos-openai-plugin@opm-agents via pre_install_pip until that migration releases. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(deps): pin released ovos-openai-plugin>=2.0.8a1; drop all CI git-refs The opm-agents OpenAIChatEngine the real-stack e2e brain uses is published in ovos-openai-plugin 2.0.8a1, so pin it in the test extra and remove the last pre_install_pip git-ref from build-tests and coverage. Coverage now installs the 'test' extra so the brain comes from PyPI. CI is fully git-ref-free. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(ci): use test_extras to install .[test] in coverage job The coverage reusable workflow installs the package extra via test_extras (the install_extras input is literal pip args). Set test_extras: test so the pinned ovos-openai-plugin e2e brain is installed from PyPI. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 --- .github/workflows/coverage.yml | 2 +- docs/index.md | 2 + docs/loop-architectures.md | 21 +++- docs/native-toolcall-loop.md | 66 +++++++++++++ examples/native_toolcall_persona.json | 21 ++++ examples/native_toolcall_persona.py | 86 +++++++++++++++++ ovos_agentic_loop/factory.py | 14 +++ ovos_agentic_loop/native_toolcall.py | 104 ++++++++++++++++++++ pyproject.toml | 7 +- test/test_native_toolcall.py | 133 ++++++++++++++++++++++++++ test/test_native_toolcall_e2e.py | 110 +++++++++++++++++++++ 11 files changed, 561 insertions(+), 5 deletions(-) create mode 100644 docs/native-toolcall-loop.md create mode 100644 examples/native_toolcall_persona.json create mode 100644 examples/native_toolcall_persona.py create mode 100644 ovos_agentic_loop/native_toolcall.py create mode 100644 test/test_native_toolcall.py create mode 100644 test/test_native_toolcall_e2e.py diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 867f573..c7f1f1f 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -13,5 +13,5 @@ jobs: python_version: '3.11' coverage_source: 'ovos_agentic_loop' test_path: 'test/' - install_extras: '' + test_extras: 'test' # install .[test] (pulls the ovos-openai-plugin e2e brain) min_coverage: 0 diff --git a/docs/index.md b/docs/index.md index 748fca0..d026351 100644 --- a/docs/index.md +++ b/docs/index.md @@ -23,6 +23,8 @@ All components integrate with `ovos-plugin-manager` (OPM) via entry points and a | `AgenticLoopEngine` | `ovos_agentic_loop/base.py:8` | — (abstract base) | — | | `ReActLoopEngine` | `ovos_agentic_loop/react.py:92` | — (concrete) | — | | `ReActLoopEnginePlugin` | `ovos_agentic_loop/factory.py:8` | `opm.agents.chat` | `ovos-react-loop` | +| `NativeToolCallEngine` | `ovos_agentic_loop/native_toolcall.py` | — (concrete) | — | +| `NativeToolCallEnginePlugin` | `ovos_agentic_loop/factory.py` | `opm.agents.chat` | `ovos-native-toolcall-loop` | | `PlanAndExecuteEngine` | `ovos_agentic_loop/plan_execute.py:108` | — (concrete) | — | | `PlanAndExecuteEnginePlugin` | `ovos_agentic_loop/factory.py:27` | `opm.agents.chat` | `ovos-plan-execute-loop` | | `ReflexionEngine` | `ovos_agentic_loop/reflexion.py:82` | — (concrete) | — | diff --git a/docs/loop-architectures.md b/docs/loop-architectures.md index 2cf72a7..affc13b 100644 --- a/docs/loop-architectures.md +++ b/docs/loop-architectures.md @@ -11,7 +11,8 @@ | Use-case signal | Recommended loop | | :--- | :--- | | No tools, pure reasoning / arithmetic / logic | **Chain-of-Thought** | -| Single-turn tool use, general assistant | **ReAct** | +| Tool use with a brain that has native function-calling | **Native Tool-Call** | +| Single-turn tool use, any text brain (no native tools) | **ReAct** | | Multi-step task with distinct, sequenced sub-goals | **Plan-and-Execute** | | Correctness matters; agent may fail on first attempt | **Reflexion** | | Multi-hop knowledge question (chain of facts) | **Self-Ask** | @@ -20,7 +21,23 @@ The loops are not mutually exclusive. Reflexion *wraps* ReAct internally, so it inherits every ReAct capability while adding the self-correction outer loop. -Plan-and-Execute uses its own mini-ReAct sub-loop per step. +Plan-and-Execute uses its own mini-ReAct sub-loop per step. **Native Tool-Call** +subclasses ReAct and falls back to it when the brain has no native function-calling. + +--- + +## Native Tool-Call — provider-native function calling + +**Entry point:** `ovos-native-toolcall-loop` +**Class:** `NativeToolCallEngine` — `ovos_agentic_loop/native_toolcall.py` +**Deep dive:** [native-toolcall-loop.md](native-toolcall-loop.md) + +Hands the toolboxes to the brain via `continue_chat(tools=...)` and reads structured +`AgentMessage.tool_calls` back, executing each and feeding results as +`MessageRole.TOOL` messages. Requires a brain with `supports_tools = True` (e.g. +`ovos-openai-plugin`); otherwise it transparently falls back to the ReAct text loop. +Prefer this over ReAct whenever the brain supports native tool-calling — it is more +reliable and cheaper (the provider parses the calls, not a regex). --- diff --git a/docs/native-toolcall-loop.md b/docs/native-toolcall-loop.md new file mode 100644 index 0000000..cdf00c4 --- /dev/null +++ b/docs/native-toolcall-loop.md @@ -0,0 +1,66 @@ +# NativeToolCallEngine — Deep Dive + +## Overview + +`NativeToolCallEngine` (`ovos_agentic_loop/native_toolcall.py`) drives a tool loop +using the brain's **native** function-calling instead of ReAct's text protocol. It +hands the toolboxes to the brain via `continue_chat(..., tools=...)` and reads back +**structured** `AgentMessage.tool_calls`, executes each through the existing +`ToolBox` machinery, and feeds the results back as `MessageRole.TOOL` messages until +the brain answers without requesting a tool. + +It is a subclass of `ReActLoopEngine` (and therefore `AgenticLoopEngine` → +`ChatEngine`), reusing its brain wiring, toolbox loading, schema collection and tool +execution. Only the loop in `continue_chat` differs. + +`NativeToolCallEnginePlugin` (`ovos_agentic_loop/factory.py`) is the OPM-registered +wrapper. Entry point: **`ovos-native-toolcall-loop`** (group `opm.agents.chat`). + +## Native vs. ReAct — and the fallback + +| | NativeToolCallEngine | ReActLoopEngine | +| --- | --- | --- | +| How the model calls a tool | provider-native `tool_calls` | `Action:`/`Action Input:` text, regex-parsed | +| Tool result fed back as | `MessageRole.TOOL` message | `Observation:` text in a user message | +| Brain requirement | `supports_tools = True` | any text ChatEngine | +| Reliability / token cost | higher / lower (provider parses) | lower / higher (prompt + parse) | + +**Fallback:** if the configured brain does not advertise `supports_tools`, +`continue_chat` transparently delegates to the inherited ReAct text loop +(`super().continue_chat(...)`). So this engine works with **any** brain — it just +uses the better path when the brain supports it. + +## The loop + +1. **No brain** → return an error `AgentMessage`. +2. **Brain lacks `supports_tools`** → `return super().continue_chat(...)` (ReAct). +3. Otherwise iterate up to `max_iterations`: + - `resp = brain.continue_chat(loop_messages, tools=self.toolboxes)` — the + `ToolBox` objects are passed straight through; the brain normalizes them with + `ToolBox.normalize_tools` to its provider's tool format. + - If `resp.tool_calls` is empty → return `AgentMessage(ASSISTANT, resp.content)`. + - Else append the assistant turn (carrying `tool_calls`) **first**, then one + `MessageRole.TOOL` message per `ToolCall` (with `tool_call_id`/`name`), + executing via the inherited `_call_tool`. The assistant-before-tool ordering + is the invariant providers require. +4. **Max iterations** → one final tool-free `continue_chat(..., tools=None)` to + force a text answer. + +## Config + +| Key | Meaning | Default | +| --- | --- | --- | +| `brain` | ChatEngine plugin id used as the inner LLM (must set `supports_tools` to use native calls) | — | +| `toolboxes` | ToolBox plugin ids to load | — | +| `max_iterations` | Max tool-call cycles before forcing an answer | 10 | + +## Streaming + +Tool calls are driven through the non-streaming `continue_chat`. Token/sentence +streaming applies only to the terminal answer; structured `tool_calls` are not +streamed. + +## Example + +See [`examples/native_toolcall_persona.json`](../examples/native_toolcall_persona.json) +and [`examples/native_toolcall_persona.py`](../examples/native_toolcall_persona.py). diff --git a/examples/native_toolcall_persona.json b/examples/native_toolcall_persona.json new file mode 100644 index 0000000..b9301ad --- /dev/null +++ b/examples/native_toolcall_persona.json @@ -0,0 +1,21 @@ +{ + "_comment": "Native tool-calling persona for ovos-persona. See native_toolcall_persona.py for the full example.", + "name": "Calculator", + "solvers": [ + "ovos-native-toolcall-loop" + ], + "ovos-native-toolcall-loop": { + "brain": "ovos-chat-openai-plugin", + "max_iterations": 5, + "system_prompt": "You are a precise assistant. Use the math tools to compute answers instead of guessing.", + "toolboxes": [ + "ovos-math-tools" + ], + "ovos-chat-openai-plugin": { + "_comment": "Needs a brain whose ChatEngine sets supports_tools=True (native function-calling). Point api_url at any OpenAI-compatible server with a tool-capable model.", + "model": "qwen2.5", + "api_url": "http://localhost:11434/v1/chat/completions", + "api_key": "not-needed-for-local" + } + } +} diff --git a/examples/native_toolcall_persona.py b/examples/native_toolcall_persona.py new file mode 100644 index 0000000..35418ac --- /dev/null +++ b/examples/native_toolcall_persona.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +# Copyright 2025, OpenVoiceOS +# +# 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 +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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. +"""Native tool-calling demo — NativeToolCallEngine + MathToolBox + a local LLM. + +Unlike the ReAct examples, this drives the brain's *native* function-calling: the +toolboxes are handed to the brain via ``continue_chat(tools=...)`` and structured +``tool_calls`` come back, so it needs a brain whose ChatEngine sets +``supports_tools = True`` (e.g. ``ovos-openai-plugin`` against a tool-capable model). +If the brain lacks native tool support, NativeToolCallEngine falls back to ReAct. + +Run:: + + python examples/native_toolcall_persona.py + +Prerequisites:: + + pip install ovos-agentic-loop ovos-openai-plugin + # a local OpenAI-compatible server with a tool-capable model, e.g.: + # ollama serve && ollama pull qwen2.5 + +Environment overrides: CALC_MODEL, CALC_API_URL. +""" +import os +import sys + +_missing = [] +try: + from ovos_agentic_loop.native_toolcall import NativeToolCallEngine +except ImportError: + _missing.append("ovos-agentic-loop") +try: + from ovos_openai_plugin.chat import OpenAIChatEngine # type: ignore[import-untyped] +except ImportError: + _missing.append("ovos-openai-plugin") +try: + from ovos_agentic_loop.tools.math import MathToolBox +except ImportError: + _missing.append("ovos-agentic-loop[math]") + +if _missing: + sys.exit(f"Missing dependencies: {', '.join(_missing)}") + + +def main() -> None: + brain = OpenAIChatEngine({ + "model": os.environ.get("CALC_MODEL", "qwen2.5"), + "api_url": os.environ.get("CALC_API_URL", + "http://localhost:11434/v1/chat/completions"), + "api_key": "not-needed-for-local", + "system_prompt": "You are a precise assistant. Use the math tools to " + "compute answers instead of guessing.", + }) + if not getattr(brain, "supports_tools", False): + print("NOTE: this brain reports no native tool support — the engine will " + "fall back to the ReAct text loop.") + + engine = NativeToolCallEngine({"max_iterations": 5}) + engine.set_brain(brain) + engine.load_toolboxes([MathToolBox()]) + + print("Calculator agent — ask a math question (Ctrl-C to quit).\n") + try: + while True: + query = input("you> ").strip() + if not query: + continue + answer = engine.get_response(query) + print(f"bot> {answer}\n") + except (KeyboardInterrupt, EOFError): + print("\nbye!") + + +if __name__ == "__main__": + main() diff --git a/ovos_agentic_loop/factory.py b/ovos_agentic_loop/factory.py index 9ed3072..3f27568 100644 --- a/ovos_agentic_loop/factory.py +++ b/ovos_agentic_loop/factory.py @@ -13,6 +13,7 @@ """OPM entry-point factory classes for ovos-agentic-loop.""" from ovos_agentic_loop.chain_of_thought import ChainOfThoughtEngine from ovos_agentic_loop.critic import CRITICEngine +from ovos_agentic_loop.native_toolcall import NativeToolCallEngine from ovos_agentic_loop.plan_execute import PlanAndExecuteEngine from ovos_agentic_loop.react import ReActLoopEngine from ovos_agentic_loop.reflexion import ReflexionEngine @@ -21,6 +22,7 @@ __all__ = [ "ReActLoopEnginePlugin", + "NativeToolCallEnginePlugin", "PlanAndExecuteEnginePlugin", "ReflexionEnginePlugin", "SelfAskEnginePlugin", @@ -30,6 +32,18 @@ ] +class NativeToolCallEnginePlugin(NativeToolCallEngine): + """ + OPM-registered plugin class for the native tool-calling loop engine. + + Uses the brain's native ``tool_calls`` when it advertises ``supports_tools``, + falling back to the ReAct text loop otherwise. + + Entry point group: ``opm.agents.chat`` + Entry point name: ``ovos-native-toolcall-loop`` + """ + + class ReActLoopEnginePlugin(ReActLoopEngine): """ OPM-registered plugin class for the ReAct loop engine. diff --git a/ovos_agentic_loop/native_toolcall.py b/ovos_agentic_loop/native_toolcall.py new file mode 100644 index 0000000..e098049 --- /dev/null +++ b/ovos_agentic_loop/native_toolcall.py @@ -0,0 +1,104 @@ +# 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 +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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. + +"""NativeToolCallEngine — agent loop driven by provider-native tool calls. + +Unlike :class:`ReActLoopEngine`, which prompts the brain to emit ``Action:`` / +``Observation:`` text and regex-parses it, this engine hands the toolbox schemas +to the brain via the ``tools`` argument and reads back **structured** +``AgentMessage.tool_calls``. It executes each call through the existing +``ToolBox`` machinery and feeds the results back as ``MessageRole.TOOL`` messages +until the brain answers without requesting tools. + +When the configured brain does not advertise ``supports_tools`` (i.e. has no +native function-calling), this engine transparently falls back to the inherited +ReAct text loop, so it works with any brain. +""" +from typing import Any, Dict, List, Optional + +from ovos_plugin_manager.templates.agents import AgentMessage, MessageRole + +from ovos_agentic_loop.react import ReActLoopEngine + + +class NativeToolCallEngine(ReActLoopEngine): + """Agentic loop using native ``tool_calls`` with a ReAct text fallback. + + Reuses ``ReActLoopEngine``'s brain wiring, toolbox loading, schema collection + and tool execution; only the loop in :meth:`continue_chat` differs. + + Config keys (inherited): ``brain`` (ChatEngine plugin id), ``toolboxes`` + (List[str]), ``max_iterations`` (int, default 10). + + Entry point group: ``opm.agents.chat`` + """ + + def continue_chat(self, messages: List[AgentMessage], + session_id: str = "default", + lang: Optional[str] = None, + units: Optional[str] = None, + tools: Optional[List[Dict[str, Any]]] = None) -> AgentMessage: + """Run the native tool-calling loop and return the final assistant message. + + If the brain lacks ``supports_tools`` this delegates to the ReAct text + loop (``super().continue_chat``). Otherwise it offers the toolbox schemas + to the brain and, while the brain returns ``tool_calls``, executes each + and appends a ``MessageRole.TOOL`` result (the assistant turn carrying the + ``tool_calls`` is appended first, preserving the provider ordering + invariant). On reaching ``max_iterations`` it makes one final tool-free + call to force a text answer. + + Args: + messages: Conversation history including the latest user turn. + session_id / lang / units: Forwarded to the brain. + tools: Ignored — schemas are collected from the registered toolboxes. + + Returns: + ``AgentMessage`` with ``MessageRole.ASSISTANT`` containing the answer. + """ + if self.brain is None: + return AgentMessage(role=MessageRole.ASSISTANT, + content="Error: no brain configured.") + + # No native function-calling on this brain → ReAct text loop. + if not getattr(self.brain, "supports_tools", False): + return super().continue_chat(messages, session_id, lang, units) + + # Pass the ToolBox objects straight through; the brain normalizes them + # (via ToolBox.normalize_tools) to its provider's tool format. + tools = list(self.toolboxes) + loop_messages: List[AgentMessage] = list(messages) + + for _ in range(self.max_iterations): + response = self.brain.continue_chat( + loop_messages, session_id=session_id, lang=lang, units=units, + tools=tools, + ) + if not response.tool_calls: + return AgentMessage(role=MessageRole.ASSISTANT, content=response.content) + + # Assistant turn carrying tool_calls must precede its TOOL results. + loop_messages.append(response) + for tc in response.tool_calls: + observation = self._call_tool(tc.name, tc.arguments) + loop_messages.append(AgentMessage( + role=MessageRole.TOOL, + content=observation, + tool_call_id=tc.id, + name=tc.name, + )) + + # Max iterations reached — one final, tool-free call to force an answer. + final = self.brain.continue_chat( + loop_messages, session_id=session_id, lang=lang, units=units, tools=None, + ) + return AgentMessage(role=MessageRole.ASSISTANT, content=final.content) diff --git a/pyproject.toml b/pyproject.toml index 4f6fe2b..0683cf7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ license = "Apache-2.0" authors = [{name = "OpenVoiceOS", email = "openvoiceos@gmail.com"}] requires-python = ">=3.10" dependencies = [ - "ovos-plugin-manager>=2.3.0a1,<3.0.0", + "ovos-plugin-manager>=2.7.0a1,<3.0.0", "pydantic>=2.0.0", ] @@ -20,11 +20,14 @@ Homepage = "https://github.com/OpenVoiceOS/ovos-agentic-loop" Repository = "https://github.com/OpenVoiceOS/ovos-agentic-loop" [project.optional-dependencies] -test = ["pytest", "pytest-cov"] +# ovos-openai-plugin is the reference tool-capable ChatEngine used by the +# real-stack native tool-call integration test (test/test_native_toolcall_e2e.py). +test = ["pytest", "pytest-cov", "ovos-openai-plugin>=2.0.8a1", "requests"] web = ["duckduckgo-search>=6.0"] [project.entry-points."opm.agents.chat"] ovos-react-loop = "ovos_agentic_loop.factory:ReActLoopEnginePlugin" +ovos-native-toolcall-loop = "ovos_agentic_loop.factory:NativeToolCallEnginePlugin" ovos-plan-execute-loop = "ovos_agentic_loop.factory:PlanAndExecuteEnginePlugin" ovos-reflexion-loop = "ovos_agentic_loop.factory:ReflexionEnginePlugin" ovos-self-ask-loop = "ovos_agentic_loop.factory:SelfAskEnginePlugin" diff --git a/test/test_native_toolcall.py b/test/test_native_toolcall.py new file mode 100644 index 0000000..f0cf969 --- /dev/null +++ b/test/test_native_toolcall.py @@ -0,0 +1,133 @@ +# 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 +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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. + +"""Unit tests for NativeToolCallEngine.""" +from typing import List +from unittest.mock import MagicMock + +from ovos_plugin_manager.templates.agents import AgentMessage, MessageRole, ToolCall +from ovos_agentic_loop.native_toolcall import NativeToolCallEngine +from ovos_agentic_loop.react import _FINAL_ANSWER_TOKEN + + +def _tool_brain(responses: List[AgentMessage]) -> MagicMock: + """Mock tool-capable ChatEngine returning the given AgentMessages in order.""" + brain = MagicMock() + brain.supports_tools = True + brain.continue_chat.side_effect = responses + return brain + + +def _calc_toolbox(result: str = "3") -> MagicMock: + """Mock ToolBox exposing a single 'calc' tool.""" + tb = MagicMock() + tb.tool_json_list = [{ + "name": "calc", + "description": "Add numbers.", + "argument_schema": {"type": "object", "properties": {"a": {"type": "integer"}}}, + }] + tb.get_tool.return_value = MagicMock() + tb.call_tool.return_value = result + return tb + + +class TestNativeToolCallEngine: + def test_single_tool_then_answer(self) -> None: + engine = NativeToolCallEngine() + engine.set_brain(_tool_brain([ + AgentMessage(role=MessageRole.ASSISTANT, content="", + tool_calls=[ToolCall(id="c1", name="calc", arguments={"a": 1, "b": 2})]), + AgentMessage(role=MessageRole.ASSISTANT, content="3"), + ])) + tb = _calc_toolbox("3") + engine.load_toolboxes([tb]) + + result = engine.continue_chat([AgentMessage(role=MessageRole.USER, content="1+2?")]) + assert result.role == MessageRole.ASSISTANT + assert result.content == "3" + tb.call_tool.assert_called_once_with("calc", {"a": 1, "b": 2}) + + # The 2nd brain call must include the assistant tool_calls turn + a TOOL + # result carrying the matching tool_call_id. + second_call_messages = engine.brain.continue_chat.call_args_list[1].args[0] + tool_msgs = [m for m in second_call_messages if m.role == MessageRole.TOOL] + assert tool_msgs and tool_msgs[0].tool_call_id == "c1" + assert tool_msgs[0].content == "3" + assert any(m.role == MessageRole.ASSISTANT and m.tool_calls + for m in second_call_messages) + + def test_no_tool_calls_immediate_answer(self) -> None: + engine = NativeToolCallEngine() + engine.set_brain(_tool_brain([ + AgentMessage(role=MessageRole.ASSISTANT, content="hello"), + ])) + tb = _calc_toolbox() + engine.load_toolboxes([tb]) + + result = engine.continue_chat([AgentMessage(role=MessageRole.USER, content="hi")]) + assert result.content == "hello" + tb.call_tool.assert_not_called() + + def test_multiple_tool_calls_one_turn(self) -> None: + engine = NativeToolCallEngine() + engine.set_brain(_tool_brain([ + AgentMessage(role=MessageRole.ASSISTANT, content="", + tool_calls=[ToolCall(id="c1", name="calc", arguments={"a": 1}), + ToolCall(id="c2", name="calc", arguments={"a": 2})]), + AgentMessage(role=MessageRole.ASSISTANT, content="done"), + ])) + tb = _calc_toolbox("ok") + engine.load_toolboxes([tb]) + + result = engine.continue_chat([AgentMessage(role=MessageRole.USER, content="go")]) + assert result.content == "done" + assert tb.call_tool.call_count == 2 + second_call_messages = engine.brain.continue_chat.call_args_list[1].args[0] + tool_ids = [m.tool_call_id for m in second_call_messages if m.role == MessageRole.TOOL] + assert tool_ids == ["c1", "c2"] + + def test_max_iterations_forces_final_answer(self) -> None: + engine = NativeToolCallEngine({"max_iterations": 2}) + # Always asks for a tool → loop exhausts, then one tool-free final call. + tool_resp = lambda: AgentMessage( # noqa: E731 + role=MessageRole.ASSISTANT, content="", + tool_calls=[ToolCall(id="c", name="calc", arguments={})]) + engine.set_brain(_tool_brain([ + tool_resp(), tool_resp(), + AgentMessage(role=MessageRole.ASSISTANT, content="forced answer"), + ])) + engine.load_toolboxes([_calc_toolbox()]) + + result = engine.continue_chat([AgentMessage(role=MessageRole.USER, content="loop")]) + assert result.content == "forced answer" + # 2 loop iterations + 1 final tool-free call + assert engine.brain.continue_chat.call_count == 3 + assert engine.brain.continue_chat.call_args_list[-1].kwargs.get("tools") is None + + def test_fallback_to_react_when_brain_lacks_tools(self) -> None: + engine = NativeToolCallEngine() + brain = MagicMock() + brain.supports_tools = False + brain.continue_chat.side_effect = [ + AgentMessage(role=MessageRole.ASSISTANT, + content=f"Thought: easy.\n{_FINAL_ANSWER_TOKEN} 42"), + ] + engine.set_brain(brain) + + result = engine.continue_chat([AgentMessage(role=MessageRole.USER, content="6*7?")]) + # ReAct text loop parsed the FINAL_ANSWER token. + assert result.content == "42" + + def test_no_brain_returns_error(self) -> None: + engine = NativeToolCallEngine() + result = engine.continue_chat([AgentMessage(role=MessageRole.USER, content="hi")]) + assert "Error" in result.content diff --git a/test/test_native_toolcall_e2e.py b/test/test_native_toolcall_e2e.py new file mode 100644 index 0000000..0b4e456 --- /dev/null +++ b/test/test_native_toolcall_e2e.py @@ -0,0 +1,110 @@ +# 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 +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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. + +"""Real-stack end-to-end test for NativeToolCallEngine. + +Wires the whole tool-calling stack with REAL classes and a single mocked boundary +(the LLM HTTP call): + + NativeToolCallEngine (ovos-agentic-loop) + -> OpenAIChatEngine.continue_chat(tools=ToolBox) (ovos-openai-plugin, real) + -> ToolBox.normalize_tools -> /chat/completions (HTTP mocked) + <- structured tool_calls -> ToolCall (real contract) + -> MathToolBox.call_tool("evaluate_expression", ...) (real execution) + -> MessageRole.TOOL result -> re-serialized (real) + -> /chat/completions (HTTP mocked) + <- final answer + +Only ``requests.post`` inside the openai plugin is mocked; everything else — +the loop, the engine, message serialization, tool-call parsing, and the actual +math tool execution — is real. +""" +import json +from unittest.mock import MagicMock, patch + +from ovos_plugin_manager.templates.agents import AgentMessage, MessageRole +from ovos_agentic_loop.native_toolcall import NativeToolCallEngine +from ovos_agentic_loop.tools.math import MathToolBox +from ovos_openai_plugin.chat import OpenAIChatEngine +import ovos_openai_plugin.api as openai_api + + +def _completion(message: dict) -> MagicMock: + resp = MagicMock() + resp.raise_for_status.return_value = None + resp.json.return_value = {"choices": [{"message": message}]} + return resp + + +def test_native_loop_executes_real_math_tool(): + """The model requests evaluate_expression; the real MathToolBox computes it.""" + # Turn 1: the LLM asks to call evaluate_expression("12*9"). + # Turn 2: with the tool result in context, it answers. + responses = [ + _completion({ + "content": None, + "tool_calls": [{ + "id": "call_1", "type": "function", + "function": {"name": "evaluate_expression", + "arguments": json.dumps({"expression": "12*9"})}, + }], + }), + _completion({"content": "12 times 9 is 108."}), + ] + + brain = OpenAIChatEngine({"api_url": "http://x/v1", "model": "test"}) + engine = NativeToolCallEngine() + engine.set_brain(brain) + engine.load_toolboxes([MathToolBox()]) + + with patch.object(openai_api.requests, "post", side_effect=responses) as mock_post: + result = engine.continue_chat( + [AgentMessage(role=MessageRole.USER, content="what is 12*9?")]) + + assert result.role == MessageRole.ASSISTANT + assert "108" in result.content + + # Two real LLM round-trips happened. + assert mock_post.call_count == 2 + + # Turn 1 payload advertised the real toolbox schema as OpenAI tools. + turn1 = json.loads(mock_post.call_args_list[0].kwargs["data"]) + tool_names = {t["function"]["name"] for t in turn1["tools"]} + assert "evaluate_expression" in tool_names + + # Turn 2 payload carried the assistant tool_call turn + the real TOOL result + # (the math tool actually computed 108). + turn2 = json.loads(mock_post.call_args_list[1].kwargs["data"]) + roles = [m["role"] for m in turn2["messages"]] + assert "assistant" in roles and "tool" in roles + tool_msg = next(m for m in turn2["messages"] if m["role"] == "tool") + assert tool_msg["tool_call_id"] == "call_1" + assert "108" in tool_msg["content"] + + +def test_native_loop_falls_back_when_brain_has_no_tool_support(): + """A brain without supports_tools drives the inherited ReAct text loop.""" + from ovos_agentic_loop.react import _FINAL_ANSWER_TOKEN + + brain = OpenAIChatEngine({"api_url": "http://x/v1", "model": "test"}) + # OpenAIChatEngine *does* support tools; force the fallback path explicitly to + # prove a non-tool brain still works end to end. + brain.supports_tools = False + engine = NativeToolCallEngine() + engine.set_brain(brain) + engine.load_toolboxes([MathToolBox()]) + + responses = [_completion({"content": f"Thought: trivial.\n{_FINAL_ANSWER_TOKEN} 42"})] + with patch.object(openai_api.requests, "post", side_effect=responses): + result = engine.continue_chat( + [AgentMessage(role=MessageRole.USER, content="6*7?")]) + assert result.content == "42" From 5069195fd9edd520f5a1555c3b669eae2f2d2b7c Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:29:37 +0000 Subject: [PATCH 5/6] Increment Version to 0.2.0a1 --- ovos_agentic_loop/version.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ovos_agentic_loop/version.py b/ovos_agentic_loop/version.py index 43e3eb2..0d600e2 100644 --- a/ovos_agentic_loop/version.py +++ b/ovos_agentic_loop/version.py @@ -12,8 +12,8 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 -VERSION_MINOR = 1 -VERSION_BUILD = 1 +VERSION_MINOR = 2 +VERSION_BUILD = 0 VERSION_ALPHA = 1 # END_VERSION_BLOCK From a152382e7e2c7b852e717ab8b3ae078e2a87655c Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:29:53 +0000 Subject: [PATCH 6/6] Update Changelog --- CHANGELOG.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ecd35f..0610a36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,20 @@ # Changelog -## [0.1.1a1](https://github.com/TigreGotico/ovos-agentic-loop/tree/0.1.1a1) (2026-06-10) +## [0.2.0a1](https://github.com/OpenVoiceOS/ovos-agentic-loop/tree/0.2.0a1) (2026-06-20) -[Full Changelog](https://github.com/TigreGotico/ovos-agentic-loop/compare/0.1.0...0.1.1a1) +[Full Changelog](https://github.com/OpenVoiceOS/ovos-agentic-loop/compare/0.1.1a1...0.2.0a1) **Merged pull requests:** -- fix: make SkillMDLoader tests hermetic [\#2](https://github.com/TigreGotico/ovos-agentic-loop/pull/2) ([JarbasAl](https://github.com/JarbasAl)) +- feat: NativeToolCallEngine — provider-native tool-calling loop [\#5](https://github.com/OpenVoiceOS/ovos-agentic-loop/pull/5) ([JarbasAl](https://github.com/JarbasAl)) + +## [0.1.1a1](https://github.com/OpenVoiceOS/ovos-agentic-loop/tree/0.1.1a1) (2026-06-10) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-agentic-loop/compare/0.1.0...0.1.1a1) + +**Merged pull requests:** + +- fix: make SkillMDLoader tests hermetic [\#2](https://github.com/OpenVoiceOS/ovos-agentic-loop/pull/2) ([JarbasAl](https://github.com/JarbasAl))