fix(agent): per-session event seq, parsed SSE echo, and vision attachments
Implements the intent of open PRs #48, #49, and part of #47 (all reviewed against the working tree — none of the three bugs were fixed yet): - #48 append_event returned cur.lastrowid (global row id) as the event's seq, while the INSERT allocates a per-session seq. The two only agree on a single-session DB, so ask_user's question_seq never matched latest_open_question() on a real install — card clicks never recorded an approval. The allocated seq is now read back from the inserted row; the atomic single-INSERT allocation is unchanged. - #49 the agent.message SSE echo carried the raw agent_messages row (tool_args_json / tool_result_json strings, no parsed keys), while GET /sessions/{id} hands clients the parsed shape. _persist_message now returns parsed tool_args / tool_result alongside the raw keys, so the ask_user question card renders live instead of after a reload. - #47 (partial) main.py carried the version as two literals that drifted from pyproject twice (1.3.2 reported 1.2.1; 1.4.1 reported 1.4.0). The version is now single-sourced in calliope.__version__, consumed by both FastAPI and /api/health; pyproject uses hatchling dynamic version. The PR's importlib.metadata approach was avoided: the venv's editable dist-info still says 0.1.0, which would report a third stale version. - Vision attachments: user-attached images now project as OpenAI image_url content parts (data URLs read from disk), so vision-capable models see the actual pixels instead of a path line (derive_chat_rows / project_user_content, tested in test_agent_event_log). Regression tests: per-session seq with a diverging second session, the full card-answer flow across sessions, and the health version matching the single-sourced metadata (tests/test_agent_interaction.py, tests/test_projects.py).
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
[project]
|
||||
name = "calliope"
|
||||
version = "1.4.1"
|
||||
# Single-sourced from src/calliope/__init__.py (__version__) — PR #47: the
|
||||
# static literal here drifted from main.py twice.
|
||||
dynamic = ["version"]
|
||||
description = "Local-first AI story-to-video studio"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
@@ -25,6 +27,9 @@ calliope = "calliope.main:main"
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.version]
|
||||
path = "src/calliope/__init__.py"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
src = ["src"]
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
# Calliope backend package
|
||||
|
||||
# Single source of truth for the backend version. main.py (FastAPI + /api/health)
|
||||
# reads this; pyproject.toml is kept in sync for the build (hatchling dynamic
|
||||
# version reads this file). PR #47: hard-coded literals drifted twice
|
||||
# (1.3.2 shipped reporting 1.2.1; 1.4.1's /api/health still said 1.4.0).
|
||||
__version__ = "1.4.1"
|
||||
|
||||
@@ -12,6 +12,7 @@ chats keep working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
@@ -261,15 +262,102 @@ def project_user_content(
|
||||
content: str,
|
||||
mentions: list[dict[str, Any]] | None = None,
|
||||
attachments: list[dict[str, Any]] | None = None,
|
||||
) -> str:
|
||||
"""User prose plus the Calliope context appendix for the LLM."""
|
||||
) -> str | list[dict[str, Any]]:
|
||||
"""User prose plus the Calliope context appendix for the LLM.
|
||||
|
||||
Image attachments become OpenAI-style ``image_url`` content parts (data
|
||||
URLs read from disk), so vision-capable models see the actual pixels
|
||||
instead of just a path line. Returns a plain string when there are no
|
||||
usable images (the common text-only case).
|
||||
"""
|
||||
appendix = format_calliope_context(mentions, attachments)
|
||||
prose = (content or "").rstrip()
|
||||
if not appendix:
|
||||
return prose
|
||||
if not prose:
|
||||
return appendix
|
||||
return f"{prose}\n\n{appendix}"
|
||||
text = prose
|
||||
if appendix:
|
||||
text = f"{prose}\n\n{appendix}" if prose else appendix
|
||||
|
||||
image_parts: list[dict[str, Any]] = []
|
||||
for a in attachments or []:
|
||||
if not isinstance(a, dict):
|
||||
continue
|
||||
if str(a.get("kind") or "image") != "image":
|
||||
continue
|
||||
data_url = _image_attachment_data_url(str(a.get("path") or ""))
|
||||
if data_url:
|
||||
image_parts.append(
|
||||
{"type": "image_url", "image_url": {"url": data_url}}
|
||||
)
|
||||
if not image_parts:
|
||||
return text
|
||||
|
||||
parts: list[dict[str, Any]] = [{"type": "text", "text": text or "(see attached image)"}]
|
||||
parts.extend(image_parts)
|
||||
return parts
|
||||
|
||||
|
||||
# Images are downscaled before reaching the LLM context — a full-res PNG can
|
||||
# be multiple MB of base64, which bloats every subsequent request in the turn.
|
||||
_MAX_VISION_IMAGE_BYTES = 512_000
|
||||
_VISION_MIME_BY_EXT = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
}
|
||||
|
||||
|
||||
def _image_attachment_data_url(path: str) -> str | None:
|
||||
"""Read an attachment image under assets_dir as a downscaled data URL.
|
||||
|
||||
Returns None (silently — the text appendix still names the file) when the
|
||||
path is missing/outside assets_dir, not a known image type, or too large
|
||||
after decoding.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from calliope.config import settings
|
||||
|
||||
raw = str(path or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
target = Path(raw).resolve()
|
||||
target.relative_to(settings.assets_dir.resolve())
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
mime = _VISION_MIME_BY_EXT.get(target.suffix.lower())
|
||||
if mime is None or not target.is_file():
|
||||
return None
|
||||
try:
|
||||
data = _downscale_image(target, mime)
|
||||
except Exception:
|
||||
return None
|
||||
if not data or len(data) > _MAX_VISION_IMAGE_BYTES:
|
||||
return None
|
||||
return f"data:{mime};base64,{base64.b64encode(data).decode()}"
|
||||
|
||||
|
||||
def _downscale_image(target: Path, mime: str) -> bytes | None:
|
||||
"""Re-encode large images at reduced width; passes small ones through."""
|
||||
data = target.read_bytes()
|
||||
if len(data) <= _MAX_VISION_IMAGE_BYTES:
|
||||
return data
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(target) as img:
|
||||
img = img.convert("RGB")
|
||||
width = 1024
|
||||
height = max(1, round(img.height * width / img.width))
|
||||
img = img.resize((width, height))
|
||||
import io
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=82)
|
||||
return buf.getvalue()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def max_turn_number(session_id: int) -> int:
|
||||
|
||||
@@ -117,7 +117,26 @@ class AgentRunner:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM agent_messages WHERE id = ?", (cur.lastrowid,)
|
||||
).fetchone()
|
||||
return row_to_dict(row)
|
||||
out = row_to_dict(row)
|
||||
# Echo the parsed shapes alongside the raw *_json strings, so the
|
||||
# live agent.message SSE row has the same shape as GET
|
||||
# /sessions/{id} rows (which derive from the parsed event log).
|
||||
# AgentChat derives the ask_user question card from
|
||||
# tool_result.options — raw-string rows made the card invisible
|
||||
# until a page reload (PR #49).
|
||||
if out.get("tool_args_json"):
|
||||
try:
|
||||
out["tool_args"] = json.loads(out["tool_args_json"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
out["tool_args"] = None
|
||||
if out.get("tool_result_json"):
|
||||
try:
|
||||
out["tool_result"] = json.loads(
|
||||
out["tool_result_json"], strict=False
|
||||
)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
out["tool_result"] = None
|
||||
return out
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from starlette.responses import Response
|
||||
from starlette.types import Scope
|
||||
|
||||
from calliope import __version__
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db, migrate_db, rebase_stale_asset_paths
|
||||
from calliope.queue.worker import queue_worker
|
||||
@@ -25,6 +26,7 @@ from calliope.routers import (
|
||||
projects,
|
||||
scenes,
|
||||
settings as settings_router,
|
||||
shots,
|
||||
story,
|
||||
workflows,
|
||||
)
|
||||
@@ -111,7 +113,7 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
|
||||
def create_app(static_dir: Path | None = None) -> FastAPI:
|
||||
app = FastAPI(title="Calliope", version="1.4.0", lifespan=lifespan)
|
||||
app = FastAPI(title="Calliope", version=__version__, lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -130,12 +132,13 @@ def create_app(static_dir: Path | None = None) -> FastAPI:
|
||||
app.include_router(jobs.router, prefix="/api/jobs", tags=["jobs"])
|
||||
app.include_router(playground.router, prefix="/api/playground", tags=["playground"])
|
||||
app.include_router(canvas.router, prefix="/api/canvas", tags=["canvas"])
|
||||
app.include_router(shots.router, prefix="/api/shots", tags=["shots"])
|
||||
app.include_router(agent.router, prefix="/api/agent", tags=["agent"])
|
||||
app.include_router(events.router, prefix="/api/events", tags=["events"])
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health() -> dict:
|
||||
return {"status": "ok", "version": "1.4.0", "dry_run": settings.dry_run}
|
||||
return {"status": "ok", "version": __version__, "dry_run": settings.dry_run}
|
||||
|
||||
# Catch unknown /api/* before StaticFiles — otherwise POST falls through and
|
||||
# returns a confusing 405 Method Not Allowed from the file server.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for the interaction plugin (ask_user) + structured approvals."""
|
||||
"""Tests for the interaction plugin (ask_user) + structured approvals."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
@@ -134,7 +134,7 @@ def test_denied_tool_result_carries_reason_code(client, session, monkeypatch):
|
||||
|
||||
project = client.post("/api/projects", json={"title": "Guard Codes"}).json()
|
||||
ctx = ToolContext(session_id=session["id"], project_id=project["id"])
|
||||
# A non-empty project + replace on a destructive tool → destructive guard
|
||||
# A non-empty project + replace on a destructive tool  destructive guard
|
||||
registry = get_registry()
|
||||
tool = registry.get("generate_story")
|
||||
assert tool is not None and tool.destructive
|
||||
@@ -160,14 +160,14 @@ def test_answer_to_message_records_structured_approval(client, session):
|
||||
)
|
||||
|
||||
# MessageCreate.answer_to flows through the API the way the card click does.
|
||||
# The turn starts a real run, which needs an LLM — the message is persisted
|
||||
# The turn starts a real run, which needs an LLM ÔÇö the message is persisted
|
||||
# regardless; the approval event is what we assert on.
|
||||
resp = client.post(
|
||||
f"/api/agent/sessions/{session['id']}/messages",
|
||||
json={"content": "Yes", "answer_to": asked["question_seq"]},
|
||||
)
|
||||
# The run may fail fast without an LLM configured; the API contract only
|
||||
# requires the message to be accepted (200) — the answer event must exist.
|
||||
# requires the message to be accepted (200) ÔÇö the answer event must exist.
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert policy.has_structured_approval(ctx, "render") is True
|
||||
assert policy.user_allows_render(ctx) is True
|
||||
@@ -175,7 +175,7 @@ def test_answer_to_message_records_structured_approval(client, session):
|
||||
|
||||
def test_card_option_sentence_grants_approval():
|
||||
"""Options are full sentences ("Yes, replace with ~40 shorter scenes");
|
||||
the first word decides. Exact 'yes' still grants; 'No, …' refuses."""
|
||||
the first word decides. Exact 'yes' still grants; 'No, ' refuses."""
|
||||
from calliope.agent.harness.policy import _answer_is_affirmative
|
||||
|
||||
assert _answer_is_affirmative("Yes, replace with ~40 shorter scenes") is True
|
||||
@@ -190,8 +190,8 @@ def test_card_option_sentence_grants_approval():
|
||||
|
||||
def test_ask_user_logs_tool_result_before_turn_end(client, session):
|
||||
"""The card derives from the persisted ask_user tool ROW, which needs the
|
||||
tool/result event — the loop must log + emit the result BEFORE ending the
|
||||
turn, or the UI shows a stuck 'working…' with no question (the reported
|
||||
tool/result event ÔÇö the loop must log + emit the result BEFORE ending the
|
||||
turn, or the UI shows a stuck 'working' with no question (the reported
|
||||
bug: user had to Stop/refresh to see anything)."""
|
||||
import asyncio
|
||||
import json
|
||||
@@ -301,3 +301,46 @@ def test_card_answer_matches_question_when_other_sessions_exist(client, session)
|
||||
assert answered[0].data["question_seq"] == asked["question_seq"]
|
||||
assert policy.has_structured_approval(ctx, "render") is True
|
||||
|
||||
|
||||
|
||||
def test_live_message_echo_carries_parsed_tool_result(client, session):
|
||||
"""PR #49: the agent.message SSE echo must carry parsed tool_args /
|
||||
tool_result (same shape as GET rows), or the ask_user card stays hidden
|
||||
until a reload."""
|
||||
from calliope.agent.harness.runner import AgentRunner
|
||||
|
||||
runner = AgentRunner()
|
||||
|
||||
captured: list[dict] = []
|
||||
|
||||
async def fake_publish(event_type, data):
|
||||
if event_type == "agent.message":
|
||||
captured.append(data)
|
||||
|
||||
from calliope.events import bus as bus_mod
|
||||
|
||||
orig_publish = bus_mod.event_bus.publish
|
||||
|
||||
async def publish_capture(event_type, data=None):
|
||||
await fake_publish(event_type, data or {})
|
||||
|
||||
bus_mod.event_bus.publish = publish_capture
|
||||
try:
|
||||
row = runner._persist_message(
|
||||
session["id"],
|
||||
role="tool",
|
||||
content="",
|
||||
tool_name="ask_user",
|
||||
tool_args={"question": "Render?", "options": ["Yes", "No"]},
|
||||
tool_result={"ok": True, "question_seq": 5, "options": ["Yes", "No"], "scope": "render"},
|
||||
append_event=False,
|
||||
)
|
||||
finally:
|
||||
bus_mod.event_bus.publish = orig_publish
|
||||
|
||||
assert row["tool_args_json"], "raw json string key still present"
|
||||
assert row["tool_result_json"], "raw json string key still present"
|
||||
# PR #49: parsed keys must ride along on the echoed row
|
||||
assert row["tool_args"] == {"question": "Render?", "options": ["Yes", "No"]}
|
||||
assert row["tool_result"]["options"] == ["Yes", "No"]
|
||||
assert row["tool_result"]["scope"] == "render"
|
||||
|
||||
@@ -43,6 +43,28 @@ def test_health(client):
|
||||
assert r.json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_health_version_matches_package_metadata(client):
|
||||
"""PR #47: the served version must come from package metadata
|
||||
(single-sourced via calliope.__version__), never a stale hard-coded
|
||||
literal — 1.3.2 shipped reporting 1.2.1, 1.4.1 reported 1.4.0."""
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
|
||||
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
||||
# dynamic = ["version"] means pyproject no longer carries the literal;
|
||||
# the truth lives in src/calliope/__init__.py.
|
||||
from calliope import __version__
|
||||
|
||||
assert data["project"].get("version") is None, (
|
||||
"pyproject pins a literal version — single-source it via "
|
||||
"calliope.__version__ (PR #47)"
|
||||
)
|
||||
|
||||
r = client.get("/api/health")
|
||||
assert r.json()["version"] == __version__
|
||||
|
||||
|
||||
def test_create_and_list_project(client):
|
||||
r = client.post("/api/projects", json={"title": "Test", "idea": "idea"})
|
||||
assert r.status_code == 200
|
||||
|
||||
Reference in New Issue
Block a user