mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Fix: open findings cleanup (#122)
* refactor(usage): remove the unconsumed per-agent USAGE_UPDATE event USAGE_UPDATE was published per active agent each sweep, bridged, and broadcast to /ws/system, but no panel client ever consumed it — the dashboard reads only the aggregate USAGE_SNAPSHOT. Every emission was wasted event-bus and WebSocket traffic. Drop the UsageUpdate payload, publish_usage_update and its throttle, the EventType member, and the bridge subscription. Keep USAGE_SNAPSHOT, which already carries the per-agent breakdown, so no live data is lost. * refactor(prompter): remove the legacy local-LLM HTTP endpoints The panel uses only the live SDK-intake path (/prompter/live/*); the legacy /prompter/chat, /draft and /sessions/* endpoints — backed by the local Ollama LLM with hardcoded prompts — had no remaining caller. Remove the router, its mount in app.py, and its integration test. The live router and the shared draft-confirmation service are untouched. * refactor(prompter): drop the dead legacy local-LLM service + schemas With the legacy HTTP endpoints gone, the local-LLM chat/draft/session methods, their prompt constants, the ConfirmOverrides/TurnResult dataclasses, and the entire prompter schema module had no production caller (only their own tests). Remove them, keeping the live-intake path: create_task_from_draft / confirm_live_draft, the enum/priority/team coercion, and the pure description/readiness helpers. * refactor(agents): stop granting the Task sub-agent tool to roles Every agent role was granted the built-in Task tool, but no role prompt or workflow uses it and there are no custom sub-agent definitions — so a Task call only spawns a context-blind generic sub-agent that burns budget (ToolSearch, the comment's stated use, is MCP-only and not callable in agent containers). Drop Task from all three grant points in lockstep: the --tools spawn flag and both _ROLE_BUILTIN_TOOLS maps (system-prompt + briefing layers), with a regression guard added to each layer's test. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -115,14 +115,13 @@ def _autogen_verbs_layer(prompts_path: Path, role: "AgentRole") -> str | None:
|
||||
# briefing, so we hoist the exact call into a top-of-system-prompt
|
||||
# layer (highest-priority instruction the model sees).
|
||||
#
|
||||
# Read/Bash/Grep/Glob/Task/TodoWrite are needed by every role; Edit/Write
|
||||
# Read/Bash/Grep/Glob/TodoWrite are needed by every role; Edit/Write
|
||||
# only by roles that author code or docs.
|
||||
_BUILTIN_TOOLS_COMMON: tuple[str, ...] = (
|
||||
"Read",
|
||||
"Bash",
|
||||
"Grep",
|
||||
"Glob",
|
||||
"Task",
|
||||
"TodoWrite",
|
||||
)
|
||||
_BUILTIN_TOOLS_AUTHORS: tuple[str, ...] = (*_BUILTIN_TOOLS_COMMON, "Edit", "Write")
|
||||
|
||||
@@ -30,7 +30,6 @@ from roboco.api.routes.optimal import router as optimal_router
|
||||
from roboco.api.routes.orchestrator import router as orchestrator_router
|
||||
from roboco.api.routes.product import router as product_router
|
||||
from roboco.api.routes.project import router as project_router
|
||||
from roboco.api.routes.prompter import router as prompter_router
|
||||
from roboco.api.routes.prompter_live import router as prompter_live_router
|
||||
from roboco.api.routes.provider import router as provider_router
|
||||
from roboco.api.routes.sessions import router as sessions_router
|
||||
@@ -315,12 +314,6 @@ def create_app() -> FastAPI:
|
||||
tags=["Providers"],
|
||||
)
|
||||
|
||||
# Prompter — conversational task drafting assistant
|
||||
app.include_router(
|
||||
prompter_router,
|
||||
prefix=f"{api_prefix}/prompter",
|
||||
tags=["Prompter"],
|
||||
)
|
||||
# Prompter live chat — panel <-> spawned intake agent (SSE + relay)
|
||||
app.include_router(
|
||||
prompter_live_router,
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
"""
|
||||
Prompter API Routes
|
||||
|
||||
Session-based conversational assistant endpoints for drafting tasks:
|
||||
- POST /api/prompter/sessions : create a new session
|
||||
- POST /api/prompter/sessions/{id}/messages : send user message, get AI reply
|
||||
- GET /api/prompter/sessions/{id}/draft : get structured task draft
|
||||
- POST /api/prompter/sessions/{id}/confirm : confirm draft → create real task
|
||||
|
||||
Legacy stateless endpoints (retained for backward compatibility):
|
||||
- POST /api/prompter/chat : back-and-forth conversation (stateless)
|
||||
- POST /api/prompter/draft : structured task draft generation (stateless)
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from roboco.api.deps import CurrentAgentContext, DbSession
|
||||
from roboco.api.schemas.prompter import (
|
||||
ChatMessage,
|
||||
PrompterChatRequest,
|
||||
PrompterChatResponse,
|
||||
PrompterDraftRequest,
|
||||
PrompterDraftResponse,
|
||||
PrompterDraftTask,
|
||||
PrompterMessageRequest,
|
||||
PrompterMessageResponse,
|
||||
PrompterSessionResponse,
|
||||
PrompterTurnResponse,
|
||||
TaskConfirmRequest,
|
||||
TaskDraftResponse,
|
||||
)
|
||||
from roboco.services.base import NotFoundError, ServiceError, ValidationError
|
||||
from roboco.services.prompter import ConfirmOverrides, get_prompter_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _translate_error(e: ServiceError) -> HTTPException:
|
||||
"""Service errors → HTTP status."""
|
||||
if isinstance(e, NotFoundError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": "not_found", "message": e.message},
|
||||
)
|
||||
if isinstance(e, ValidationError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"error": "validation_error",
|
||||
"message": e.message,
|
||||
"field": e.field,
|
||||
},
|
||||
)
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={"error": "internal_error", "message": e.message},
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SESSION-BASED ENDPOINTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions",
|
||||
response_model=PrompterSessionResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_session(
|
||||
db: DbSession,
|
||||
agent: CurrentAgentContext,
|
||||
) -> PrompterSessionResponse:
|
||||
"""Create a new Prompter conversation session linked to the authenticated agent."""
|
||||
service = get_prompter_service(db)
|
||||
try:
|
||||
session = await service.create_session(agent_id=agent.agent_id)
|
||||
except ServiceError as e:
|
||||
raise _translate_error(e) from e
|
||||
# Commit explicitly: the rest of the write surface (tasks, a2a, ...) does
|
||||
# the same rather than rely on the request-teardown auto-commit, which is
|
||||
# sensitive to middleware/teardown ordering. Without this the 201 is
|
||||
# returned but the row may never persist, so the next request 404s.
|
||||
await db.commit()
|
||||
|
||||
return PrompterSessionResponse(
|
||||
id=UUID(str(session.id)),
|
||||
agent_id=UUID(str(session.agent_id)),
|
||||
status=session.status,
|
||||
created_at=session.created_at,
|
||||
updated_at=session.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/messages",
|
||||
response_model=PrompterTurnResponse,
|
||||
)
|
||||
async def send_message(
|
||||
session_id: UUID,
|
||||
data: PrompterMessageRequest,
|
||||
db: DbSession,
|
||||
agent: CurrentAgentContext,
|
||||
) -> PrompterTurnResponse:
|
||||
"""
|
||||
Accept a user message, append it and an AI assistant response to the
|
||||
conversation, and return the updated message list plus the readiness
|
||||
signal (``draft_ready`` and the coarse ``scale`` hint) for this turn.
|
||||
"""
|
||||
service = get_prompter_service(db)
|
||||
try:
|
||||
turn = await service.send_message(
|
||||
session_id=session_id,
|
||||
agent_id=agent.agent_id,
|
||||
content=data.content,
|
||||
context=data.context,
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise _translate_error(e) from e
|
||||
await db.commit()
|
||||
|
||||
return PrompterTurnResponse(
|
||||
messages=[
|
||||
PrompterMessageResponse(
|
||||
id=UUID(str(msg.id)),
|
||||
session_id=UUID(str(msg.session_id)),
|
||||
role=msg.role,
|
||||
content=msg.content,
|
||||
created_at=msg.created_at,
|
||||
)
|
||||
for msg in turn.messages
|
||||
],
|
||||
draft_ready=turn.draft_ready,
|
||||
scale=turn.scale,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sessions/{session_id}/draft",
|
||||
response_model=TaskDraftResponse,
|
||||
)
|
||||
async def get_draft(
|
||||
session_id: UUID,
|
||||
db: DbSession,
|
||||
agent: CurrentAgentContext,
|
||||
) -> TaskDraftResponse:
|
||||
"""
|
||||
Return a structured task draft extracted from conversation history via LLM.
|
||||
|
||||
The draft contains: title, description, acceptance_criteria, team,
|
||||
task_type, nature, and estimated_complexity.
|
||||
"""
|
||||
service = get_prompter_service(db)
|
||||
try:
|
||||
draft_record = await service.get_or_generate_draft(
|
||||
session_id=session_id,
|
||||
agent_id=agent.agent_id,
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise _translate_error(e) from e
|
||||
# Persist a newly generated draft (no-op when it was already cached).
|
||||
await db.commit()
|
||||
|
||||
# Parse the stored draft_data into PrompterDraftTask for validation
|
||||
try:
|
||||
draft_task = PrompterDraftTask(**draft_record.draft_data)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={
|
||||
"error": "draft_schema_error",
|
||||
"message": f"Stored draft did not match schema: {exc}",
|
||||
"raw_draft": draft_record.draft_data,
|
||||
},
|
||||
) from exc
|
||||
|
||||
return TaskDraftResponse(
|
||||
id=UUID(str(draft_record.id)),
|
||||
session_id=UUID(str(draft_record.session_id)),
|
||||
draft=draft_task,
|
||||
confirmed_at=draft_record.confirmed_at,
|
||||
task_id=UUID(str(draft_record.task_id)) if draft_record.task_id else None,
|
||||
created_at=draft_record.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/confirm",
|
||||
response_model=dict,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def confirm_draft(
|
||||
session_id: UUID,
|
||||
data: TaskConfirmRequest,
|
||||
db: DbSession,
|
||||
agent: CurrentAgentContext,
|
||||
) -> dict:
|
||||
"""
|
||||
Validate the draft and create a real Task using the existing TaskService.
|
||||
|
||||
Returns the created task ID.
|
||||
"""
|
||||
service = get_prompter_service(db)
|
||||
try:
|
||||
task_id = await service.confirm_draft(
|
||||
session_id=session_id,
|
||||
agent_id=agent.agent_id,
|
||||
confirm_overrides=ConfirmOverrides(
|
||||
project_id=data.project_id,
|
||||
product_id=data.product_id,
|
||||
assigned_to=data.assigned_to,
|
||||
extra=data.overrides,
|
||||
draft=data.draft.model_dump(mode="json") if data.draft else None,
|
||||
),
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise _translate_error(e) from e
|
||||
await db.commit()
|
||||
|
||||
return {"task_id": str(task_id)}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# LEGACY STATELESS ENDPOINTS (backward compatibility)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.post("/chat", response_model=PrompterChatResponse)
|
||||
async def prompter_chat(
|
||||
data: PrompterChatRequest,
|
||||
_agent: CurrentAgentContext,
|
||||
) -> PrompterChatResponse:
|
||||
"""
|
||||
Continue a Prompter conversation (stateless).
|
||||
|
||||
The frontend sends the full conversation history (including the new user
|
||||
message). The assistant replies, optionally signalling that enough context
|
||||
has been gathered to generate a draft (`draft_ready=True`).
|
||||
"""
|
||||
service = get_prompter_service()
|
||||
try:
|
||||
result = await service.chat(
|
||||
messages=[msg.model_dump() for msg in data.messages],
|
||||
context=data.context,
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise _translate_error(e) from e
|
||||
|
||||
return PrompterChatResponse(
|
||||
message=result["message"],
|
||||
draft_ready=result["draft_ready"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/draft", response_model=PrompterDraftResponse)
|
||||
async def prompter_draft(
|
||||
data: PrompterDraftRequest,
|
||||
_agent: CurrentAgentContext,
|
||||
) -> PrompterDraftResponse:
|
||||
"""
|
||||
Generate a structured task draft from conversation context (stateless).
|
||||
|
||||
The frontend sends the full conversation history. The backend calls the
|
||||
LLM to produce a JSON draft conforming to the TaskCreate schema.
|
||||
"""
|
||||
service = get_prompter_service()
|
||||
try:
|
||||
result = await service.draft(
|
||||
messages=[msg.model_dump() for msg in data.messages],
|
||||
context=data.context,
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise _translate_error(e) from e
|
||||
|
||||
draft_raw = result["draft"]
|
||||
try:
|
||||
draft = PrompterDraftTask(**draft_raw)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={
|
||||
"error": "draft_schema_error",
|
||||
"message": f"Generated draft did not match schema: {e}",
|
||||
"raw_draft": draft_raw,
|
||||
},
|
||||
) from e
|
||||
|
||||
return PrompterDraftResponse(
|
||||
draft=draft,
|
||||
reasoning=result["reasoning"],
|
||||
)
|
||||
|
||||
|
||||
def _messages_to_dicts(messages: list[ChatMessage]) -> list[dict[str, str]]:
|
||||
"""Convert ChatMessage list to dict list (internal helper)."""
|
||||
return [msg.model_dump() for msg in messages]
|
||||
@@ -1,291 +0,0 @@
|
||||
"""
|
||||
Prompter API Schemas
|
||||
|
||||
Request/response models for the conversational Prompter assistant
|
||||
that helps users draft tasks through natural language.
|
||||
|
||||
Includes both the session-based schemas (for the DB-persisted approach)
|
||||
and the legacy stateless schemas retained for backward compatibility.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from roboco.models.base import (
|
||||
Complexity,
|
||||
TaskNature,
|
||||
TaskType,
|
||||
Team,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# SHARED MESSAGE SCHEMA
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
"""A single message in the Prompter conversation."""
|
||||
|
||||
role: str = Field(..., description="One of: user, assistant, system")
|
||||
content: str = Field(..., min_length=1, description="Message text")
|
||||
|
||||
@field_validator("role")
|
||||
@classmethod
|
||||
def _valid_role(cls, v: str) -> str:
|
||||
if v not in {"user", "assistant", "system"}:
|
||||
raise ValueError("role must be one of: user, assistant, system")
|
||||
return v
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SESSION-BASED SCHEMAS (acceptance-criteria-required names)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class PrompterSessionResponse(BaseModel):
|
||||
"""Response for session creation and retrieval."""
|
||||
|
||||
id: UUID
|
||||
agent_id: UUID
|
||||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class PrompterMessageRequest(BaseModel):
|
||||
"""Request body for POST /api/prompter/sessions/{id}/messages."""
|
||||
|
||||
content: str = Field(..., min_length=1, description="The user's message text")
|
||||
context: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Optional per-turn context overrides",
|
||||
)
|
||||
|
||||
|
||||
class PrompterMessageResponse(BaseModel):
|
||||
"""A single message record returned to the client."""
|
||||
|
||||
id: UUID
|
||||
session_id: UUID
|
||||
role: str
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class PrompterTurnResponse(BaseModel):
|
||||
"""Result of a chat turn: the full message list plus the readiness signal.
|
||||
|
||||
Carries ``draft_ready`` (and the coarse ``scale`` hint) so the frontend
|
||||
consumes the backend's judgement instead of re-deriving it by string match.
|
||||
"""
|
||||
|
||||
messages: list[PrompterMessageResponse]
|
||||
draft_ready: bool = False
|
||||
scale: str | None = Field(
|
||||
default=None,
|
||||
description="Coarse size hint from the assistant: 'single' or 'multi'",
|
||||
)
|
||||
|
||||
|
||||
class TaskConfirmRequest(BaseModel):
|
||||
"""Request body for POST /api/prompter/sessions/{id}/confirm.
|
||||
|
||||
Allows the frontend to pass overrides that should be applied
|
||||
to the draft before the real task is created.
|
||||
"""
|
||||
|
||||
project_id: UUID | None = Field(
|
||||
default=None,
|
||||
description="Override project_id from the draft (required if draft omits it)",
|
||||
)
|
||||
product_id: UUID | None = Field(
|
||||
default=None,
|
||||
description="Override product_id from the draft",
|
||||
)
|
||||
assigned_to: str | None = Field(
|
||||
default=None,
|
||||
description="Agent slug or UUID to assign the task to",
|
||||
)
|
||||
overrides: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Additional fields to override in the draft before task creation",
|
||||
)
|
||||
draft: "PrompterDraftTask | None" = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"The human-edited structured draft. When present it replaces the "
|
||||
"stored draft (after re-validation and description re-composition) "
|
||||
"before the task is created."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DRAFT TASK SCHEMA (shared between session and legacy paths)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class CellWork(BaseModel):
|
||||
"""One cell's slice of a task's work — the per-cell breakdown of The Work.
|
||||
|
||||
For a single-cell task there is exactly one entry; a board-led feature
|
||||
carries one entry per participating cell.
|
||||
"""
|
||||
|
||||
team: Team = Field(
|
||||
..., description="The cell (or coordinating team) doing this work"
|
||||
)
|
||||
summary: str = Field(
|
||||
..., min_length=1, description="One-line summary of this cell's slice"
|
||||
)
|
||||
items: list[str] = Field(
|
||||
default_factory=list, description="Concrete deliverables for this cell"
|
||||
)
|
||||
|
||||
|
||||
class PrompterDraftTask(BaseModel):
|
||||
"""A task draft produced by the Prompter.
|
||||
|
||||
Mirrors TaskCreate fields so the frontend can POST /api/tasks
|
||||
with confirmed_by_human=True after human review.
|
||||
|
||||
The structured spec fields (``objective``, ``what_this_builds``,
|
||||
``the_work``, ``notes``) are first-class in this contract but persisted
|
||||
inside the existing ``draft_data`` JSONB column — no migration. The backend
|
||||
composes ``description`` deterministically from them; ``acceptance_criteria``
|
||||
renders as Success Criteria.
|
||||
"""
|
||||
|
||||
title: str = Field(..., min_length=1, max_length=200)
|
||||
description: str = Field(..., min_length=20)
|
||||
acceptance_criteria: list[str] = Field(..., min_length=1)
|
||||
team: Team = Field(...)
|
||||
priority: int = Field(default=2, ge=0, le=3)
|
||||
task_type: TaskType = Field(...)
|
||||
nature: TaskNature = Field(...)
|
||||
estimated_complexity: Complexity = Field(...)
|
||||
|
||||
# Structured spec fields — optional for backward compatibility.
|
||||
objective: str | None = Field(
|
||||
default=None,
|
||||
description="The outcome this task delivers, in one or two sentences",
|
||||
)
|
||||
what_this_builds: list[str] = Field(
|
||||
default_factory=list, description="Concrete artifacts this task produces"
|
||||
)
|
||||
the_work: list[CellWork] = Field(
|
||||
default_factory=list,
|
||||
description="Per-cell breakdown; length drives single vs multi-cell",
|
||||
)
|
||||
notes: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Constraints, reuse pointers, things to confirm with the human",
|
||||
)
|
||||
project_id: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Project UUID as string; exactly one of project_id or "
|
||||
"product_id must be set"
|
||||
),
|
||||
)
|
||||
product_id: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Product UUID as string; exactly one of project_id or "
|
||||
"product_id must be set"
|
||||
),
|
||||
)
|
||||
assigned_to: str | None = Field(
|
||||
default=None,
|
||||
description="Agent slug or UUID to assign the task to",
|
||||
)
|
||||
target_date: str | None = Field(
|
||||
default=None,
|
||||
description="ISO-8601 target completion date",
|
||||
)
|
||||
|
||||
# Provenance — always set by the prompter backend
|
||||
source: str = "prompter"
|
||||
confirmed_by_human: bool = False
|
||||
|
||||
|
||||
# Resolve TaskConfirmRequest.draft now that PrompterDraftTask exists.
|
||||
TaskConfirmRequest.model_rebuild()
|
||||
|
||||
|
||||
class TaskDraftResponse(BaseModel):
|
||||
"""Response for GET /api/prompter/sessions/{id}/draft."""
|
||||
|
||||
id: UUID
|
||||
session_id: UUID
|
||||
draft: PrompterDraftTask
|
||||
confirmed_at: datetime | None = None
|
||||
task_id: UUID | None = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# LEGACY STATELESS SCHEMAS (retained for backward compatibility)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class PrompterChatRequest(BaseModel):
|
||||
"""Request to continue a Prompter conversation (stateless)."""
|
||||
|
||||
messages: list[ChatMessage] = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Conversation history including the new user message",
|
||||
)
|
||||
context: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Optional context (project_id, team, prior drafts, etc.)",
|
||||
)
|
||||
|
||||
|
||||
class PrompterChatResponse(BaseModel):
|
||||
"""Response from the Prompter chat endpoint (stateless)."""
|
||||
|
||||
message: str = Field(..., description="Assistant's reply")
|
||||
conversation_id: str | None = Field(
|
||||
default=None, description="Client-managed conversation identifier"
|
||||
)
|
||||
draft_ready: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"True when the assistant believes enough context exists to draft a task"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class PrompterDraftRequest(BaseModel):
|
||||
"""Request to generate a task draft from conversation context (stateless)."""
|
||||
|
||||
messages: list[ChatMessage] = Field(
|
||||
..., min_length=1, description="Full conversation used as drafting context"
|
||||
)
|
||||
context: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Optional overrides (project_id, team, assigned_to, etc.)",
|
||||
)
|
||||
|
||||
|
||||
class PrompterDraftResponse(BaseModel):
|
||||
"""Response from the Prompter draft endpoint (stateless)."""
|
||||
|
||||
draft: PrompterDraftTask = Field(..., description="Structured task draft")
|
||||
reasoning: str = Field(
|
||||
default="",
|
||||
description="Assistant's explanation of how the draft was derived",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -21,7 +21,6 @@ _RATE_LIMIT_WS_TYPES = {
|
||||
}
|
||||
|
||||
_USAGE_WS_TYPES = {
|
||||
EventType.USAGE_UPDATE: "USAGE_UPDATE",
|
||||
EventType.USAGE_SNAPSHOT: "USAGE_SNAPSHOT",
|
||||
}
|
||||
|
||||
@@ -150,13 +149,12 @@ async def _handle_rate_limit_event(event: Event) -> None:
|
||||
|
||||
|
||||
async def _handle_usage_event(event: Event) -> None:
|
||||
"""Forward USAGE_UPDATE/SNAPSHOT events to operator system WS clients.
|
||||
"""Forward USAGE_SNAPSHOT events to operator system WS clients.
|
||||
|
||||
Both event types carry all the fields the panel needs directly in
|
||||
``event.data``; we tag them with the discriminating ``type`` string the
|
||||
The event carries all the fields the panel needs directly in
|
||||
``event.data``; we tag it with the discriminating ``type`` string the
|
||||
panel switches on (the same UPPER_SNAKE mapping the rate-limit handler
|
||||
uses), so the panel can distinguish per-agent updates from aggregate
|
||||
snapshots.
|
||||
uses).
|
||||
"""
|
||||
ws_type = _USAGE_WS_TYPES.get(event.type)
|
||||
if ws_type is None:
|
||||
@@ -197,7 +195,6 @@ def register_websocket_bridge_handlers() -> None:
|
||||
bus.subscribe(EventType.RATE_LIMIT_LIFTED, _handle_rate_limit_event)
|
||||
|
||||
# Usage events -> system WebSocket (panel dashboard)
|
||||
bus.subscribe(EventType.USAGE_UPDATE, _handle_usage_event)
|
||||
bus.subscribe(EventType.USAGE_SNAPSHOT, _handle_usage_event)
|
||||
|
||||
logger.info("WebSocket bridge handlers registered")
|
||||
|
||||
@@ -70,7 +70,6 @@ class EventType(StrEnum):
|
||||
RATE_LIMIT_LIFTED = "rate_limit.lifted"
|
||||
|
||||
# Usage events
|
||||
USAGE_UPDATE = "usage.update"
|
||||
USAGE_SNAPSHOT = "usage.snapshot"
|
||||
|
||||
# Question events
|
||||
|
||||
@@ -1800,8 +1800,6 @@ class AgentOrchestrator:
|
||||
- Read/Write/Edit : file IO inside the workspace
|
||||
- Bash : shell commands (gated by bash-guard hook)
|
||||
- Grep/Glob : code navigation
|
||||
- Task : sub-agent dispatch (used for ToolSearch and
|
||||
other delegated jobs)
|
||||
- TodoWrite : per-session planning
|
||||
Permissions still gate *which* paths Edit/Write can touch (see
|
||||
`_get_role_permissions`), so this is purely about loading vs
|
||||
@@ -1817,7 +1815,7 @@ class AgentOrchestrator:
|
||||
"/app/mcp-config.json",
|
||||
"--strict-mcp-config",
|
||||
"--tools",
|
||||
"Read,Write,Edit,Bash,Grep,Glob,Task,TodoWrite",
|
||||
"Read,Write,Edit,Bash,Grep,Glob,TodoWrite",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--verbose",
|
||||
@@ -2315,7 +2313,6 @@ class AgentOrchestrator:
|
||||
"Bash",
|
||||
"Grep",
|
||||
"Glob",
|
||||
"Task",
|
||||
"TodoWrite",
|
||||
)
|
||||
_ROLE_BUILTIN_TOOLS: ClassVar[dict[str, tuple[str, ...]]] = {
|
||||
@@ -3507,8 +3504,8 @@ class AgentOrchestrator:
|
||||
current progress without waiting for session close.
|
||||
Errors per-agent are caught so one bad agent doesn't abort the whole sweep.
|
||||
|
||||
Additionally publishes USAGE_UPDATE events per agent (throttled to at most
|
||||
one per 5-second window) and a USAGE_SNAPSHOT aggregate after the loop.
|
||||
Also publishes a USAGE_SNAPSHOT aggregate event after the loop so the
|
||||
/ws/system dashboard updates live for active agents.
|
||||
"""
|
||||
if not self._instances:
|
||||
return
|
||||
@@ -3548,25 +3545,6 @@ class AgentOrchestrator:
|
||||
tokens_input, tokens_output = tokens[0], tokens[1]
|
||||
model = instance.config.model if instance.config else "unknown"
|
||||
|
||||
# Publish USAGE_UPDATE event for this agent (throttled).
|
||||
with contextlib.suppress(Exception):
|
||||
from roboco.events import get_event_bus
|
||||
from roboco.services.usage_events import (
|
||||
UsageUpdate,
|
||||
publish_usage_update,
|
||||
)
|
||||
|
||||
await publish_usage_update(
|
||||
get_event_bus(),
|
||||
UsageUpdate(
|
||||
agent_id=agent_id,
|
||||
task_id=instance.current_task_id,
|
||||
input_tokens=tokens_input,
|
||||
output_tokens=tokens_output,
|
||||
model=model,
|
||||
),
|
||||
)
|
||||
|
||||
# Accumulate per-agent data for the aggregate snapshot.
|
||||
with contextlib.suppress(Exception):
|
||||
from roboco.billing.pricing import calculate_cost
|
||||
|
||||
+23
-630
@@ -1,13 +1,10 @@
|
||||
"""
|
||||
Prompter Service
|
||||
|
||||
Conversational LLM assistant that helps users draft tasks.
|
||||
Uses the project's local LLM (Ollama, OpenAI-compatible) for
|
||||
natural-language interaction and structured JSON draft generation —
|
||||
the same engine as RAG/HyDE, so no external API key is required.
|
||||
|
||||
Provides both a session-based approach (DB-persisted) and a
|
||||
legacy stateless interface for backward compatibility.
|
||||
Server-side helper for the live SDK-intake flow: turns a confirmed structured
|
||||
draft into a real Task (``create_task_from_draft`` / ``confirm_live_draft``),
|
||||
plus the pure helpers that compose a task description and parse the interview
|
||||
readiness signal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -16,22 +13,13 @@ import contextlib
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
from uuid import UUID, uuid4
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
|
||||
from roboco.config import settings
|
||||
from roboco.db.tables import (
|
||||
AgentTable,
|
||||
PrompterMessageTable,
|
||||
PrompterSessionTable,
|
||||
TaskDraftTable,
|
||||
TaskTable,
|
||||
)
|
||||
from roboco.db.tables import AgentTable, TaskTable
|
||||
from roboco.foundation.identity import CELL_TEAMS
|
||||
from roboco.models.base import (
|
||||
AgentRole,
|
||||
@@ -42,7 +30,7 @@ from roboco.models.base import (
|
||||
Team,
|
||||
)
|
||||
from roboco.models.task import TaskCreateRequest
|
||||
from roboco.services.base import NotFoundError, ServiceError, ValidationError
|
||||
from roboco.services.base import ServiceError, ValidationError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -57,22 +45,6 @@ _BOARD_REVIEW_ROLES: frozenset[AgentRole] = frozenset(
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConfirmOverrides:
|
||||
"""Optional overrides applied when confirming a draft to create a task."""
|
||||
|
||||
project_id: UUID | None = None
|
||||
product_id: UUID | None = None
|
||||
assigned_to: str | None = None
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
draft: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReadinessTag:
|
||||
"""Parsed contents of an assistant turn's trailing roboco-meta block."""
|
||||
@@ -82,144 +54,17 @@ class ReadinessTag:
|
||||
scale: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TurnResult:
|
||||
"""Outcome of a chat turn: the message list plus the readiness signal."""
|
||||
|
||||
messages: list[PrompterMessageTable]
|
||||
draft_ready: bool = False
|
||||
scale: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PROMPTER_SYSTEM_PROMPT = (
|
||||
"You are the RoboCo Prompter — the intake interviewer for an AI agentic "
|
||||
"software company. A human describes something they want built; you ask a "
|
||||
"few sharp questions, then a launch-ready task spec is handed to the dev "
|
||||
"teams.\n\n"
|
||||
"How RoboCo is organized:\n"
|
||||
"- A human CEO sits above a Board (Product Owner, Head of Marketing, "
|
||||
"Auditor).\n"
|
||||
"- The Main PM coordinates three delivery cells — Backend, Frontend, and "
|
||||
"UX/UI. Each cell has developers, a QA, a PM, and a documenter.\n"
|
||||
"- Small, single-domain work (a bug fix, one endpoint, one component) is "
|
||||
"one task owned by one cell.\n"
|
||||
"- A real feature is board-led: the Board sets requirements, the Main PM "
|
||||
"delegates one subtask per participating cell, and the cells deliver in "
|
||||
"parallel.\n\n"
|
||||
"What a well-formed task looks like (the house standard):\n"
|
||||
"- Objective — the outcome, not the implementation.\n"
|
||||
"- What This Builds — the concrete artifacts.\n"
|
||||
"- The Work — the per-cell breakdown (one cell for small work; Backend, "
|
||||
"Frontend, UX/UI for a feature).\n"
|
||||
"- Notes — constraints, what to reuse, anything to confirm with the human.\n"
|
||||
"- Success Criteria — verifiable acceptance criteria.\n\n"
|
||||
"Your interview discipline:\n"
|
||||
"- Open by reflecting back, in one or two sentences, what you understand "
|
||||
"they want, so they can correct course immediately.\n"
|
||||
"- Then ask only the highest-leverage questions you are actually missing — "
|
||||
"one or two per turn. Never dump a checklist.\n"
|
||||
"- Before you can draft, cover: (1) the true objective, (2) scope "
|
||||
"boundaries — what is explicitly out, (3) the surface — which page, "
|
||||
"endpoint, or component, grounded in the projects/products you are shown, "
|
||||
"(4) reuse vs build — what existing code or services to lean on, "
|
||||
"(5) the audience, (6) what 'done' looks like.\n"
|
||||
"- Stop as soon as objective, scope, surface, and acceptance are clear. "
|
||||
"Aim for two to four turns total. Do not pad the conversation.\n"
|
||||
"- Use the real project and product names you are given; prefer an "
|
||||
"existing surface over inventing one.\n\n"
|
||||
"Every reply ends with exactly one fenced control block the human never "
|
||||
"sees, reporting coverage and readiness:\n"
|
||||
"```roboco-meta\n"
|
||||
'{"covered": ["objective", "scope", "surface", "acceptance"], '
|
||||
'"ready": false, "scale": "single"}\n'
|
||||
"```\n"
|
||||
"- covered: which of objective / scope / surface / reuse / audience / "
|
||||
"acceptance you have nailed down.\n"
|
||||
"- ready: true only when you could write a complete task spec right now.\n"
|
||||
"- scale: 'single' for one-cell work, 'multi' for a board-led feature "
|
||||
"across cells.\n"
|
||||
"Write nothing after that block."
|
||||
)
|
||||
|
||||
_DRAFT_SYSTEM_PROMPT = (
|
||||
"You are the RoboCo Prompter's drafting engine. Given a finished "
|
||||
"conversation, output a single JSON object — a structured task "
|
||||
"draft. No markdown, no prose, no code fence.\n\n"
|
||||
"Required fields:\n"
|
||||
"- title: concise, actionable (max 200 chars).\n"
|
||||
"- objective: the outcome in one or two sentences.\n"
|
||||
"- what_this_builds: array of concrete artifacts (strings).\n"
|
||||
"- the_work: array of per-cell slices. Each item is "
|
||||
'{"team": backend|frontend|ux_ui, "summary": one line, '
|
||||
'"items": [deliverables]}. One entry for single-cell work; one entry per '
|
||||
"participating cell for a board-led feature.\n"
|
||||
"- acceptance_criteria: array of verifiable criteria (at least one) — "
|
||||
"these become Success Criteria.\n"
|
||||
"- notes: array of constraints, reuse pointers, things to confirm (may be "
|
||||
"empty).\n"
|
||||
"- team: the primary cell (backend|frontend|ux_ui). For a multi-cell "
|
||||
"feature set the lead cell here; the backend routes it through the Main "
|
||||
"PM.\n"
|
||||
"- task_type: one of code, documentation, research, planning, design, "
|
||||
"administrative.\n"
|
||||
"- nature: technical or non_technical.\n"
|
||||
"- estimated_complexity: low, medium, high.\n"
|
||||
"- priority: integer 0-3 (0 highest, 3 lowest).\n\n"
|
||||
"Optional, only if unambiguous from context: project_id, product_id, "
|
||||
"assigned_to, target_date.\n\n"
|
||||
"Do NOT write a 'description' field — the backend composes it from your "
|
||||
"structured fields.\n\n"
|
||||
"Example shape (abbreviated):\n"
|
||||
'{"title": "...", "objective": "...", "what_this_builds": ["..."], '
|
||||
'"the_work": [{"team": "backend", "summary": "...", "items": ["..."]}, '
|
||||
'{"team": "frontend", "summary": "...", "items": ["..."]}], '
|
||||
'"acceptance_criteria": ["..."], "notes": ["..."], "team": "backend", '
|
||||
'"task_type": "code", "nature": "technical", '
|
||||
'"estimated_complexity": "high", "priority": 1}\n\n'
|
||||
"Return ONLY the JSON object."
|
||||
)
|
||||
|
||||
|
||||
class PrompterService:
|
||||
"""Service for Prompter chat, session management, and structured draft generation.
|
||||
"""Create tasks from confirmed intake drafts.
|
||||
|
||||
Accepts an optional SQLAlchemy ``AsyncSession`` for the session-based
|
||||
(DB-persisted) interface. When no session is provided, only the legacy
|
||||
stateless ``chat()`` and ``draft()`` methods are available.
|
||||
Accepts an optional SQLAlchemy ``AsyncSession`` for the DB-backed task
|
||||
creation. The pure draft/description helpers below need no session.
|
||||
"""
|
||||
|
||||
def __init__(self, db: AsyncSession | None = None) -> None:
|
||||
self.log = logger.bind(component="prompter_service")
|
||||
self._db = db
|
||||
|
||||
async def _create_message(
|
||||
self, *, messages: list[dict[str, str]], max_tokens: int
|
||||
) -> str:
|
||||
"""Call the local LLM and return the reply text.
|
||||
|
||||
Uses the project's local LLM — the same OpenAI-compatible Ollama
|
||||
endpoint as RAG/HyDE (``settings.local_llm_*``), so no external API key
|
||||
is required. ``messages`` is an OpenAI-style list (system + turns). This
|
||||
is the single seam the prompter tests substitute.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
resp = await client.post(
|
||||
f"{settings.local_llm_base_url}/chat/completions",
|
||||
json={
|
||||
"model": settings.local_llm_model,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"options": {"num_ctx": 8192},
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return str(data["choices"][0]["message"]["content"] or "").strip()
|
||||
|
||||
@property
|
||||
def _session(self) -> AsyncSession:
|
||||
"""Return DB session, raising if not configured."""
|
||||
@@ -230,180 +75,6 @@ class PrompterService:
|
||||
)
|
||||
return self._db
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Session-based interface
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
async def create_session(self, agent_id: UUID) -> PrompterSessionTable:
|
||||
"""Create a new Prompter conversation session."""
|
||||
session = PrompterSessionTable(
|
||||
id=uuid4(),
|
||||
agent_id=agent_id,
|
||||
status="active",
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
self._session.add(session)
|
||||
await self._session.flush()
|
||||
self.log.info("Prompter session created", session_id=str(session.id))
|
||||
return session
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
session_id: UUID,
|
||||
agent_id: UUID,
|
||||
content: str,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> TurnResult:
|
||||
"""
|
||||
Append a user message, call the LLM for a reply, persist both, and
|
||||
return all messages plus the readiness signal for this turn.
|
||||
"""
|
||||
session = await self._get_session(session_id, agent_id)
|
||||
|
||||
# Persist the user message first
|
||||
user_msg = PrompterMessageTable(
|
||||
id=uuid4(),
|
||||
session_id=session_id,
|
||||
role="user",
|
||||
content=content,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
self._session.add(user_msg)
|
||||
await self._session.flush()
|
||||
|
||||
# Load full conversation history for the LLM call
|
||||
history = await self._load_messages(session_id)
|
||||
chat_messages = [{"role": m.role, "content": m.content} for m in history]
|
||||
|
||||
# Ground the interview in the real projects/products the human can target
|
||||
live_context = await self._assemble_live_context()
|
||||
|
||||
# Call the LLM
|
||||
llm_reply = await self._llm_chat(
|
||||
messages=chat_messages,
|
||||
context=context,
|
||||
live_context=live_context,
|
||||
)
|
||||
|
||||
# Persist the assistant reply (control block already stripped)
|
||||
assistant_msg = PrompterMessageTable(
|
||||
id=uuid4(),
|
||||
session_id=session_id,
|
||||
role="assistant",
|
||||
content=llm_reply["message"],
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
self._session.add(assistant_msg)
|
||||
|
||||
# Update session status if draft is ready
|
||||
if llm_reply["draft_ready"] and session.status == "active":
|
||||
session.status = "draft_ready"
|
||||
|
||||
await self._session.flush()
|
||||
self.log.info(
|
||||
"Message processed",
|
||||
session_id=str(session_id),
|
||||
draft_ready=llm_reply["draft_ready"],
|
||||
)
|
||||
|
||||
return TurnResult(
|
||||
messages=await self._load_messages(session_id),
|
||||
draft_ready=bool(llm_reply["draft_ready"]),
|
||||
scale=llm_reply.get("scale"),
|
||||
)
|
||||
|
||||
async def get_or_generate_draft(
|
||||
self,
|
||||
session_id: UUID,
|
||||
agent_id: UUID,
|
||||
) -> TaskDraftTable:
|
||||
"""
|
||||
Return an existing draft for the session, or generate one via LLM
|
||||
if none exists yet.
|
||||
"""
|
||||
await self._get_session(session_id, agent_id)
|
||||
|
||||
# Check for an existing draft
|
||||
result = await self._session.execute(
|
||||
select(TaskDraftTable)
|
||||
.where(TaskDraftTable.session_id == session_id)
|
||||
.order_by(TaskDraftTable.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
# No draft yet — generate one from conversation history
|
||||
history = await self._load_messages(session_id)
|
||||
if not history:
|
||||
raise ValidationError(
|
||||
message=(
|
||||
"Cannot generate a draft from an empty conversation; "
|
||||
"send at least one message first."
|
||||
),
|
||||
field="messages",
|
||||
)
|
||||
|
||||
chat_messages = [{"role": m.role, "content": m.content} for m in history]
|
||||
draft_result = await self._llm_draft(
|
||||
messages=chat_messages,
|
||||
)
|
||||
|
||||
draft_record = TaskDraftTable(
|
||||
id=uuid4(),
|
||||
session_id=session_id,
|
||||
draft_data=draft_result["draft"],
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
self._session.add(draft_record)
|
||||
await self._session.flush()
|
||||
return draft_record
|
||||
|
||||
async def confirm_draft(
|
||||
self,
|
||||
session_id: UUID,
|
||||
agent_id: UUID,
|
||||
confirm_overrides: ConfirmOverrides | None = None,
|
||||
) -> UUID:
|
||||
"""
|
||||
Validate the draft and create a real Task via the TaskService.
|
||||
|
||||
Returns the newly created task's UUID.
|
||||
"""
|
||||
session_rec = await self._get_session(session_id, agent_id)
|
||||
ov = confirm_overrides or ConfirmOverrides()
|
||||
|
||||
# Get or generate the draft. A human-edited structured draft, if passed,
|
||||
# replaces the stored one before overrides and re-composition.
|
||||
draft_record = await self.get_or_generate_draft(session_id, agent_id)
|
||||
if ov.draft is not None:
|
||||
draft_data: dict[str, Any] = dict(ov.draft)
|
||||
draft_data["source"] = "prompter"
|
||||
draft_data["confirmed_by_human"] = False
|
||||
else:
|
||||
draft_data = dict(draft_record.draft_data)
|
||||
self._apply_overrides(draft_data, ov)
|
||||
|
||||
task = await self.create_task_from_draft(draft_data, agent_id)
|
||||
|
||||
# Persist the launched draft so the stored record reflects reality.
|
||||
draft_record.draft_data = draft_data
|
||||
|
||||
# Mark draft as confirmed
|
||||
now = datetime.now(UTC)
|
||||
draft_record.confirmed_at = now
|
||||
draft_record.task_id = task.id
|
||||
session_rec.status = "confirmed"
|
||||
await self._session.flush()
|
||||
|
||||
self.log.info(
|
||||
"Draft confirmed — task created",
|
||||
session_id=str(session_id),
|
||||
task_id=str(task.id),
|
||||
)
|
||||
return UUID(str(task.id))
|
||||
|
||||
async def _assignee_is_board(self, agent_id: UUID) -> bool:
|
||||
"""True if ``agent_id`` is a board/advisory role (PO / marketing / auditor)."""
|
||||
result = await self._session.execute(
|
||||
@@ -421,18 +92,16 @@ class PrompterService:
|
||||
) -> TaskTable:
|
||||
"""Create a Task from a structured draft.
|
||||
|
||||
Shared by both prompter confirm paths (``confirm_draft`` and the
|
||||
live-intake ``confirm_live_draft``): recomposes the description,
|
||||
validates exactly-one target, coerces enums, routes the owning team
|
||||
(product → Main PM, project → lead cell), and persists via
|
||||
``TaskService.create``. Mutates ``draft_data['description']`` in place.
|
||||
``confirmed_by_human=True`` — the CEO confirmed it.
|
||||
Recomposes the description, validates exactly-one target, coerces enums,
|
||||
routes the owning team (product → Main PM, project → lead cell), and
|
||||
persists via ``TaskService.create``. Mutates ``draft_data['description']``
|
||||
in place. ``confirmed_by_human=True`` — the CEO confirmed it.
|
||||
|
||||
``status`` defaults to ``BACKLOG`` (legacy ``confirm_draft`` behaviour).
|
||||
The live-intake buttons pass ``PENDING`` + an ``assigned_to`` (a board
|
||||
agent for "Board review & Start", main-pm for "Approve & Start") so the
|
||||
task starts immediately on the chosen review path. An explicit
|
||||
``assigned_to`` wins over any assignee carried on the draft.
|
||||
``status`` defaults to ``BACKLOG``. The live-intake buttons pass
|
||||
``PENDING`` + an ``assigned_to`` (a board agent for "Board review &
|
||||
Start", main-pm for "Approve & Start") so the task starts immediately on
|
||||
the chosen review path. An explicit ``assigned_to`` wins over any
|
||||
assignee carried on the draft.
|
||||
"""
|
||||
# Recompose the description from the (possibly edited) structured fields —
|
||||
# the task always carries a freshly-composed, consistent description.
|
||||
@@ -552,18 +221,6 @@ class PrompterService:
|
||||
)
|
||||
return UUID(str(task.id))
|
||||
|
||||
@staticmethod
|
||||
def _apply_overrides(draft_data: dict[str, Any], ov: ConfirmOverrides) -> None:
|
||||
"""Merge confirm-time overrides onto the draft data in place."""
|
||||
if ov.project_id is not None:
|
||||
draft_data["project_id"] = str(ov.project_id)
|
||||
if ov.product_id is not None:
|
||||
draft_data["product_id"] = str(ov.product_id)
|
||||
if ov.assigned_to is not None:
|
||||
draft_data["assigned_to"] = ov.assigned_to
|
||||
if ov.extra:
|
||||
draft_data.update(ov.extra)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_uuid_field(draft_data: dict[str, Any], key: str) -> UUID | None:
|
||||
"""Parse ``draft_data[key]`` as a UUID; None if absent, raises if malformed."""
|
||||
@@ -653,266 +310,16 @@ class PrompterService:
|
||||
return 2
|
||||
return 2
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Private helpers (session-based)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
async def _get_session(
|
||||
self, session_id: UUID, agent_id: UUID
|
||||
) -> PrompterSessionTable:
|
||||
"""Load and authorize a PrompterSession."""
|
||||
result = await self._session.execute(
|
||||
select(PrompterSessionTable).where(PrompterSessionTable.id == session_id)
|
||||
)
|
||||
rec = result.scalar_one_or_none()
|
||||
if rec is None:
|
||||
raise NotFoundError("Prompter session", str(session_id))
|
||||
if rec.agent_id != agent_id:
|
||||
raise ServiceError(
|
||||
f"Session {session_id} does not belong to agent {agent_id}"
|
||||
)
|
||||
return rec
|
||||
|
||||
async def _load_messages(self, session_id: UUID) -> list[PrompterMessageTable]:
|
||||
"""Return all messages for a session ordered by creation time."""
|
||||
result = await self._session.execute(
|
||||
select(PrompterMessageTable)
|
||||
.where(PrompterMessageTable.session_id == session_id)
|
||||
.order_by(PrompterMessageTable.created_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def _assemble_live_context(self) -> str | None:
|
||||
"""Build a compact 'Available projects / products' block for the interview.
|
||||
|
||||
Grounds the assistant in the real targets the human can launch against,
|
||||
so it references existing surfaces and can resolve project/product
|
||||
itself. Best-effort: a lookup failure degrades to no context rather than
|
||||
breaking the chat. Returns None when nothing is registered.
|
||||
"""
|
||||
if self._db is None:
|
||||
return None
|
||||
|
||||
from roboco.services.product import get_product_service
|
||||
from roboco.services.project import get_project_service
|
||||
|
||||
lines: list[str] = []
|
||||
try:
|
||||
projects = await get_project_service(self._session).list_all(
|
||||
active_only=True, limit=50
|
||||
)
|
||||
except Exception as exc:
|
||||
self.log.warning("Live project list unavailable", error=str(exc))
|
||||
projects = []
|
||||
if projects:
|
||||
lines.append("Available projects (single-cell tasks target one of these):")
|
||||
lines.extend(f" - {p.name} (slug: {p.slug}, id: {p.id})" for p in projects)
|
||||
|
||||
try:
|
||||
products = await get_product_service(self._session).list_all(limit=50)
|
||||
except Exception as exc:
|
||||
self.log.warning("Live product list unavailable", error=str(exc))
|
||||
products = []
|
||||
if products:
|
||||
lines.append(
|
||||
"Available products (board-led multi-cell features target one "
|
||||
"of these):"
|
||||
)
|
||||
lines.extend(
|
||||
f" - {pr.name} (slug: {pr.slug}, id: {pr.id})" for pr in products
|
||||
)
|
||||
|
||||
return "\n".join(lines) if lines else None
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Shared LLM helpers
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
async def _llm_chat(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
context: dict[str, Any] | None = None,
|
||||
max_tokens: int = 2048,
|
||||
live_context: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Call the LLM for a chat response.
|
||||
|
||||
Returns ``{message, draft_ready, scale}`` where ``message`` is the
|
||||
user-visible reply with the trailing roboco-meta control block stripped.
|
||||
"""
|
||||
user_prompt = _build_chat_prompt(messages, context, live_context)
|
||||
try:
|
||||
content = await self._create_message(
|
||||
messages=[
|
||||
{"role": "system", "content": _PROMPTER_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.error("Prompter chat LLM call failed", error=str(e))
|
||||
raise ServiceError(f"LLM chat failed: {e}") from e
|
||||
|
||||
if not content:
|
||||
raise ServiceError("LLM returned empty content")
|
||||
|
||||
clean, tag = parse_readiness(content)
|
||||
# If the model omitted the control block, fall back to the clean text
|
||||
# so the user still sees a reply rather than an empty bubble.
|
||||
message = clean or content
|
||||
return {
|
||||
"message": message,
|
||||
"draft_ready": bool(tag and tag.ready),
|
||||
"scale": tag.scale if tag else None,
|
||||
}
|
||||
|
||||
async def _llm_draft(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
context: dict[str, Any] | None = None,
|
||||
max_tokens: int = 4096,
|
||||
) -> dict[str, Any]:
|
||||
"""Call the LLM to generate a structured draft. Returns {draft, reasoning}."""
|
||||
user_prompt = _build_draft_prompt(messages, context)
|
||||
try:
|
||||
content = await self._create_message(
|
||||
messages=[
|
||||
{"role": "system", "content": _DRAFT_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.error("Prompter draft LLM call failed", error=str(e))
|
||||
raise ServiceError(f"LLM draft generation failed: {e}") from e
|
||||
|
||||
if not content:
|
||||
raise ServiceError("LLM returned empty content for draft")
|
||||
|
||||
try:
|
||||
draft_data = json.loads(_strip_code_fences(content))
|
||||
except json.JSONDecodeError as e:
|
||||
self.log.warning("Draft JSON parse failed", content_preview=content[:200])
|
||||
raise ValidationError(
|
||||
message=f"Draft response was not valid JSON: {e}",
|
||||
field="draft",
|
||||
) from e
|
||||
|
||||
draft_data["source"] = "prompter"
|
||||
draft_data["confirmed_by_human"] = False
|
||||
# Compose the markdown description from the structured fields — the model
|
||||
# never hand-formats it, so the description is always consistent.
|
||||
draft_data["description"] = compose_description(draft_data)
|
||||
return {
|
||||
"draft": draft_data,
|
||||
"reasoning": _build_reasoning(messages, draft_data),
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Legacy stateless interface
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
context: dict[str, Any] | None = None,
|
||||
max_tokens: int = 2048,
|
||||
) -> dict[str, Any]:
|
||||
"""Continue a Prompter conversation (stateless)."""
|
||||
return await self._llm_chat(
|
||||
messages=messages,
|
||||
context=context,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
|
||||
async def draft(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
context: dict[str, Any] | None = None,
|
||||
max_tokens: int = 4096,
|
||||
) -> dict[str, Any]:
|
||||
"""Generate a structured task draft from conversation context (stateless)."""
|
||||
return await self._llm_draft(
|
||||
messages=messages,
|
||||
context=context,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level helpers (pure functions, no state)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_chat_prompt(
|
||||
messages: list[dict[str, str]],
|
||||
context: dict[str, Any] | None,
|
||||
live_context: str | None = None,
|
||||
) -> str:
|
||||
lines: list[str] = []
|
||||
if live_context:
|
||||
lines.append(live_context)
|
||||
lines.append("")
|
||||
if context:
|
||||
lines.append("Context:")
|
||||
for key, value in context.items():
|
||||
lines.append(f" {key}: {value}")
|
||||
lines.append("")
|
||||
lines.append("Conversation:")
|
||||
for msg in messages:
|
||||
role = msg.get("role", "user")
|
||||
content = msg.get("content", "")
|
||||
lines.append(f"{role}: {content}")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Continue the conversation as the Prompter assistant. End with the "
|
||||
"roboco-meta control block. If you can write a complete task spec now, "
|
||||
"set ready to true."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_draft_prompt(
|
||||
messages: list[dict[str, str]],
|
||||
context: dict[str, Any] | None,
|
||||
) -> str:
|
||||
lines: list[str] = []
|
||||
lines.append(
|
||||
"Produce a JSON task draft from the following conversation. "
|
||||
"Return ONLY valid JSON — no markdown, no preamble."
|
||||
)
|
||||
if context:
|
||||
lines.append("")
|
||||
lines.append("Overrides:")
|
||||
for key, value in context.items():
|
||||
lines.append(f" {key}: {value}")
|
||||
lines.append("")
|
||||
lines.append("Conversation:")
|
||||
for msg in messages:
|
||||
role = msg.get("role", "user")
|
||||
content = msg.get("content", "")
|
||||
lines.append(f"{role}: {content}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _strip_code_fences(content: str) -> str:
|
||||
"""Strip a wrapping markdown code fence (```json ... ```) if present.
|
||||
|
||||
Local models often wrap JSON output in a fenced block; drop the opening
|
||||
fence line and the closing fence so the body parses cleanly as JSON.
|
||||
"""
|
||||
text = content.strip()
|
||||
if text.startswith("```"):
|
||||
text = text.split("\n", 1)[1] if "\n" in text else text[3:]
|
||||
if text.rstrip().endswith("```"):
|
||||
text = text.rstrip()[:-3]
|
||||
return text.strip()
|
||||
|
||||
|
||||
_META_FENCE_RE = re.compile(r"```roboco-meta\s*(.*?)```", re.DOTALL)
|
||||
|
||||
# Mirror of PrompterDraftTask.description min_length — below this the composed
|
||||
# body is too thin to be a valid task, so we fall back to any provided text.
|
||||
# Below this length the composed body is too thin to be a valid task, so we
|
||||
# fall back to any model-provided description text.
|
||||
_MIN_DESCRIPTION_LEN = 20
|
||||
|
||||
_TEAM_LABELS: dict[str, str] = {
|
||||
@@ -1050,20 +457,6 @@ def compose_description(draft: dict[str, Any]) -> str:
|
||||
return _text(draft.get("description")) or composed
|
||||
|
||||
|
||||
def _build_reasoning(
|
||||
messages: list[dict[str, str]],
|
||||
draft_data: dict[str, Any],
|
||||
) -> str:
|
||||
title = draft_data.get("title", "Untitled")
|
||||
team = draft_data.get("team", "unknown")
|
||||
complexity = draft_data.get("estimated_complexity", "unknown")
|
||||
return (
|
||||
f"Draft generated from conversation of {len(messages)} messages. "
|
||||
f"Proposed task '{title}' for team {team} "
|
||||
f"with complexity {complexity}."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factory
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1072,7 +465,7 @@ def _build_reasoning(
|
||||
def get_prompter_service(db: AsyncSession | None = None) -> PrompterService:
|
||||
"""Create a PrompterService instance.
|
||||
|
||||
Pass ``db`` for the session-based interface; omit for the stateless
|
||||
legacy interface.
|
||||
Pass ``db`` for the DB-backed task-creation interface; omit for the pure
|
||||
draft/description helpers.
|
||||
"""
|
||||
return PrompterService(db=db)
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
"""
|
||||
Usage Event Publisher
|
||||
|
||||
Throttled helpers for publishing USAGE_UPDATE and USAGE_SNAPSHOT events
|
||||
to the StreamEventBus. Consumed by the orchestrator token sweep and
|
||||
forwarded to /ws/system WebSocket clients via the websocket_bridge.
|
||||
Helper for publishing USAGE_SNAPSHOT aggregate events to the StreamEventBus.
|
||||
Consumed by the orchestrator token sweep and forwarded to /ws/system WebSocket
|
||||
clients via the websocket_bridge.
|
||||
|
||||
Throttle window: one USAGE_UPDATE publish per agent per 5-second window.
|
||||
Subsequent calls within the window are silently dropped so a frequent
|
||||
sweep loop cannot flood the event bus or WebSocket clients.
|
||||
|
||||
USAGE_SNAPSHOT is always published (no per-agent throttle) — it is an
|
||||
aggregate and published at most once per sweep cycle.
|
||||
USAGE_SNAPSHOT is an aggregate, published at most once per sweep cycle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -23,64 +17,6 @@ from typing import TYPE_CHECKING, Any
|
||||
if TYPE_CHECKING:
|
||||
from roboco.events.stream_bus import StreamEventBus
|
||||
|
||||
_THROTTLE_WINDOW_SECONDS: float = 5.0
|
||||
|
||||
|
||||
class _UsageThrottle:
|
||||
"""Per-agent last-publish timestamp tracker.
|
||||
|
||||
Uses ``time.monotonic()`` so clock adjustments (NTP, DST) don't cause
|
||||
spurious suppressions or double-fires.
|
||||
"""
|
||||
|
||||
def __init__(self, window: float = _THROTTLE_WINDOW_SECONDS) -> None:
|
||||
self._window = window
|
||||
self._last: dict[str, float] = {}
|
||||
|
||||
def should_publish(self, agent_id: str) -> bool:
|
||||
"""Return True if the agent is outside the throttle window.
|
||||
|
||||
Also records the current monotonic time as the new *last published*
|
||||
timestamp when it returns True, so the caller does not need to call a
|
||||
separate ``record()`` method.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
if now - self._last.get(agent_id, 0.0) >= self._window:
|
||||
self._last[agent_id] = now
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Module-level singleton — shared across all callers in this process.
|
||||
_throttle = _UsageThrottle()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UsageUpdate:
|
||||
"""Per-agent cumulative token counts carried by a USAGE_UPDATE event.
|
||||
|
||||
Fields map directly onto the event payload the panel consumes (the
|
||||
backend emits these per active agent during the token sweep).
|
||||
"""
|
||||
|
||||
agent_id: str
|
||||
task_id: str | None
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
model: str
|
||||
timestamp: datetime | None = None
|
||||
|
||||
def event_data(self) -> dict[str, Any]:
|
||||
"""Render the event payload, stamping ``timestamp`` if not supplied."""
|
||||
return {
|
||||
"agent_id": self.agent_id,
|
||||
"task_id": self.task_id,
|
||||
"input_tokens": self.input_tokens,
|
||||
"output_tokens": self.output_tokens,
|
||||
"model": self.model,
|
||||
"timestamp": (self.timestamp or datetime.now(UTC)).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UsageSnapshot:
|
||||
@@ -108,20 +44,6 @@ class UsageSnapshot:
|
||||
}
|
||||
|
||||
|
||||
async def publish_usage_update(bus: StreamEventBus, update: UsageUpdate) -> bool:
|
||||
"""Publish a USAGE_UPDATE event if the per-agent throttle window has elapsed.
|
||||
|
||||
Returns True if the event was published; False if suppressed by the throttle.
|
||||
"""
|
||||
if not _throttle.should_publish(update.agent_id):
|
||||
return False
|
||||
|
||||
from roboco.models.events import Event, EventType # lazy — avoids circular import
|
||||
|
||||
await bus.publish(Event(type=EventType.USAGE_UPDATE, data=update.event_data()))
|
||||
return True
|
||||
|
||||
|
||||
async def publish_usage_snapshot(bus: StreamEventBus, snapshot: UsageSnapshot) -> None:
|
||||
"""Publish a USAGE_SNAPSHOT aggregate event (no throttle)."""
|
||||
from roboco.models.events import Event, EventType # lazy — avoids circular import
|
||||
|
||||
Reference in New Issue
Block a user