Files
cleveragents-core/robot/helper_session_tell_llm.py
T
hurui200320 87a7ce35d7
CI / benchmark-regression (push) Has been skipped
CI / helm (push) Successful in 38s
CI / lint (push) Successful in 1m14s
CI / build (push) Successful in 1m10s
CI / push-validation (push) Successful in 49s
CI / quality (push) Successful in 1m35s
CI / typecheck (push) Successful in 1m39s
CI / security (push) Successful in 1m45s
CI / integration_tests (push) Successful in 3m44s
CI / e2e_tests (push) Successful in 4m29s
CI / unit_tests (push) Successful in 5m10s
CI / docker (push) Successful in 1m55s
CI / coverage (push) Successful in 11m8s
CI / status-check (push) Successful in 3s
CI / benchmark-publish (push) Successful in 1h20m21s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Failing after 1m35s
CI / docker (pull_request) Successful in 1m26s
CI / unit_tests (pull_request) Successful in 6m40s
CI / push-validation (pull_request) Successful in 1m24s
CI / quality (pull_request) Successful in 4m4s
CI / integration_tests (pull_request) Successful in 5m44s
CI / e2e_tests (pull_request) Failing after 6m5s
CI / helm (pull_request) Successful in 1m10s
CI / build (pull_request) Successful in 2m39s
CI / lint (pull_request) Successful in 2m58s
CI / typecheck (pull_request) Successful in 3m56s
CI / security (pull_request) Successful in 3m56s
CI / coverage (pull_request) Successful in 10m52s
CI / status-check (pull_request) Failing after 3s
feat(session): implement real LLM actor invocation in session tell
Replace the M3 stub in `agents session tell` with real orchestrator actor
invocation via `SessionWorkflow.tell()`. The stub echoed a canned
`Acknowledged: ...` response without calling any LLM or actor; this
commit wires up the full pipeline:

Architecture:
- New `SessionWorkflow` (Application layer) orchestrates LLM invocation
  for session tell. Accepts a `ProviderRegistry` and `ToolRegistry`;
  falls back to `FakeListLLM` when no provider is configured.
- `LangChainSessionCaller` implements the `LLMCaller` protocol, building
  history-aware LangChain message lists from the session conversation and
  invoking the LLM via `ToolCallingRuntime.run_tool_loop()`.
- `TellResult` (Pydantic BaseModel) carries the assistant response plus
  token usage (input_tokens, output_tokens, cost_usd, duration_ms).

Domain / service layer:
- `SessionActorNotConfiguredError` added to the session domain model;
  raised when tell is invoked with no actor on the session and no
  `--actor` override. Clear message; CLI exits with code 1.
- `SessionService.get_messages()` abstract method added (+ implementation
  in `PersistentSessionService`) to load ordered message history.

A2A facade:
- `A2aLocalFacade` gains `message/send` and `message/stream` standard
  A2A operation handlers that route to `SessionWorkflow.tell()`.
  Total supported operations count: 42 → 44.

CLI:
- `session tell` command delegates to `_build_session_workflow()`
  (patchable factory) instead of directly calling `SessionService`.
- Non-streaming: `SessionWorkflow.tell()` returns `TellResult`; output
  includes a Usage panel (Rich/Plain) or a `usage` object (JSON/YAML).
- Streaming: `SessionWorkflow.tell_stream()` yields tokens; CLI prints
  them via `console.print` (not raw `sys.stdout.write`).
- Token usage recorded via `SessionService.update_token_usage()` in
  both paths.

Tests:
- New Behave feature `session_tell_llm.feature` (4 scenarios): real LLM
  response persisted; streaming yields tokens; no-actor exits code 1;
  `--actor` override resolves correctly.
- New Robot suite `session_tell_llm.robot` (4 tests): end-to-end with
  stub LLM injected via monkey-patching `_resolve_llm`.
- Updated all existing `session tell` test steps to patch
  `_build_session_workflow` returning a mock `TellResult` so tests
  remain isolated from the LLM layer.
- Updated operation-count assertions (42 → 44) in
  `a2a_cli_facade_integration`, `consolidated_misc`,
  `m6_autonomy_acceptance` feature files and steps.

Cycle 7 (Review ID 8088) fixes:
- Blocker 4: Split `session_workflow.py` (was 801 lines) into two files:
  `session_workflow.py` (467 lines) and `session_caller.py` (318 lines).
  Extracted: `LangChainSessionCaller`, `extract_content`,
  `extract_token_usage`, `estimate_cost`, `estimate_tokens`,
  `history_to_langchain_messages`, and stub classes.
- Blocker 5: Route CLI streaming path through
  `_facade_dispatch("message/stream", ...)` instead of directly
  calling `workflow.tell_stream()`. The facade's
  `_handle_message_stream` falls back to non-streaming with
  `streamed: false` (acceptable for this milestone per the spec).
- Updated streaming test assertion to validate full response presence
  (no longer checks token-by-token word positions, since the facade
  fallback returns a complete message).

ISSUES CLOSED: #5784
2026-05-11 04:39:29 +00:00

214 lines
7.0 KiB
Python

"""Helper script for session_tell_llm.robot integration tests.
Each subcommand is a self-contained check that prints a sentinel on success.
Tests verify SessionWorkflow.tell() / tell_stream() behaviour with a stub LLM.
No real API keys or network access required — all LLM calls are intercepted
by a monkey-patched ``_resolve_llm`` that returns a minimal stub.
"""
# ruff: noqa: E402, I001
from __future__ import annotations
import sys
from collections.abc import Iterator
from pathlib import Path
from typing import Any
# Ensure local source tree is importable
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from cleveragents.application.services.session_service import (
PersistentSessionService,
)
from cleveragents.application.services.session_workflow import (
SessionWorkflow,
)
from cleveragents.domain.models.core.session import (
MessageRole,
SessionActorNotConfiguredError,
)
from cleveragents.infrastructure.database.models import Base
from cleveragents.infrastructure.database.repositories import (
SessionMessageRepository,
SessionRepository,
)
# ---------------------------------------------------------------------------
# Stub LLM — returns deterministic responses without LLM API calls
# ---------------------------------------------------------------------------
_STUB_TEXT = "This is a real response from the stub LLM actor."
class _StubLLM:
"""Minimal LLM stub that behaves like a LangChain chat model."""
def invoke(self, messages: Any, **kwargs: Any) -> Any:
class _Resp:
content = _STUB_TEXT
def __init__(self) -> None:
self.tool_calls: list[Any] = []
self.response_metadata: dict[str, Any] = {
"usage": {"input_tokens": 10, "output_tokens": 20}
}
return _Resp()
def stream(self, messages: Any, **kwargs: Any) -> Iterator[Any]:
for word in _STUB_TEXT.split():
class _Chunk:
def __init__(self, t: str) -> None:
self.content = t + " "
yield _Chunk(word)
# ---------------------------------------------------------------------------
# Fixture helpers
# ---------------------------------------------------------------------------
def _make_db_session_factory() -> Any:
"""Create an in-memory SQLite engine and return a SQLAlchemy session factory."""
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
return sessionmaker(bind=engine, expire_on_commit=False)
def _make_workflow(
actor_name: str | None = "openai/gpt-4",
llm_factory: Any = None,
) -> tuple[SessionWorkflow, Any]:
"""Build a real SessionWorkflow backed by an in-memory SQLite database."""
db_factory = _make_db_session_factory()
db_session = db_factory()
def get_db() -> Any:
return db_session
session_repo = SessionRepository(get_db)
message_repo = SessionMessageRepository(get_db)
svc = PersistentSessionService(session_repo=session_repo, message_repo=message_repo)
# Create a real session in the DB
session = svc.create(actor_name=actor_name)
factory = llm_factory if llm_factory is not None else lambda _actor: _StubLLM()
wf = SessionWorkflow(session_service=svc, llm_factory=factory)
# No monkey-patching needed — llm_factory provides clean DI (addresses M8)
return wf, session
# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------
def tell_persists() -> None:
"""Verify tell() returns real LLM response and persists messages."""
wf, session = _make_workflow(actor_name="openai/gpt-4")
result = wf.tell(
session_id=session.session_id,
prompt="What can you help me with?",
)
assert result.assistant_message == _STUB_TEXT, (
f"Expected stub response, got: {result.assistant_message!r}"
)
assert result.input_tokens > 0, "input_tokens should be > 0"
assert result.output_tokens > 0, "output_tokens should be > 0"
# Verify messages were persisted via the service
messages = wf._session_service.get_messages(session.session_id)
roles = [m.role for m in messages]
assert MessageRole.USER in roles, "User message not persisted"
assert MessageRole.ASSISTANT in roles, "Assistant message not persisted"
print("session-tell-persists-ok")
def tell_no_actor() -> None:
"""Verify tell() raises SessionActorNotConfiguredError when no actor set."""
wf, session = _make_workflow(actor_name=None)
try:
wf.tell(session_id=session.session_id, prompt="Hello")
print(
"FAIL: expected SessionActorNotConfiguredError not raised",
file=sys.stderr,
)
sys.exit(1)
except SessionActorNotConfiguredError as exc:
assert "actor" in str(exc).lower(), (
f"Error message should mention 'actor', got: {exc}"
)
print("session-tell-no-actor-ok")
def tell_stream() -> None:
"""Verify tell_stream() yields tokens from the stub LLM."""
wf, session = _make_workflow(actor_name="openai/gpt-4")
tokens: list[str] = []
for token in wf.tell_stream(
session_id=session.session_id,
prompt="Hello",
):
tokens.append(token)
assert len(tokens) > 0, "Expected tokens to be yielded, got none"
full_response = "".join(tokens).strip()
assert len(full_response) > 0, f"Expected non-empty stream, got: {full_response!r}"
print("session-tell-stream-ok")
def tell_actor_override() -> None:
"""Verify tell() uses actor_override instead of session actor."""
captured_actor: list[str] = []
def _tracking_resolve(actor_name: str) -> Any:
captured_actor.append(actor_name)
return _StubLLM()
wf, session = _make_workflow(
actor_name="anthropic/claude-3-haiku",
llm_factory=_tracking_resolve,
)
result = wf.tell(
session_id=session.session_id,
prompt="Hello",
actor_override="openai/gpt-4",
)
assert captured_actor, "Actor resolver was never called"
assert captured_actor[0] == "openai/gpt-4", (
f"Expected 'openai/gpt-4' actor, got {captured_actor[0]!r}"
)
assert result.assistant_message is not None
print("session-tell-actor-override-ok")
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
COMMANDS = {
"tell-persists": tell_persists,
"tell-no-actor": tell_no_actor,
"tell-stream": tell_stream,
"tell-actor-override": tell_actor_override,
}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
print(
f"Usage: {sys.argv[0]} <command>\nCommands: {', '.join(COMMANDS)}",
file=sys.stderr,
)
sys.exit(1)
COMMANDS[sys.argv[1]]()