|
| 1 | +"""Run business logic: what the agent is told, and what a caller may do. |
| 2 | +
|
| 3 | +``manager.Controller`` owns execution (threads, sandboxes, event |
| 4 | +fan-out). This module sits in front of it with the per-request rules: |
| 5 | +augmenting the prompt with workspace context, checking that a run |
| 6 | +belongs to the conversation, and turning a refused cancel/answer into a |
| 7 | +domain error. |
| 8 | +""" |
| 9 | + |
| 10 | +from __future__ import annotations |
| 11 | + |
| 12 | +from typing import Any |
| 13 | + |
| 14 | +from sqlalchemy.orm import Session |
| 15 | + |
| 16 | +from ..models import Conversation, ConversationFile, Run |
| 17 | +from .conversations import files_of |
| 18 | +from .errors import Conflict, NotFound |
| 19 | +from .manager import Controller |
| 20 | + |
| 21 | +# Told to the agent, not to the user: the sandbox cwd is the only place |
| 22 | +# a produced file can be picked up from, and the model has no other way |
| 23 | +# to learn that. |
| 24 | +_SAVE_HINT = ( |
| 25 | + "\n\n[Save any result files (e.g. output Excel) into your working " |
| 26 | + "directory with a clear name; the user downloads them from there.]" |
| 27 | +) |
| 28 | + |
| 29 | + |
| 30 | +def build_harness_prompt(prompt: str, files: list[ConversationFile]) -> str: |
| 31 | + """The prompt the agent receives, given the conversation's uploads. |
| 32 | +
|
| 33 | + The Run row keeps the user's verbatim prompt (the UI echoes it); |
| 34 | + this is the augmented copy. Uploads are named by ``stored_name``, |
| 35 | + not ``filename``: the on-disk name carries a unique prefix, and the |
| 36 | + agent must open the file that actually exists. |
| 37 | + """ |
| 38 | + augmented = prompt |
| 39 | + if files: |
| 40 | + names = ", ".join(f.stored_name for f in files) |
| 41 | + augmented += f"\n\n[Uploaded files in your working directory: {names}]" |
| 42 | + return augmented + _SAVE_HINT |
| 43 | + |
| 44 | + |
| 45 | +def start( |
| 46 | + controller: Controller, |
| 47 | + db: Session, |
| 48 | + *, |
| 49 | + user_id: str, |
| 50 | + conversation: Conversation, |
| 51 | + prompt: str, |
| 52 | +) -> Run: |
| 53 | + """Begin a run for the conversation's next turn.""" |
| 54 | + harness_prompt = build_harness_prompt(prompt, files_of(db, conversation)) |
| 55 | + try: |
| 56 | + return controller.start_run( |
| 57 | + db, |
| 58 | + user_id=user_id, |
| 59 | + conversation_id=conversation.id, |
| 60 | + prompt=prompt, |
| 61 | + harness_prompt=harness_prompt, |
| 62 | + ) |
| 63 | + except RuntimeError as exc: |
| 64 | + # one run at a time per conversation (enforced by the manager) |
| 65 | + raise Conflict(str(exc)) from exc |
| 66 | + |
| 67 | + |
| 68 | +def of_conversation(db: Session, conversation: Conversation, run_id: str) -> Run: |
| 69 | + """A run belonging to this conversation, or NotFound.""" |
| 70 | + run = db.get(Run, run_id) |
| 71 | + if run is None or run.conversation_id != conversation.id: |
| 72 | + raise NotFound("run not found") |
| 73 | + return run |
| 74 | + |
| 75 | + |
| 76 | +def cancel(controller: Controller, run_id: str) -> bool: |
| 77 | + """Request cancellation; False when the run is no longer live.""" |
| 78 | + return controller.cancel_run(run_id) |
| 79 | + |
| 80 | + |
| 81 | +def answer(controller: Controller, run_id: str, answers: list[str]) -> None: |
| 82 | + """Forward a reply to a pending mid-run question. |
| 83 | +
|
| 84 | + The harness's ask event (notify kind ``ask``) carries the questions; |
| 85 | + the reply goes to the blocked agent verbatim. |
| 86 | + """ |
| 87 | + if not controller.deliver_answer(run_id, answers): |
| 88 | + raise Conflict("run is not accepting answers (finished, or runner lacks mid-run Q&A)") |
| 89 | + |
| 90 | + |
| 91 | +def replay_payloads(run: Run) -> list[dict[str, Any]]: |
| 92 | + """Stored transcript of a finished run, terminal event included. |
| 93 | +
|
| 94 | + A late subscriber gets the same event sequence a live one saw, so |
| 95 | + the browser renders a finished run exactly like it watched it. |
| 96 | + """ |
| 97 | + return [*(run.events or []), {"type": "run", "state": run.status}] |
0 commit comments