mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-30 04:15:45 +08:00
481 lines
18 KiB
Python
481 lines
18 KiB
Python
"""Local Agent default runner implementation.
|
|
|
|
Supports:
|
|
- Model fallback (primary + fallbacks)
|
|
- Streaming and non-streaming
|
|
- Tool calling loop with max iterations
|
|
- Knowledge retrieval with permission validation
|
|
- Protocol v1 RunnerResult output
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import time
|
|
from contextlib import aclosing
|
|
from typing import Any, AsyncGenerator
|
|
|
|
from langbot_plugin.api.definition.components.runner.runner import Runner
|
|
from langbot_plugin.api.entities.builtin.provider.message import Message
|
|
from langbot_plugin.api.entities.builtin.runner import (
|
|
RunnerContext,
|
|
RunnerResult,
|
|
)
|
|
|
|
from pkg.agent_core import (
|
|
AgentLoop,
|
|
AgentLoopEvent,
|
|
AgentLoopEventType,
|
|
LangBotModelAdapter,
|
|
)
|
|
from pkg.box import LocalAgentBox
|
|
from pkg.config import get_run_timeout_seconds, model_reasoning_levels
|
|
from pkg.run_assembly import AgentRunAssembler, AgentRunAssembly, NoAuthorizedModelError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
CANCELLED_ERROR = "Run cancellation requested"
|
|
CANCELLED_CODE = "cancelled"
|
|
TIMEOUT_ERROR = "Agent run timed out"
|
|
TIMEOUT_CODE = "runner.timeout"
|
|
INTERRUPT_CHECK_INTERVAL_SECONDS = 0.5
|
|
|
|
|
|
class RunCancelledError(Exception):
|
|
"""Raised internally when Host marks the run cancelled."""
|
|
|
|
|
|
class RunDeadline:
|
|
"""Wall-clock deadline shared by assembly, model calls, and tool calls."""
|
|
|
|
def __init__(self, timeout_seconds: float):
|
|
self.timeout_seconds = max(0.001, float(timeout_seconds))
|
|
self._deadline = time.monotonic() + self.timeout_seconds
|
|
|
|
def remaining(self) -> float:
|
|
return max(0.0, self._deadline - time.monotonic())
|
|
|
|
|
|
class RunInterruptChecker:
|
|
"""Poll Host run ledger for cooperative cancellation requests."""
|
|
|
|
def __init__(
|
|
self,
|
|
api: Any,
|
|
ctx: RunnerContext,
|
|
*,
|
|
interval_seconds: float = INTERRUPT_CHECK_INTERVAL_SECONDS,
|
|
):
|
|
self.api = api
|
|
self.ctx = ctx
|
|
self.interval_seconds = max(0.1, interval_seconds)
|
|
self._check_generation = 0
|
|
self._cancelled = asyncio.Event()
|
|
self._check_lock = asyncio.Lock()
|
|
self._watch_lock = asyncio.Lock()
|
|
self._watcher: asyncio.Task | None = None
|
|
available_apis = getattr(getattr(ctx, "context", None), "available_apis", None)
|
|
self.available = bool(getattr(available_apis, "run_get", False)) and callable(getattr(api, "run_get", None))
|
|
|
|
async def is_cancelled(self, *, force: bool = False, coalesce: bool = False) -> bool:
|
|
if self._cancelled.is_set():
|
|
return True
|
|
if not self.available:
|
|
return False
|
|
# Hot-path checks never wait for a ledger RPC already in flight.
|
|
if not force:
|
|
return self._cancelled.is_set()
|
|
if coalesce:
|
|
async with self._watch_lock:
|
|
if self._check_lock.locked():
|
|
# The watcher can await a fresh boundary without issuing another RPC.
|
|
generation = self._check_generation
|
|
async with self._check_lock:
|
|
if generation != self._check_generation:
|
|
return self._cancelled.is_set()
|
|
if self._cancelled.is_set():
|
|
return True
|
|
return await self._poll()
|
|
async with self._check_lock:
|
|
if self._cancelled.is_set():
|
|
return True
|
|
# A fresh boundary must not wait for an older background snapshot.
|
|
return await self._poll()
|
|
|
|
async def _poll(self) -> bool:
|
|
request = asyncio.ensure_future(self.api.run_get(self.ctx.run_id))
|
|
try:
|
|
# wait_for can swallow cancellation when the RPC completes at
|
|
# the same instant on Python 3.11. Keep task ownership explicit.
|
|
done, _ = await asyncio.wait({request}, timeout=1.0)
|
|
if not done:
|
|
return self._cancelled.is_set()
|
|
run = request.result()
|
|
self._check_generation += 1
|
|
if _run_cancel_requested(run):
|
|
self._cancelled.set()
|
|
except Exception:
|
|
logger.debug("Failed to check AgentRun cancellation state", exc_info=True)
|
|
finally:
|
|
if not request.done():
|
|
request.cancel()
|
|
await asyncio.gather(request, return_exceptions=True)
|
|
return self._cancelled.is_set()
|
|
|
|
async def _watch(self):
|
|
while not self._cancelled.is_set():
|
|
await self.is_cancelled(force=True, coalesce=True)
|
|
await asyncio.sleep(self.interval_seconds)
|
|
|
|
async def __aenter__(self):
|
|
if self.available:
|
|
self._watcher = asyncio.create_task(self._watch())
|
|
return self
|
|
|
|
async def __aexit__(self, *args):
|
|
if self._watcher is not None:
|
|
self._watcher.cancel()
|
|
await asyncio.gather(self._watcher, return_exceptions=True)
|
|
self._watcher = None
|
|
|
|
async def wait_for(self, awaitable: Any, *, deadline: RunDeadline | None = None) -> Any:
|
|
"""Wait for an awaitable while polling Host cancellation and run timeout."""
|
|
if not self.available and deadline is None:
|
|
return await awaitable
|
|
|
|
task = asyncio.ensure_future(awaitable)
|
|
cancellation = asyncio.create_task(self._cancelled.wait())
|
|
temporary_watcher = asyncio.create_task(self._watch()) if self.available and self._watcher is None else None
|
|
try:
|
|
await asyncio.wait(
|
|
{task, cancellation},
|
|
timeout=deadline.remaining() if deadline else None,
|
|
return_when=asyncio.FIRST_COMPLETED,
|
|
)
|
|
if self._cancelled.is_set():
|
|
raise RunCancelledError(CANCELLED_ERROR)
|
|
if not task.done():
|
|
raise asyncio.TimeoutError(TIMEOUT_ERROR)
|
|
return task.result()
|
|
finally:
|
|
pending = [task, cancellation] + ([temporary_watcher] if temporary_watcher else [])
|
|
for future in pending:
|
|
if not future.done():
|
|
future.cancel()
|
|
await asyncio.gather(*pending, return_exceptions=True)
|
|
|
|
|
|
class RunUsageTracker:
|
|
"""Share already observed model usage across run-control cancellation paths."""
|
|
|
|
def __init__(self):
|
|
self._usage: dict[str, Any] | None = None
|
|
|
|
def update(self, usage: dict[str, Any] | None) -> None:
|
|
if usage is not None:
|
|
self._usage = usage
|
|
|
|
def current(self) -> dict[str, Any] | None:
|
|
return dict(self._usage) if self._usage is not None else None
|
|
|
|
|
|
def _run_cancel_requested(run: Any) -> bool:
|
|
if run is None:
|
|
return False
|
|
if isinstance(run, dict):
|
|
return run.get("cancel_requested_at") is not None or run.get("status") == "cancelled"
|
|
return getattr(run, "cancel_requested_at", None) is not None or getattr(run, "status", None) == "cancelled"
|
|
|
|
|
|
class DefaultRunner(Runner):
|
|
"""Default Runner for Local Agent.
|
|
|
|
Full-featured LLM runner with:
|
|
- Model primary/fallback selection
|
|
- Streaming and non-streaming output
|
|
- Tool calling loop
|
|
- Knowledge retrieval (RAG)
|
|
|
|
All resource access goes through RunnerAPIProxy for authorization.
|
|
"""
|
|
|
|
async def run(self, ctx: RunnerContext) -> AsyncGenerator[RunnerResult, None]:
|
|
async with RunInterruptChecker(self.get_run_api(ctx), ctx) as interrupt_checker:
|
|
async with aclosing(self._run_controlled(ctx, interrupt_checker)) as results:
|
|
async for result in results:
|
|
yield result
|
|
|
|
async def _run_controlled(
|
|
self, ctx: RunnerContext, interrupt_checker: RunInterruptChecker
|
|
) -> AsyncGenerator[RunnerResult, None]:
|
|
"""Run the agent with full LLM capabilities.
|
|
|
|
Implementation:
|
|
1. Get authorized models and parse model config
|
|
2. Retrieve knowledge base context (if configured)
|
|
3. Build messages from prompt + history + input
|
|
4. Stream/Invoke LLM with fallback support
|
|
5. Handle tool calling loop
|
|
6. Yield RunnerResult events
|
|
"""
|
|
api = self.get_run_api(ctx)
|
|
config = ctx.config if isinstance(ctx.config, dict) else {}
|
|
timeout_seconds = get_run_timeout_seconds(config)
|
|
deadline = RunDeadline(timeout_seconds) if timeout_seconds is not None else None
|
|
usage_tracker = RunUsageTracker()
|
|
|
|
if await interrupt_checker.is_cancelled(force=True):
|
|
yield RunnerResult.run_failed(
|
|
ctx.run_id,
|
|
error=CANCELLED_ERROR,
|
|
code=CANCELLED_CODE,
|
|
retryable=False,
|
|
)
|
|
return
|
|
|
|
box = LocalAgentBox(ctx, api)
|
|
try:
|
|
needs_box = box.needed()
|
|
if needs_box and ctx.input.attachments:
|
|
await interrupt_checker.wait_for(box.prepare(), deadline=deadline)
|
|
assembly = await interrupt_checker.wait_for(
|
|
AgentRunAssembler(api, ctx).assemble(),
|
|
deadline=deadline,
|
|
)
|
|
if needs_box:
|
|
box.install_tool(assembly)
|
|
assembly.tool_executor.api = box
|
|
except NoAuthorizedModelError:
|
|
yield RunnerResult.run_failed(
|
|
ctx.run_id,
|
|
error="No authorized model for local-agent",
|
|
code="runner.no_model",
|
|
)
|
|
return
|
|
except RunCancelledError:
|
|
yield _cancelled_result(ctx.run_id)
|
|
return
|
|
except asyncio.TimeoutError:
|
|
yield _timeout_result(ctx.run_id)
|
|
return
|
|
except Exception as e:
|
|
logger.exception("Agent run assembly failed")
|
|
yield RunnerResult.run_failed(
|
|
ctx.run_id,
|
|
error=str(e) or "Agent run assembly failed",
|
|
code="runner.error",
|
|
retryable=False,
|
|
)
|
|
return
|
|
|
|
if await interrupt_checker.is_cancelled(force=True):
|
|
yield RunnerResult.run_failed(
|
|
ctx.run_id,
|
|
error=CANCELLED_ERROR,
|
|
code=CANCELLED_CODE,
|
|
retryable=False,
|
|
)
|
|
return
|
|
|
|
try:
|
|
results = self._run_runner_loop(
|
|
run_id=ctx.run_id,
|
|
api=api,
|
|
assembly=assembly,
|
|
interrupt_checker=interrupt_checker,
|
|
usage_tracker=usage_tracker,
|
|
reasoning_levels=model_reasoning_levels(config),
|
|
)
|
|
async with aclosing(_iterate_with_run_controls(results, interrupt_checker, deadline)) as owned_stream:
|
|
async for result in owned_stream:
|
|
if box.binding is not None and getattr(result.type, "value", result.type) == "message.completed":
|
|
result.data["file_ids"] = box.file_ids
|
|
yield result
|
|
if _is_terminal_result(result):
|
|
return
|
|
except RunCancelledError:
|
|
yield _cancelled_result(ctx.run_id, usage=usage_tracker.current())
|
|
except asyncio.TimeoutError:
|
|
yield _timeout_result(ctx.run_id, usage=usage_tracker.current())
|
|
except Exception as e:
|
|
logger.exception("Agent run failed")
|
|
yield RunnerResult.run_failed(
|
|
ctx.run_id,
|
|
error=str(e) or "Agent run failed",
|
|
code="runner.error",
|
|
retryable=False,
|
|
usage=usage_tracker.current(),
|
|
)
|
|
|
|
async def _run_runner_loop(
|
|
self,
|
|
run_id: str,
|
|
api: Any,
|
|
assembly: AgentRunAssembly,
|
|
interrupt_checker: RunInterruptChecker | None = None,
|
|
usage_tracker: RunUsageTracker | None = None,
|
|
reasoning_levels: dict[str, str] | None = None,
|
|
) -> AsyncGenerator[RunnerResult, None]:
|
|
"""Run the LangBot-native Pi-style agent loop."""
|
|
loop = AgentLoop(
|
|
model_adapter=LangBotModelAdapter(
|
|
api, remove_think=assembly.remove_think, reasoning_levels=reasoning_levels
|
|
),
|
|
tool_executor=assembly.tool_executor,
|
|
model_ids=assembly.model_ids,
|
|
messages=assembly.messages,
|
|
tools=assembly.tools,
|
|
streaming=assembly.streaming,
|
|
max_tool_iterations=assembly.max_tool_iterations,
|
|
tool_execution_mode=assembly.tool_execution_mode,
|
|
hooks=assembly.hooks,
|
|
)
|
|
|
|
final_message: Message | None = None
|
|
terminal_usage: dict[str, Any] | None = None
|
|
async with aclosing(loop.run()) as owned_stream:
|
|
async for event in owned_stream:
|
|
if event.usage is not None:
|
|
terminal_usage = event.usage
|
|
if usage_tracker is not None:
|
|
usage_tracker.update(event.usage)
|
|
|
|
if interrupt_checker is not None and await interrupt_checker.is_cancelled():
|
|
yield RunnerResult.run_failed(
|
|
run_id,
|
|
error=CANCELLED_ERROR,
|
|
code=CANCELLED_CODE,
|
|
retryable=False,
|
|
usage=terminal_usage,
|
|
)
|
|
return
|
|
|
|
result = self._loop_event_to_result(run_id, event, streaming=assembly.streaming)
|
|
if result is not None:
|
|
yield result
|
|
if getattr(result.type, "value", result.type) == "run.failed":
|
|
return
|
|
|
|
if interrupt_checker is not None and await interrupt_checker.is_cancelled():
|
|
yield RunnerResult.run_failed(
|
|
run_id,
|
|
error=CANCELLED_ERROR,
|
|
code=CANCELLED_CODE,
|
|
retryable=False,
|
|
usage=terminal_usage,
|
|
)
|
|
return
|
|
|
|
if (
|
|
event.type == AgentLoopEventType.MESSAGE_END
|
|
and event.message is not None
|
|
and event.message.role == "assistant"
|
|
and not event.message.tool_calls
|
|
):
|
|
final_message = event.message
|
|
|
|
if event.type == AgentLoopEventType.AGENT_END:
|
|
if interrupt_checker is not None and await interrupt_checker.is_cancelled(force=True):
|
|
yield RunnerResult.run_failed(
|
|
run_id,
|
|
error=CANCELLED_ERROR,
|
|
code=CANCELLED_CODE,
|
|
retryable=False,
|
|
usage=terminal_usage,
|
|
)
|
|
return
|
|
if final_message is not None:
|
|
yield RunnerResult.message_completed(run_id, final_message)
|
|
yield RunnerResult.run_completed(
|
|
run_id,
|
|
finish_reason="stop",
|
|
usage=event.usage or terminal_usage,
|
|
)
|
|
|
|
def _loop_event_to_result(
|
|
self,
|
|
run_id: str,
|
|
event: AgentLoopEvent,
|
|
*,
|
|
streaming: bool,
|
|
) -> RunnerResult | None:
|
|
if event.type == AgentLoopEventType.MESSAGE_UPDATE and streaming and event.chunk is not None:
|
|
return RunnerResult.message_delta(run_id, event.chunk)
|
|
|
|
if event.type == AgentLoopEventType.TOOL_EXECUTION_START and event.tool_call_id and event.tool_name:
|
|
return RunnerResult.tool_call_started(
|
|
run_id,
|
|
tool_call_id=event.tool_call_id,
|
|
tool_name=event.tool_name,
|
|
parameters=event.parameters,
|
|
)
|
|
|
|
if event.type == AgentLoopEventType.TOOL_EXECUTION_END and event.tool_call_id and event.tool_name:
|
|
return RunnerResult.tool_call_completed(
|
|
run_id,
|
|
tool_call_id=event.tool_call_id,
|
|
tool_name=event.tool_name,
|
|
result=_tool_event_result_payload(event.result),
|
|
error=event.error,
|
|
)
|
|
|
|
if event.type == AgentLoopEventType.RUN_FAILED:
|
|
return RunnerResult.run_failed(
|
|
run_id,
|
|
error=event.error or "Agent loop failed",
|
|
code=event.code or "runner.error",
|
|
retryable=event.retryable,
|
|
usage=event.usage,
|
|
)
|
|
|
|
return None
|
|
|
|
|
|
def _tool_event_result_payload(result: Any) -> dict[str, Any] | None:
|
|
if result is None or isinstance(result, dict):
|
|
return result
|
|
return {"value": result}
|
|
|
|
|
|
async def _iterate_with_run_controls(
|
|
results: AsyncGenerator[RunnerResult, None],
|
|
interrupt_checker: RunInterruptChecker,
|
|
deadline: RunDeadline | None,
|
|
) -> AsyncGenerator[RunnerResult, None]:
|
|
iterator = results.__aiter__()
|
|
try:
|
|
while True:
|
|
try:
|
|
yield await interrupt_checker.wait_for(iterator.__anext__(), deadline=deadline)
|
|
except StopAsyncIteration:
|
|
return
|
|
except BaseException:
|
|
await iterator.aclose()
|
|
raise
|
|
|
|
|
|
def _cancelled_result(run_id: str, usage: dict[str, Any] | None = None) -> RunnerResult:
|
|
return RunnerResult.run_failed(
|
|
run_id,
|
|
error=CANCELLED_ERROR,
|
|
code=CANCELLED_CODE,
|
|
retryable=False,
|
|
usage=usage,
|
|
)
|
|
|
|
|
|
def _timeout_result(run_id: str, usage: dict[str, Any] | None = None) -> RunnerResult:
|
|
return RunnerResult.run_failed(
|
|
run_id,
|
|
error=TIMEOUT_ERROR,
|
|
code=TIMEOUT_CODE,
|
|
retryable=True,
|
|
usage=usage,
|
|
)
|
|
|
|
|
|
def _is_terminal_result(result: RunnerResult) -> bool:
|
|
result_type = getattr(result.type, "value", result.type)
|
|
return result_type in {"run.failed", "run.completed"}
|