Skip to content

Commit c65771f

Browse files
authored
Create runs.py
1 parent bd69575 commit c65771f

1 file changed

Lines changed: 97 additions & 0 deletions

File tree

‎app/controllers/runs.py‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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

Comments
 (0)