diff --git a/backend/apps/agents/schedule_mcp_server.py b/backend/apps/agents/schedule_mcp_server.py
index fe74d732..bca169ef 100644
--- a/backend/apps/agents/schedule_mcp_server.py
+++ b/backend/apps/agents/schedule_mcp_server.py
@@ -546,7 +546,10 @@ def handle_delete_step(args: dict) -> dict:
# How long a synchronous test may hold the turn. Long enough for a real multi-step workflow, short
# enough that a wedged test returns an honest "still running" instead of hanging the conversation.
-TEST_WAIT_S = 240
+# A real workflow test does several searches and page fetches per step. The first budget was 240s
+# and a live three-step digest took 243, so it missed by three seconds and the agent had to make a
+# second hop anyway, which is the exact re-pinging this tool exists to remove.
+TEST_WAIT_S = 600
TEST_POLL_S = 3
@@ -574,10 +577,21 @@ def handle_test_workflow(args: dict) -> dict:
continue
transcript = t.get("transcript") or "(empty transcript)"
return _ok(f"Test finished (status: {last_status}). Transcript:\n\n{transcript}")
- return _ok(
- f"Test Agent (session {sid[:8]}) is still running after {TEST_WAIT_S}s, so it is a long one. "
- f"Last status: {last_status}. Call ReadTestTranscript to pick up the result."
+ # Hand back whatever the run has actually produced. Returning only "call ReadTestTranscript" made
+ # the model guess when to poll, which is the same dead end as not waiting at all; a partial
+ # transcript is something it can reason about right now.
+ partial = ""
+ try:
+ t = _call("GET", f"/{wid}/test-transcript")
+ if "_error" not in t:
+ partial = (t.get("transcript") or "").strip()
+ except Exception:
+ partial = ""
+ head = (
+ f"Test Agent (session {sid[:8]}) has not finished after {TEST_WAIT_S}s (status: {last_status}). "
+ "Everything it has produced so far follows; call ReadTestTranscript for the rest once it lands."
)
+ return _ok(f"{head}\n\n{partial}" if partial else head)
def handle_read_test_transcript(args: dict) -> dict:
diff --git a/backend/apps/memory/distill.py b/backend/apps/memory/distill.py
index e4f46e2f..4cb45db6 100644
--- a/backend/apps/memory/distill.py
+++ b/backend/apps/memory/distill.py
@@ -11,6 +11,7 @@ from backend.apps.agents.core.aux_llm import aux_max_tokens_for
from backend.apps.agents.core.models import AgentSession
from backend.apps.agents.manager.predict_followups import conversation_tail
from backend.apps.agents.manager.session.history_compaction import get_branch_messages
+from backend.apps.memory import store
from backend.apps.memory.store import add_fact
logger = logging.getLogger(__name__)
@@ -65,7 +66,17 @@ async def distill_session_memory(session: AgentSession) -> List[str]:
f"Return at most {MAX_FACTS_PER_DISTILL} facts, one per line, no numbering, no quotes. "
"If the conversation reveals nothing durable, return the single word NOTHING."
)
- user_turn = "Conversation:\n\n" + tail + "\n\n\nExtract the facts."
+ # Show it what is already known. Without this the model re-derives facts it recorded weeks
+ # ago, phrased differently every time, and the store's token-overlap guard cannot catch a
+ # paraphrase: the four duplicate pairs found in a real memory scored 0.11 to 0.33 against a
+ # 0.60 threshold. The generator is semantic, so the dedupe has to be too.
+ known = "\n".join(f"- {f.text}" for f in store.list_facts())
+ already = (
+ "Facts already stored (do NOT repeat these, in any wording):\n" + known + "\n\n"
+ "Return a fact ONLY if it is genuinely new, or is strictly more specific than one above "
+ "(in which case return the sharper version and it will replace the old one).\n\n"
+ ) if known else ""
+ user_turn = already + "Conversation:\n\n" + tail + "\n\n\nExtract the facts."
chunks: List[str] = []
async with client.messages.stream(
model=aux_model,