Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions flow/api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,25 @@ def start_run(
session: str | None = None,
model: str | None = None,
attachments: list[str] | str | None = None,
page_context: dict[str, Any] | str | None = None,
stream: bool | str = False,
) -> dict[str, Any] | Response:
"""Start a new turn. Creates a session if none is given. `attachments` are uploaded File
names whose text is injected into this turn. With `stream=True`, returns SSE."""
"""Start a new turn. Creates a session if none is given. Attachments and an optional,
permission-checked Desk page snapshot are injected into this turn. With `stream=True`, returns SSE."""
if not isinstance(input, str) or not input.strip():
frappe.throw(_("Input is required."), title=_("Invalid Input"))

from flow.lib.page_context import build_page_context
from flow.lib.session import load_session, new_session

if isinstance(page_context, str):
page_context = frappe.parse_json(page_context)

context = build_page_context(page_context)
stream = _is_truthy(stream)
files = _parse_attachments(attachments)
convo = load_session(session, agent=agent, model=model) if session else new_session(agent, model=model)
out = convo.chat(input, attachments=files, stream=stream)
out = convo.chat(input, attachments=files, page_context=context, stream=stream)
return _sse_response(out) if stream else _summarize(out)


Expand All @@ -58,7 +64,7 @@ def resume_run(
title=_("Cannot Resume"),
)

out = load_session(run.session).resume(parsed_answers, stream=stream)
out = load_session(run.session).resume(parsed_answers, run_name=run.name, stream=stream)
return _sse_response(out) if stream else _summarize(out)


Expand Down
10 changes: 9 additions & 1 deletion flow/flow/doctype/flow_agent_memory/test_flow_agent_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from flow.memory import store
from flow.memory.memory import build_memory_block, save_memory
from flow.tools.builtins import sync_builtin_tools
from flow.tools.builtins import bind_update_memory, sync_builtin_tools

EXTRA_TEST_RECORD_DEPENDENCIES = []
IGNORE_TEST_RECORD_DEPENDENCIES = []
Expand Down Expand Up @@ -54,6 +54,14 @@ def tearDown(self):
frappe.db.rollback()
store.drop_table()

def test_update_memory_requires_confirmation(self):
memory_tool = bind_update_memory(self.agent.name)
self.assertTrue(memory_tool.requires_confirmation)
self.assertIn(
"Widget mapping",
memory_tool.confirm_prompt({"scope": "agent", "content": "Widget mapping"}),
)

def test_agent_scope_clears_user(self):
doc = frappe.get_doc(_memory(self.agent.name, user="Administrator")).insert()
self.assertIsNone(doc.user)
Expand Down
11 changes: 10 additions & 1 deletion flow/flow/doctype/flow_run/flow_run.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"questions",
"usage",
"config_snapshot",
"page_context",
"section_break_error",
"error",
"section_break_feedback",
Expand Down Expand Up @@ -157,6 +158,14 @@
"label": "Config Snapshot",
"read_only": 1
},
{
"description": "Permission-checked Desk page snapshot attached to this run.",
"fieldname": "page_context",
"fieldtype": "JSON",
"hidden": 1,
"label": "Page Context",
"read_only": 1
},
{
"collapsible": 1,
"depends_on": "eval:doc.status === 'Failed'",
Expand Down Expand Up @@ -195,7 +204,7 @@
],
"index_web_pages_for_search": 0,
"links": [],
"modified": "2026-05-23 00:00:00",
"modified": "2026-08-16 00:00:00",
"modified_by": "Administrator",
"module": "Flow",
"name": "Flow Run",
Expand Down
9 changes: 7 additions & 2 deletions flow/flow/doctype/flow_run/flow_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
if TYPE_CHECKING:
from flow.lib.agent import Event, RunResult

JSON_FIELDS = ("tool_calls", "questions", "usage", "config_snapshot")
JSON_FIELDS = ("tool_calls", "questions", "usage", "config_snapshot", "page_context")


@dataclass
Expand Down Expand Up @@ -49,6 +49,7 @@ class FlowRun(Document):
input: DF.LongText | None
iterations: DF.Int
output: DF.LongText | None
page_context: DF.JSON | None
questions: DF.JSON | None
reference_doctype: DF.Link | None
reference_name: DF.DynamicLink | None
Expand Down Expand Up @@ -125,6 +126,7 @@ def create_run(
reference_doctype: str | None = None,
reference_name: str | None = None,
config_snapshot: dict[str, Any] | None = None,
page_context: dict[str, Any] | None = None,
) -> FlowRun:
"""Create a new Flow Run row in the Running state. `session` is required — every run
belongs to a Flow Session (which carries the transcript and agent linkage)."""
Expand All @@ -138,6 +140,7 @@ def create_run(
"reference_name": reference_name,
"session": session,
"config_snapshot": _dump_json(config_snapshot) if config_snapshot else None,
"page_context": _dump_json(page_context) if page_context else None,
"status": "Running",
}
).insert(ignore_permissions=True)
Expand All @@ -154,6 +157,7 @@ def persist_result(
reference_doctype: str | None = None,
reference_name: str | None = None,
config_snapshot: dict[str, Any] | None = None,
page_context: dict[str, Any] | None = None,
) -> FlowRun:
"""Convenience: create a row and immediately apply a finished RunResult."""
doc = create_run(
Expand All @@ -164,6 +168,7 @@ def persist_result(
reference_doctype=reference_doctype,
reference_name=reference_name,
config_snapshot=config_snapshot,
page_context=page_context,
)
doc.apply_result(result)
return doc
Expand Down Expand Up @@ -232,7 +237,7 @@ def _new_messages_for_session(session: str, full_transcript: list[dict[str, Any]
def _dump_json(value: Any) -> str | None:
if value is None:
return None
return json.dumps(value, default=str)
return json.dumps(value, default=str, separators=(",", ":"))


def _merge_usage(existing: str | None, new: dict[str, int]) -> dict[str, int]:
Expand Down
23 changes: 23 additions & 0 deletions flow/flow/doctype/flow_run/test_flow_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from flow.flow.doctype.flow_run.flow_run import create_run, persist_result
from flow.lib.agent import Question, RunResult
from flow.lib.model import ToolCall
from flow.lib.page_context import PAGE_CONTEXT_MAX_CONTENT_CHARS, build_page_context


def _completed_result(output: str = "all done") -> RunResult:
Expand Down Expand Up @@ -197,6 +198,28 @@ def test_create_run_with_config_snapshot(self):
self.assertEqual(json.loads(doc.config_snapshot), snapshot)
self.assertEqual(doc.status, "Running")

def test_create_run_persists_page_context(self):
session = _new_session(self.agent)
page_context = {"type": "route", "route": ["query-report", "Sales Analytics"]}

doc = create_run(source="Manual", input="hi", session=session, page_context=page_context)

self.assertEqual(json.loads(doc.page_context), page_context)

def test_persisted_built_page_context_respects_total_cap(self):
session = _new_session(self.agent)
page_context = build_page_context(
{
"type": "route",
"route": ["query-report", "Sales Analytics"],
"page_text": "x" * (PAGE_CONTEXT_MAX_CONTENT_CHARS + 100),
}
)

doc = create_run(source="Manual", input="hi", session=session, page_context=page_context)

self.assertLessEqual(len(doc.page_context), PAGE_CONTEXT_MAX_CONTENT_CHARS)

def test_mark_failed_sets_status_and_error(self):
session = _new_session(self.agent)
doc = create_run(source="Manual", input="hi", session=session)
Expand Down
86 changes: 73 additions & 13 deletions flow/flow/doctype/flow_session/flow_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from frappe import _
from frappe.model.document import Document

from flow.lib.page_context import format_page_context, parse_page_context, revalidate_page_context

if TYPE_CHECKING:
from collections.abc import Generator

Expand Down Expand Up @@ -131,15 +133,16 @@ def chat(
input: str,
*,
attachments: list[str] | None = None,
page_context: dict[str, Any] | None = None,
source: str = "Manual",
trigger: str | None = None,
reference_doctype: str | None = None,
reference_name: str | None = None,
auto_approve: bool = False,
stream: bool = False,
) -> FlowRun | Generator[Event]:
"""Run one turn and persist it as a Flow Run. `attachments` are File names whose text
is injected into this turn's prompt. With `stream=True`, returns an event generator.
"""Run one turn and persist it as a Flow Run. Attachments and the optional page snapshot
are injected ephemerally into this turn's prompt. With `stream=True`, returns an event generator.

Commits the current transaction before the model call (to release row locks). Do not
call with pending writes you may want to roll back on failure; commit-and-compensate
Expand All @@ -148,6 +151,7 @@ def chat(

self.reload()
self._assert_not_blocked()

attachment_data = self._load_attachments(attachments)
if not self.title:
self.db_set("title", derive_title(input))
Expand All @@ -160,11 +164,11 @@ def chat(
reference_doctype=reference_doctype,
reference_name=reference_name,
config_snapshot=self._snapshot,
page_context=page_context,
)
self._persist_turn(input, attachment_data, run.name)
self._index_retrieval_attachments(run.name, {d["file"]: d["extracted_text"] for d in attachment_data})
run_input = self._build_prompt_messages()

run_input = self._build_prompt_messages(page_context=parse_page_context(run.page_context))
# Release row locks and publish the Running run before the long model call, so a
# concurrent turn doesn't block on the Flow Session row until lock timeout (1205).
if not frappe.flags.in_test:
Expand Down Expand Up @@ -283,11 +287,17 @@ def _index_retrieval_attachments(self, run: str, texts: dict[str, str] | None =
frappe.log_error(title="Chat attachment indexing failed")
row.db_set("mode", "Inline")

def resume(self, answers: dict[str, Any], *, stream: bool = False) -> FlowRun | Generator[Event]:
"""Resume this session's paused run with the user's answers."""
def resume(
self,
answers: dict[str, Any],
*,
run_name: str | None = None,
stream: bool = False,
) -> FlowRun | Generator[Event]:
"""Resume a paused run with the page snapshot captured for that exact turn."""
from flow.flow.doctype.flow_run.flow_run import stream_with_persistence

run_name = frappe.db.get_value(
run_name = run_name or frappe.db.get_value(
"Flow Run",
{"session": self.name, "status": "Paused"},
"name",
Expand All @@ -296,9 +306,12 @@ def resume(self, answers: dict[str, Any], *, stream: bool = False) -> FlowRun |
if not run_name:
frappe.throw(_("This session has no paused run to resume."), title=_("Nothing to Resume"))
run = frappe.get_doc("Flow Run", run_name)
if run.session != self.name or run.status != "Paused":
frappe.throw(_("This run cannot be resumed from this session."), title=_("Cannot Resume"))

self.reload()
messages = self._build_prompt_messages()
page_context = revalidate_page_context(parse_page_context(run.page_context))
messages = self._build_prompt_messages(page_context=page_context)
if not messages:
frappe.throw(_("This session has no transcript to resume from."))

Expand All @@ -316,14 +329,18 @@ def resume(self, answers: dict[str, Any], *, stream: bool = False) -> FlowRun |
run.apply_result(result)
return run

def _build_prompt_messages(self) -> list[dict[str, Any]]:
def _build_prompt_messages(
self,
page_context: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""Transcript as sent to the model. Augmentation is ephemeral — stored messages stay
clean (file text lives only in the attachments child table):

- Inline files: full text re-injected on their turn, clamped to the remaining budget.
- Retrieval files: a short note marks where each was attached; for the latest user turn
the most relevant chunks (by that turn's query) are injected in place of the full text.
- Agent memory: the agent's saved memories are appended to the system message.
- Page context: the current turn's bounded snapshot is appended to the latest user message.
"""
from flow.knowledge.retriever import retrieve_attachments
from flow.memory.memory import build_memory_block
Expand Down Expand Up @@ -353,10 +370,21 @@ def _build_prompt_messages(self) -> list[dict[str, Any]]:

memory_block = build_memory_block(self.agent, query=self._latest_user_content())
if memory_block:
if messages and messages[0]["role"] == "system":
messages[0]["content"] = f"{messages[0]['content']}\n\n{memory_block}"
else:
messages.insert(0, {"role": "system", "content": memory_block})
_append_system_context(messages, memory_block)
budget = max(0, budget - len(memory_block))

if page_context:
context_block = format_page_context(page_context)
if context_block:
context_budget = max(0, budget - 2) # separator appended to the user message
context_block, truncated = _clamp(context_block, context_budget)
if context_block and truncated:
marker = _("\n[Page context truncated to fit the model context window.]")
if context_budget > len(marker):
context_block, _was_truncated = _clamp(context_block, context_budget - len(marker))
context_block += marker
if context_block:
_append_user_context(messages, context_block)
return messages

def _latest_user_run(self) -> str | None:
Expand Down Expand Up @@ -507,3 +535,35 @@ def derive_title(text: str) -> str:
if len(cleaned) <= TITLE_MAX_LENGTH:
return cleaned
return cleaned[: TITLE_MAX_LENGTH - 1].rstrip() + "…"


def _append_system_context(
messages: list[dict[str, Any]],
content: str,
) -> None:
if not content:
return

if messages and messages[0].get("role") == "system":
existing = messages[0].get("content") or ""
messages[0]["content"] = f"{existing}\n\n{content}".strip()
return

messages.insert(
0,
{
"role": "system",
"content": content,
},
)


def _append_user_context(messages: list[dict[str, Any]], content: str) -> None:
if not content:
return
for message in reversed(messages):
if message.get("role") == "user":
existing = message.get("content") or ""
message["content"] = f"{existing}\n\n{content}".strip()
return
messages.append({"role": "user", "content": content})
Loading