Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
329 changes: 329 additions & 0 deletions .agents/specs/2026-09-25-tool-use-orchestration-loop-design.md

Large diffs are not rendered by default.

1,302 changes: 1,302 additions & 0 deletions .agents/specs/2026-09-25-tool-use-orchestration-loop.md

Large diffs are not rendered by default.

223 changes: 223 additions & 0 deletions backend/scripts/smoke_agent_loop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
#!/usr/bin/env python
"""Manual smoke test: run the tool-use orchestration loop end-to-end.

Wires the real seams — a stdio filesystem MCP server through
``ToolRegistry`` -> ``translate_tools`` -> ``ToolLoop`` -> a live
OpenAI-dialect engine — and prompts the model to create a file. Success
criterion: the loop caught at least one tool call and the file exists.

Engine endpoint comes from the usual ``OCTAVE_INFERENCE_*`` env / ``.env``
(see ``InferenceSettings``) unless overridden with flags.

Usage (from backend/):

# engine from env, temp sandbox, npx filesystem server
uv run scripts/smoke_agent_loop.py

# explicit engine / directory / prompt
uv run scripts/smoke_agent_loop.py \\
--base-url http://localhost:11434/v1 --model qwen2.5-coder \\
--dir /tmp/octave-sandbox \\
--prompt "Create hello.txt containing 'hi'"

Requires Node/npx for the filesystem server. Prints the full transcript.
Exits non-zero when no tool call was caught or the file is missing.
Not part of the test suite.
"""

import argparse
import asyncio
import sys
import tempfile
from pathlib import Path

import anyio
import httpx

from octave.agent import McpToolExecutor, ToolLoop
from octave.inference import (
AdapterConfig,
InferenceAdapter,
InferenceSettings,
OpenAIAdapter,
)
from octave.inference.errors import AdapterError
from octave.inference.types import Message
from octave.mcp import McpError, McpServerManager, StdioConfig, ToolRegistry
from octave.tools import translate_tools

DEFAULT_PROMPT = (
"Create a file named note.txt in the working directory containing "
"exactly the text: hello from octave"
)


async def run(
*,
config: AdapterConfig,
directory: Path,
prompt: str,
max_rounds: int,
ca_bundle: str | None = None,
) -> int:
"""Connect the fleet, run one orchestrated turn, verify the tool call."""
manager = McpServerManager()
manager.register(
id="fs",
name="filesystem",
config=StdioConfig(
command="npx",
args=["-y", "@modelcontextprotocol/server-filesystem", str(directory)],
),
)
registry = ToolRegistry(manager=manager)
http_client = (
httpx.AsyncClient(verify=ca_bundle) if ca_bundle else None
)
adapter: InferenceAdapter = OpenAIAdapter(config, http_client=http_client)
try:
await manager.start_all()
# The registry's warm-up is a background task: wait until the
# supervisor reports the server actually connected, then refresh
# explicitly so the inventory below is populated.
for _ in range(30):
state = manager.status_of("fs").state
if state in ("connected", "crashed"):
break
await anyio.sleep(2.0)
if state != "connected":
status = manager.status_of("fs")
print(
f"✗ filesystem server state={state} error={status.last_error}",
file=sys.stderr,
)
return 1
await registry.start()
await registry.refresh("fs")
inventory = await registry.inventory()
for server in inventory:
print(f"server: {server.server_id} — {len(server.tools)} tool(s)")
toolset = translate_tools(inventory)
print(f"tools: {', '.join(tool.name for tool in toolset.tools)}")

loop = ToolLoop(
adapter=adapter,
executor=McpToolExecutor(registry),
max_tool_rounds=max_rounds,
)
turn = await loop.run([Message(role="user", content=prompt)], toolset)

print(f"\nrounds: {turn.tool_rounds}")
print("--- transcript ---")
tool_call_count = 0
for message in turn.messages:
if message.tool_calls:
tool_call_count += len(message.tool_calls)
for call in message.tool_calls:
print(
f"assistant -> tool call: {call.name} "
f"arguments={call.arguments}"
)
elif message.role == "tool":
preview = message.content[:120].replace("\n", " ")
print(f"tool[{message.name}] {preview}")
else:
preview = message.content[:120].replace("\n", " ")
print(f"{message.role}: {preview}")
print("--- final ---")
print(turn.result.text or "(empty)")

created = directory / "note.txt"
if turn.tool_rounds == 0 or tool_call_count == 0:
print(
"\n✗ no tool call was caught (model answered without tools)",
file=sys.stderr,
)
return 1
if not created.exists():
print(
f"\n✗ tool calls executed but {created} does not exist",
file=sys.stderr,
)
return 1
print(f"\n✓ caught {tool_call_count} tool call(s); {created} created")
return 0
except (McpError, AdapterError) as exc:
print(f"\n✗ {type(exc).__name__}: {exc}", file=sys.stderr)
return 1
finally:
await registry.stop()
await manager.stop_all()
await adapter.aclose()


def main() -> None:
parser = argparse.ArgumentParser(
description="Smoke-test the tool-use orchestration loop end-to-end."
)
parser.add_argument(
"--base-url", default=None, help="OpenAI-dialect /v1 URL (default: env)"
)
parser.add_argument(
"--model", default=None, help="Model id for completions (default: env)"
)
parser.add_argument(
"--dir",
type=Path,
default=None,
help="Directory to sandbox the filesystem server (default: temp dir)",
)
parser.add_argument("--prompt", default=DEFAULT_PROMPT, help="User message to send")
parser.add_argument("--max-rounds", type=int, default=8, help="Tool round limit")
parser.add_argument(
"--ca-bundle",
default=None,
help=(
"CA bundle for TLS verification (default: certifi). "
"For internal CAs try /etc/ssl/certs/ca-certificates.crt"
),
)
parsed = parser.parse_args()

directory: Path = parsed.dir or Path(tempfile.mkdtemp(prefix="octave-smoke-loop-"))
directory.mkdir(parents=True, exist_ok=True)
print(f"sandbox: {directory}")

settings = InferenceSettings()
config = settings.to_adapter_config()
if parsed.base_url:
config = AdapterConfig(
adapter=config.adapter,
base_url=parsed.base_url,
api_key=config.api_key,
default_model=parsed.model or config.default_model,
timeout_seconds=config.timeout_seconds,
max_retries=config.max_retries,
extra=config.extra,
)
elif parsed.model:
config = AdapterConfig(
adapter=config.adapter,
base_url=config.base_url,
api_key=config.api_key,
default_model=parsed.model,
timeout_seconds=config.timeout_seconds,
max_retries=config.max_retries,
extra=config.extra,
)

sys.exit(
asyncio.run(
run(
config=config,
directory=directory,
prompt=parsed.prompt,
max_rounds=parsed.max_rounds,
ca_bundle=parsed.ca_bundle,
)
)
)


if __name__ == "__main__":
main()
23 changes: 23 additions & 0 deletions backend/src/octave/agent/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Agent plane: tool-use orchestration loop (issue #79).

Composition layer — the only package importing both octave.mcp and
octave.inference. Future Agent Manager components (manager, registry,
router) land here as additional modules; they do not exist yet.
"""

from octave.agent.errors import ToolLoopLimitError
from octave.agent.executor import ToolExecutor
from octave.agent.loop import ToolLoop
from octave.agent.mcp_executor import McpToolExecutor
from octave.agent.types import ToolOutcome, ToolTurn
from octave.tools.errors import ToolError

__all__ = [
"McpToolExecutor",
"ToolError",
"ToolExecutor",
"ToolLoop",
"ToolLoopLimitError",
"ToolOutcome",
"ToolTurn",
]
19 changes: 19 additions & 0 deletions backend/src/octave/agent/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Orchestration-fatal errors (design spec #79)."""

from octave.inference.types import Message
from octave.tools.errors import ToolError

__all__ = ["ToolLoopLimitError"]


class ToolLoopLimitError(ToolError):
"""max_tool_rounds exhausted without a final answer.

``messages`` is the partial transcript (input + everything appended,
ending on the unfulfilled assistant tool_calls message) so callers can
persist partial work or retry.
"""

def __init__(self, message: str, *, messages: list[Message]) -> None:
super().__init__(message)
self.messages = messages
19 changes: 19 additions & 0 deletions backend/src/octave/agent/executor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Tool-execution seam (design spec #79 decision 7)."""

from typing import Any, Protocol

from octave.agent.types import ToolOutcome

__all__ = ["ToolExecutor"]


class ToolExecutor(Protocol):
"""Executes one resolved tool call.

Implementations must not raise for tool-level failures — return
``ToolOutcome(is_error=True)`` so the model can self-correct.
"""

async def call(
self, server_id: str, tool_name: str, arguments: dict[str, Any]
) -> ToolOutcome: ...
Loading
Loading