Files
app/backend/fluksio/ai/agent.py
T
stroblmeandClaude Opus 5.5 58b92da8b0 Assistant streams its answer token by token
ai/client.py streams the completion and puts the chunks back together
(Assembler), covering OpenAI's piecewise tool-call arguments, Ollama's whole
calls, OpenRouter's comment lines, unnumbered calls and in-stream errors.
The turn emits a delta event per piece and a message per round as before,
and the chat grows the last answer as deltas arrive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
2026-10-15 17:37:56 +02:00

181 lines
6.6 KiB
Python

"""The turn: the model, the tools, and the rounds between them.
The tools are the MCP server's own, called in-process rather than over the
endpoint: same names, same schemas, same REST calls underneath. A tool added
for external agents is there for the assistant the moment it exists, and the
assistant can do nothing an external agent could not.
The transcript is a list of chat-completion messages, mutated in place by
:func:`run_turn` and persisted by the caller. It is also what the browser
renders, so nothing is stored twice.
"""
from __future__ import annotations
import json
import logging
from collections.abc import AsyncIterator
from typing import Any
import httpx
from mcp.types import TextContent
from fluksio.ai import client
from fluksio.ai.config import AIConfig
from fluksio.flow.schemas import FlowDef
from fluksio.mcp.server import caller_authorization, mcp
logger = logging.getLogger(__name__)
_SYSTEM = """You are the assistant inside a Fluksio instance. You read, build, edit and \
operate flows through the tools; they are the same API the editor uses, so \
anything you save appears in the editor at once.
How to work:
- Read before you write: get_flow before save_flow, and pass back the version \
you read. On a version conflict, read again and reapply the change.
- save_flow writes a draft, and validate_flow tells you whether it is sound. \
Publish only when the user asks for the change to go live; otherwise leave \
the draft for review in the editor and say so.
- list_node_types gives the node types and their parameters; get_node_source \
shows how a Python node is written before you write one.
- Keep answers short and name what you changed. Answers render as markdown: \
use lists, `inline code` and fenced code blocks where they help, and no \
tables, images or HTML.
The flow document `save_flow` takes, as a JSON schema:
"""
# ponytail: a tool's answer is replayed on every later round of the
# conversation, so one big one is paid for again and again; cut it here rather
# than summarise it. A per-tool digest is the upgrade if this bites.
_RESULT_LIMIT = 20_000
def system_prompt(flow: str | None) -> str:
prompt = _SYSTEM + json.dumps(FlowDef.model_json_schema())
if flow:
prompt += f"\n\nThe user is looking at the flow named {flow!r}."
return prompt
async def tool_specs() -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": tool.name,
"description": tool.description or "",
"parameters": tool.inputSchema,
},
}
for tool in await mcp.list_tools()
]
def _arguments(raw: Any) -> dict[str, Any]:
if isinstance(raw, dict):
return raw
try:
parsed = json.loads(raw or "{}")
except ValueError:
return {}
return parsed if isinstance(parsed, dict) else {}
async def _call_tool(name: str, arguments: dict[str, Any]) -> str:
"""Run one tool; whatever goes wrong is text the model can read."""
try:
result = await mcp.call_tool(name, arguments)
except Exception as exc: # noqa: BLE001 - an unknown tool, or refused arguments
return json.dumps({"error": str(exc)})
if isinstance(result, dict):
text = json.dumps(result)
else:
text = "".join(c.text for c in result if isinstance(c, TextContent))
if len(text) > _RESULT_LIMIT:
text = text[:_RESULT_LIMIT] + "… (truncated)"
return text
def summarize(content: str | None) -> dict[str, Any]:
"""What the browser shows for a tool's answer: whether it worked."""
try:
result = json.loads(content or "")
except ValueError:
return {"ok": True}
if isinstance(result, dict) and "error" in result:
return {"ok": False, "error": str(result["error"])[:300]}
return {"ok": True}
async def run_turn(
config: AIConfig,
messages: list[dict[str, Any]],
text: str,
*,
flow: str | None,
authorization: str,
) -> AsyncIterator[dict[str, Any]]:
"""One user turn, yielding progress events as it goes.
A round's text arrives as ``delta`` events while the model writes it, and
once more whole as ``message`` when the round is done. A round is appended
to *messages* only once every tool in it has answered, so a turn cut off
part-way leaves a transcript the provider still accepts.
"""
messages.append({"role": "user", "content": text})
tools = await tool_specs()
system = {"role": "system", "content": system_prompt(flow)}
token = caller_authorization.set(authorization)
try:
for _ in range(config.max_rounds):
choice: dict[str, Any] = {}
try:
async for part in client.stream_completion(
api_base=config.api_base,
api_key=config.api_key,
model=config.model,
messages=[system, *messages],
tools=tools,
):
if isinstance(part, str):
yield {"type": "delta", "content": part}
else:
choice = part
except (client.ProviderError, httpx.HTTPError) as exc:
yield {"type": "error", "detail": f"The provider answered {exc}"}
return
content = choice.get("content") or ""
calls = choice.get("tool_calls") or []
assistant: dict[str, Any] = {"role": "assistant", "content": content}
if content:
yield {"type": "message", "content": content}
if not calls:
messages.append(assistant)
return
assistant["tool_calls"] = calls
answers: list[dict[str, Any]] = []
for call in calls:
function = call.get("function") or {}
name = str(function.get("name", ""))
arguments = _arguments(function.get("arguments"))
yield {"type": "tool_call", "name": name, "arguments": arguments}
result = await _call_tool(name, arguments)
answers.append(
{
"role": "tool",
"tool_call_id": call.get("id", ""),
"content": result,
}
)
yield {"type": "tool_result", "name": name, **summarize(result)}
messages.append(assistant)
messages.extend(answers)
yield {
"type": "error",
"detail": f"Stopped after {config.max_rounds} rounds of tool calls.",
}
finally:
caller_authorization.reset(token)