Spaces:
Runtime error
Runtime error
File size: 34,451 Bytes
94b5741 1e09b5f 94b5741 528cb2d 94b5741 b74c6ae 94b5741 1e09b5f b74c6ae 528cb2d 94b5741 528cb2d 94b5741 b74c6ae 94b5741 1e09b5f 6e43a65 1e09b5f b74c6ae 1e09b5f b74c6ae 1e09b5f b74c6ae 1e09b5f b74c6ae 1e09b5f b74c6ae 1e09b5f b74c6ae 1e09b5f b74c6ae 1e09b5f b74c6ae 1e09b5f 528cb2d b74c6ae 528cb2d b74c6ae 528cb2d b74c6ae 528cb2d b74c6ae 528cb2d b74c6ae 528cb2d b74c6ae 528cb2d b74c6ae 528cb2d b74c6ae 528cb2d b74c6ae 528cb2d b74c6ae 528cb2d b74c6ae 528cb2d b74c6ae 528cb2d b74c6ae 528cb2d 1e09b5f b74c6ae 1e09b5f b74c6ae 1e09b5f b74c6ae 1e09b5f b74c6ae 1e09b5f b74c6ae 1e09b5f 94b5741 b74c6ae 94b5741 b74c6ae 94b5741 f74c934 b74c6ae 94b5741 1e09b5f b74c6ae 94b5741 1e09b5f b74c6ae 94b5741 b74c6ae 1e09b5f b74c6ae 1e09b5f b74c6ae 1e09b5f 528cb2d b74c6ae 528cb2d b74c6ae 528cb2d b74c6ae 1e09b5f b74c6ae 94b5741 b74c6ae 94b5741 b74c6ae 1e09b5f b74c6ae 94b5741 b74c6ae 94b5741 b74c6ae 94b5741 b74c6ae 94b5741 1e09b5f 94b5741 1e09b5f b74c6ae 94b5741 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 | """Agentic research over the file system corpus: the model explores files with list/search/read,
then every citation is checked against the real file before it reaches the writer.
Cost control: every message is triaged first. Greetings and thanks are answered from a phrase list
(no model call). Everything else gets one cheap call on the fast model that reads the recent
conversation, answers off topic and app questions directly, and turns follow ups into standalone
questions. Only real archive questions run the research agent, with a small step budget for simple
lookups and the full budget for synthesis."""
import logging
import random
import re
from collections.abc import Callable
from dataclasses import dataclass, field
import dspy
from dspy.utils.exceptions import AdapterParseError
from app.chat_memory.context import (
JEV_MEMORY,
JEV_MEMORY_QUESTION,
MEMORY_LEVELS,
ChatMemory,
build_context,
context_banner,
memory_decision,
topic_question,
topics_digest,
)
from app.chat_memory.files import SMALL_TALK
from app.chat_memory.logs import context_log, text_enabled
from app.chat_memory.tools import ChatTools, is_chat_path
from app.config import settings
from app.corpus import Corpus, ScopedTools
from app.llm.postprocess import strip_em_dashes
from app.llm.provider import main_lm, track_usage, triage_lm
from app.llm.signatures import ResearchArchive, ResearchArchiveWithMemory, TriageMessage, TriageWithMemory
from app.pipeline.trace import MEMORY_GIVES, ChatTrace, current_trace, probs_text
from app.services import jev
_MARKER = re.compile(r"\[(\d+)\]")
log = logging.getLogger(__name__)
@dataclass
class VerifiedCitation:
marker: int
path: str
line_start: int
line_end: int
quote: str
def _route_info(route: "Triage") -> dict:
"""What triage decided, in plain values: the first thing to look at when an answer forgot or mixed up a chat."""
return {"kind": route.kind, "depth": route.depth, "topic": route.topic, "memory": route.memory,
"standalone_question": route.question}
@dataclass
class ResearchResult:
text: str
unsupported: bool
citations: list[VerifiedCitation]
prompt_tokens: int = 0
completion_tokens: int = 0
steps: list[tuple[str, str]] = field(default_factory=list)
kind: str = "archive" # archive | chitchat | about_app | off_topic
question: str = "" # the standalone question that was researched
recall: dict = field(default_factory=dict) # how recall behaved for this answer (saved with the message)
@dataclass
class Turn:
role: str # user | assistant
text: str
HISTORY_TURNS = 8
HISTORY_CHARS = 700 # per older message: half from the start, half from the end
RECENT_TURNS = 2 # the newest messages (the last question and answer) are kept whole, up to RECENT_CHARS
RECENT_CHARS = 4000 # safety ceiling for those, the same as the longest question a writer can send
QUICK_STEPS = 4
MAX_AGENT_TOOLS = 6 # seen live: with a seventh tool the model returns {} instead of its next step
_CHITCHAT = SMALL_TALK # greetings and thanks: the same pattern chat memory uses to decide what may be skipped
_GREETING = re.compile(r"^\s*(hi|hey|hello|yo|hiya|good (morning|afternoon|evening))", re.IGNORECASE)
_BYE = re.compile(r"^\s*(bye|goodbye|see you)", re.IGNORECASE)
def shorten(text: str, limit: int = HISTORY_CHARS) -> str:
"""A long message keeps its start and its end and drops the middle: the opening usually states the point
and the ending holds the conclusion or the question that follow ups refer to."""
if len(text) <= limit:
return text
half = limit // 2
head, tail = text[:half], text[-half:]
head = head.rsplit(" ", 1)[0] if " " in head else head # cut on word boundaries
tail = tail.split(" ", 1)[1] if " " in tail else tail
return f"{head.rstrip()} ... {tail.lstrip()}"
def conversation_text(history: list[Turn], limit: int = HISTORY_TURNS) -> str:
"""Last few turns, citation markers stripped, so the prompt stays small. The newest two messages stay whole
because a follow up like "make it shorter" only makes sense against their exact words; older ones are shortened."""
if not history:
return "(new conversation)"
lines = []
recent_from = len(history) - RECENT_TURNS
for i, t in enumerate(history):
if i < len(history) - limit:
continue
text = _MARKER.sub("", t.text).strip()
text = shorten(text, RECENT_CHARS if i >= recent_from else HISTORY_CHARS)
lines.append(f"{'Writer' if t.role == 'user' else 'Assistant'}: {text}")
return "\n".join(lines)
def instant_reply(message: str, history: list[Turn]) -> str | None:
"""Canned replies for pure small talk: no model call at all."""
if not _CHITCHAT.match(message):
return None
if _BYE.match(message):
return "Clear skies. Your archive will be here when you come back."
if _GREETING.match(message):
return ("Hi! Ask me anything about the posts in this notebook, for example what you have written "
"about pricing, or how your view on something changed over time.")
if history:
return "Glad that helped. Want to dig further into any of those posts?"
return "Ask me anything about the posts in this notebook and I will answer with citations."
@dataclass
class Triage:
kind: str
question: str
depth: str
reply: str
prompt_tokens: int = 0
completion_tokens: int = 0
topic: str = "" # the slug of the chat topic the message belongs to, "" when none
memory: str = "none" # none | lookup | replay | compose
# For the log and for recall_json: who decided, what Jev answered before our thresholds, and what they changed.
engine: str = ""
jev: dict = field(default_factory=dict) # question id -> {"choice", "confidence", "probabilities"}
notes: list[str] = field(default_factory=list)
JEV_INTENT = {
"archive": {
"what": "Needs facts, ideas, quotes, opinions or history from the writer's own posts, including follow "
"ups to the conversation like 'say more', 'why?', 'what about last year?'",
"examples": ["What have I written about pricing?", "Summarize my take on AI", "Tell me more about that"],
},
"chitchat": {
"what": "Greetings, thanks, reactions or small talk with no question about the posts",
"examples": ["hello there", "that was helpful, thanks", "you are great"],
},
"about_app": {
"what": "How to use Notestack itself: sources, notebooks, audio, video, launch kits, settings",
"examples": ["How do I add another Substack?", "Can you make a podcast from this?"],
},
"off_topic": {
"what": "General knowledge, coding, math, news, homework or tasks unrelated to the writer's posts",
"not_for": "Questions about what the writer wrote, even if the subject is general",
"examples": ["What is the capital of France?", "Write me a Python script", "Who won the game last night?"],
},
}
JEV_DEPTH = {
"quick": "A single fact, quote or lookup in one or two posts",
"deep": "Synthesis, comparison or change over time across many posts",
}
JEV_MIN_CONFIDENCE = 0.55 # below this, research anyway: a wasted search beats a wrong brush off
def canned_reply(kind: str, allowed: dict[str, str]) -> str:
"""Templated replies for messages that need no research (no model call)."""
titles = [t for t in allowed.values() if t]
sample = random.choice(titles) if titles else None
try_this = f' For example: "What is the main argument in {sample}?"' if sample else ""
if kind == "about_app":
return ("Add more Substacks, blogs or files on the Sources page, then group posts into notebooks. From "
"any notebook, the Studio panel on the right makes summaries, audio overviews, videos and quote "
"cards, and Launch Kit and Launchpad turn a post into scheduled social posts. In this chat I "
"answer questions about the posts in this notebook." + try_this)
if kind == "off_topic":
return ("I only answer from the posts in this notebook, so I cannot help with that one. Ask me about "
"something you have written instead." + try_this)
return "Happy to help. Ask me anything about the posts in this notebook." + try_this
def triage_with_jev(message: str, history: list[Turn], notebook: str, mem: ChatMemory | None = None) -> Triage:
state = {"notebook": notebook, "conversation": conversation_text(history, limit=4), "message": message}
questions = {
"intent": {"instructions": "What does the latest message need from a research assistant that only "
"knows this writer's posts?", "criteria": JEV_INTENT},
"depth": {"instructions": "If it is about the posts, how much research does it need?",
"criteria": JEV_DEPTH},
}
if mem is not None: # two more choices in the same request
state["topics"] = topics_digest(mem.tf)
questions["memory"] = {"instructions": JEV_MEMORY_QUESTION, "criteria": JEV_MEMORY}
topic_q = topic_question(mem.tf)
if topic_q:
questions["topic"] = topic_q
answers = jev.decide(state, questions)
intent, depth = answers["intent"], answers["depth"]
raw = {qid: {"choice": a.choice, "confidence": round(a.confidence, 2),
"probabilities": {k: round(v, 2) for k, v in a.probabilities.items()}} for qid, a in answers.items()}
notes: list[str] = []
kind = intent.choice if intent.choice in JEV_INTENT else "archive"
if kind != intent.choice:
notes.append(f"intent {intent.choice!r} is not one of our options -> archive")
if kind != "archive" and intent.confidence < JEV_MIN_CONFIDENCE:
notes.append(f"intent {kind!r} confidence {intent.confidence:.2f} < {JEV_MIN_CONFIDENCE} -> archive "
"(a wasted search beats a wrong brush off)")
kind = "archive"
topic, memory = "", "none"
if kind == "archive" and mem is not None:
asked = answers["memory"]
memory = asked.choice if asked.choice in MEMORY_LEVELS else "none"
if memory != asked.choice:
notes.append(f"memory {asked.choice!r} is not one of our options -> none")
if asked.confidence < JEV_MIN_CONFIDENCE:
memory = "lookup" if mem.latest() else "none" # a wasted lookup costs less than a wrong answer
notes.append(f"memory {asked.choice!r} confidence {asked.confidence:.2f} < {JEV_MIN_CONFIDENCE} "
f"-> {memory}")
picked = answers.get("topic")
if picked and picked.confidence >= JEV_MIN_CONFIDENCE and picked.choice in mem.tf.slugs:
topic = picked.choice
elif picked and picked.choice != "none":
why = ("unknown topic" if picked.choice not in mem.tf.slugs
else f"confidence {picked.confidence:.2f} < {JEV_MIN_CONFIDENCE}")
notes.append(f"topic {picked.choice!r}: {why} -> no topic (the chat's latest topic is used instead)")
elif mem is not None:
notes.append(f"kind is {kind}, so the memory and topic answers are not used")
# Jev does not write text: the research agent resolves follow ups using the conversation it is given.
return Triage(kind, message, depth.choice if depth.choice in JEV_DEPTH else "deep", "", topic=topic, memory=memory,
engine="jev", jev=raw, notes=notes)
def triage(message: str, history: list[Turn], notebook: str, mem: ChatMemory | None = None) -> Triage:
"""Decide whether a message is worth researching. Jev when configured (fast, typed, no generation),
otherwise one cheap call on the triage LLM."""
why = "Jev is not configured (no TYPESAFE_API_KEY)"
if jev.configured():
try:
return triage_with_jev(message, history, notebook, mem)
except jev.JevError as exc:
log.warning("Jev triage failed, falling back to the LLM", exc_info=True)
why = f"Jev failed: {exc}"
route = triage_llm(message, history, notebook, mem)
route.engine = f"llm triage ({why})"
return route
def triage_llm(message: str, history: list[Turn], notebook: str, mem: ChatMemory | None = None) -> Triage:
"""One cheap LLM call: is this worth researching, and what exactly should be researched?"""
lm = triage_lm()
try:
with track_usage(lm) as usage, dspy.context(lm=lm):
if mem is not None:
pred = dspy.Predict(TriageWithMemory)(conversation=conversation_text(history), message=message,
notebook=notebook, topics=topics_digest(mem.tf))
else:
pred = dspy.Predict(TriageMessage)(conversation=conversation_text(history), message=message,
notebook=notebook)
except Exception as exc:
# If triage fails, fall back to researching the raw message rather than failing the chat.
log.warning("LLM triage failed, researching the raw message", exc_info=True)
return Triage("archive", message, "deep", "",
notes=[f"triage model failed ({type(exc).__name__}) -> archive, deep"])
notes: list[str] = []
kind = pred.kind if pred.kind in {"archive", "chitchat", "about_app", "off_topic"} else "archive"
question = (pred.standalone_question or "").strip() or message
reply = strip_em_dashes((pred.reply or "").strip())
if kind != "archive" and not reply:
notes.append(f"model said {kind} but wrote no reply -> archive")
kind = "archive" # nothing to say without research: research it
topic, memory = "", "none"
if mem is not None and kind == "archive":
memory = pred.memory if pred.memory in MEMORY_LEVELS else "none"
topic = (pred.topic or "").strip() if (pred.topic or "").strip() in mem.tf.slugs else ""
return Triage(kind, question, pred.depth if pred.depth in {"quick", "deep"} else "deep", reply,
usage.prompt_tokens, usage.completion_tokens, topic=topic, memory=memory, notes=notes)
def notebook_brief(title: str, allowed: dict[str, str]) -> str:
titles = list(allowed.values())[:25]
return f"Notebook: {title}. {len(allowed)} posts, including: " + "; ".join(titles)
def archive_guide(tools: ScopedTools) -> str:
return (
f"This notebook has {len(tools.allowed)} posts stored as markdown files under sources/. "
"Each file starts with front matter (title, url, published date), then '# Title' and '## Section' "
"headings. Paths look like sources/<site>/<yyyy-mm-dd>-<slug>.md. "
"Line numbers from read() and search() are what you cite."
)
def verify_citations(tools: ScopedTools, answer: str, citations,
chat_tools: ChatTools | None = None) -> tuple[str, list[VerifiedCitation]]:
"""Keep only citations that point at real lines, renumber them 1..n, and drop dangling markers. A path starting
with chats/ is checked against the notebook's chat memory, anything else against the posts."""
verified: list[VerifiedCitation] = []
remap: dict[int, int] = {}
for c in sorted(citations, key=lambda c: c.marker):
if c.marker in remap:
continue
if is_chat_path(c.path):
quote = chat_tools.quote(c.path, c.line_start, c.line_end) if chat_tools else None
else:
quote = tools.quote(c.path, c.line_start, c.line_end)
if not quote:
continue
new = len(verified) + 1
remap[c.marker] = new
end = max(c.line_start, min(c.line_end, c.line_start + 40))
verified.append(VerifiedCitation(new, c.path, c.line_start, end, quote[:600]))
def swap(m: re.Match) -> str:
n = remap.get(int(m.group(1)))
return f"[{n}]" if n else ""
text = _MARKER.sub(swap, answer)
text = re.sub(r"[ \t]+([.,;:!?])", r"\1", text) # keep line breaks: answers are Markdown
return ensure_markers(text.strip(), verified), verified
_WORD = re.compile(r"[a-z0-9][a-z0-9'-]{3,}")
_STOP = {"that", "this", "with", "from", "have", "your", "they", "them", "then", "than", "into", "when", "what",
"which", "their", "there", "about", "would", "could", "should", "also", "each", "only", "just", "more",
"some", "such", "like", "does", "were", "been", "being", "will", "post", "posts", "based"}
def _words(text: str) -> set[str]:
return {w for w in _WORD.findall(text.lower()) if w not in _STOP}
def ensure_markers(text: str, cites: list[VerifiedCitation]) -> str:
"""Every verified citation is shown in the answer as a [n] marker. The model sometimes writes none, or writes
numbers that do not match its citations and the verifier drops them. Whatever is missing is put back on the
sentence or list item whose words overlap the cited lines most, so the text and the list always agree."""
if not cites or not text:
return text
present = {int(n) for n in _MARKER.findall(text)}
missing = [c for c in cites if c.marker not in present]
if not missing:
return text
lines = text.split("\n")
units: list[int] = [] # indexes of lines that are prose or list items: the places a marker can go
in_code = False
for i, line in enumerate(lines):
t = line.strip()
if t.startswith("```"):
in_code = not in_code
continue
if in_code or len(t) < 25 or t.startswith(("#", "|", ">", "![")):
continue
units.append(i)
if not units:
lines[-1] = lines[-1].rstrip() + " " + "".join(f"[{c.marker}]" for c in missing)
return "\n".join(lines)
unit_words = {i: _words(lines[i]) for i in units}
add: dict[int, list[int]] = {}
def best(c: VerifiedCitation, only_unmarked: bool) -> int | None:
qw = _words(c.quote)
pool = [i for i in units if not (only_unmarked and (_MARKER.search(lines[i]) or i in add))]
scored = [(len(unit_words[i] & qw) / (1 + len(unit_words[i]) ** 0.5), i) for i in pool]
scored = [x for x in scored if x[0] > 0]
return max(scored)[1] if scored else None
# With no markers at all, give each citation a line of its own first, so they spread across the answer.
for c in missing:
i = best(c, only_unmarked=not present)
if i is not None:
add.setdefault(i, []).append(c.marker)
# Anything that shares no words with the answer still has to show: put it on the last prose line.
placed = {m for ms in add.values() for m in ms}
for c in missing:
if c.marker not in placed:
add.setdefault(units[-1], []).append(c.marker)
for i, ms in add.items():
line = lines[i].rstrip()
lines[i] = line + " " + "".join(f"[{m}]" for m in sorted(set(ms)))
return "\n".join(lines)
CONTEXT_LOG_SECTION_CHARS = 4000
def context_log_enabled() -> bool:
return text_enabled()
def _section(title: str, text: str) -> str:
text = (text or "").strip() or "(empty)"
hidden = len(text) - CONTEXT_LOG_SECTION_CHARS
note = f" [+{hidden} more characters not shown]" if hidden > 0 else ""
return f"--- {title} ({len(text)} chars) ---\n{text[:CONTEXT_LOG_SECTION_CHARS]}{note}"
def describe_context(question: str, route: Triage, inputs: dict, signature, tool_names: list[str],
chat_memory: ChatMemory | None, with_chat_tools: bool = False) -> str:
"""Everything the research agent is given for one message, as readable text for the server log."""
parts = [
"=== CHAT CONTEXT: what the research agent is given ===",
f"message from the writer: {question!r}",
f"triage: kind={route.kind} depth={route.depth} topic={route.topic or '-'} memory={route.memory}",
f"question given to the agent: {inputs['question']!r}",
f"agent signature: {signature.__name__} | tools: {', '.join(tool_names)}",
_section("conversation (last turns, citation markers removed)", inputs["conversation"]),
_section("writer notes (standing profile)", inputs["writer_profile"]),
"--- chat memory: what was decided and where it came from ---",
memory_decision(chat_memory, route.topic, route.memory, inputs.get("chat_memory", ""), with_chat_tools),
]
if chat_memory is not None:
parts.append(_section("topics of this chat (what triage chose from)", topics_digest(chat_memory.tf)))
parts.append(_section("chat memory notes (summary, exact words, recent messages)",
inputs.get("chat_memory", "(none loaded for this message)")))
return "\n".join(parts)
RETRY_TEMPERATURE = 0.9 # a different request from the failed one, so the LM cache cannot return the same bad reply
FAILED_REPLY = "I had trouble reading your posts just now. Please send that again."
def _retry_malformed_steps(agent, trace: ChatTrace):
"""dspy's ReAct needs every model reply to be JSON with next_thought, next_tool_name and next_tool_args. The model
sometimes sends only the tool's arguments, which raises AdapterParseError and used to fail the whole message.
Here the failing step is asked once more (a new request, not the cached one). If it fails again, a ValueError
tells ReAct to stop researching and write the answer from what it has found so far.
Hooks ReAct's `_call_with_potential_trajectory_truncation` (dspy 2.6.*, pinned in requirements.txt). Without it the
agent is returned unchanged."""
original = getattr(agent, "_call_with_potential_trajectory_truncation", None)
if original is None:
return agent
def call(module, trajectory, **inputs):
try:
return original(module, trajectory, **inputs)
except AdapterParseError as first:
reply = " ".join(str(first.lm_response).split())[:160]
log.warning("Malformed model reply, asking once more: %s", reply)
trace.add("steps", f"! the model's reply was not in the expected shape: {reply!r} -> asking again once")
try:
return original(module, trajectory, **inputs, config={"temperature": RETRY_TEMPERATURE})
except AdapterParseError as second:
if module is agent.react:
trace.add("steps", "! still malformed: research stops here and the answer is written from "
"what was found so far")
raise ValueError("the model could not produce a valid next step") from second
raise
agent._call_with_potential_trajectory_truncation = call
return agent
def _triage_detail(route: Triage) -> dict:
"""Who triaged, what Jev answered before our thresholds, and what the thresholds changed. Saved with the message."""
return {"engine": route.engine, "jev": route.jev, "notes": route.notes}
def _trace_triage(trace: ChatTrace, route: Triage) -> None:
"""The triage section of the log: every Jev answer with its confidence, then what our thresholds did to it."""
trace.add("triage", f"engine : {route.engine or 'unknown'}")
if route.jev:
for qid in ("intent", "depth", "memory", "topic"):
a = route.jev.get(qid)
if a:
trace.add("triage", f"{qid:<9}: {a['choice']:<22} confidence {a['confidence']:.2f} "
f"[{probs_text(a['probabilities'])}]")
else:
trace.add("triage", "(a language model answered, so there are no probabilities or confidences)")
trace.add("triage", "overrides: " + ("; ".join(route.notes) if route.notes
else "none, the answers were used as given"))
trace.add("triage", f"FINAL : kind={route.kind} depth={route.depth} memory={route.memory} "
f"topic={route.topic or '-'}")
if route.kind == "archive":
trace.add("triage", f"memory={route.memory} means: {MEMORY_GIVES.get(route.memory, '?')}")
def _memory_parts(notes: str) -> str:
"""Which pieces ended up in the chat memory block, read from the block's own headings."""
parts = [label for marker, label in (
("Summary [", "topic summary (R2)"), ("Exact words kept", "saved exact quotes (R2)"),
("Latest messages in this topic", "last messages word for word (Postgres)"),
("Messages not yet filed", "unfiled messages (Postgres)"),
("Earlier conversations in this notebook", "notebook topic index (R2)")) if marker in notes]
return ", ".join(parts) or "empty"
def research(
corpus: Corpus,
allowed: dict[str, str],
question: str,
on_step: Callable[[str, str], None] | None = None,
history: list[Turn] | None = None,
notebook_title: str = "",
profile: str = "",
chat_memory: ChatMemory | None = None,
trace: ChatTrace | None = None,
) -> ResearchResult:
history = history or []
trace = trace or current_trace() or ChatTrace() # a throwaway one when research() is called on its own
if not allowed:
trace.add("route", "NO POSTS: the notebook has none, so a fixed reply was sent and no model was called")
return ResearchResult("This notebook has no posts yet. Add some sources first.", True, [], kind="chitchat")
canned = instant_reply(question, history)
if canned:
trace.add("route", "SMALL TALK: the message is only a greeting or thanks (regex), so a fixed reply was sent "
"and no model, Jev call or R2 read was needed")
if context_log_enabled():
context_log.info("=== CHAT CONTEXT === message %r is small talk: canned reply, no model call", question)
return ResearchResult(canned, False, [], kind="chitchat", question=question)
if on_step:
on_step("think", "Reading the conversation")
with trace.stage("triage"):
route = triage(question, history, notebook_brief(notebook_title, allowed), chat_memory)
_trace_triage(trace, route)
if route.kind != "archive":
# App help is always the vetted template: a model would invent product facts.
if context_log_enabled():
context_log.info("=== CHAT CONTEXT === message %r triaged as %s: answered without the research agent",
question, route.kind)
reply = canned_reply(route.kind, allowed) if route.kind == "about_app" or not route.reply else route.reply
source = "a fixed template" if route.kind == "about_app" or not route.reply else "the triage model's reply"
trace.add("route", f"{route.kind.upper()}: triage decided this needs no research, so the reply is {source} "
"(no research agent, no post reads, no R2 sync)")
trace.add("result", f"reply {len(reply)} chars | "
f"tokens (triage): {route.prompt_tokens}/{route.completion_tokens}")
return ResearchResult(reply, False, [], route.prompt_tokens, route.completion_tokens,
kind=route.kind, question=question,
recall={"route": _route_info(route), "triage_detail": _triage_detail(route),
"memory_loaded": chat_memory is not None})
trace.add("route", "ARCHIVE: the message needs the writer's posts, so the research agent runs")
with trace.stage("sync-posts"):
corpus.sync()
steps: list[tuple[str, str]] = []
def log(kind: str, detail: str) -> None:
steps.append((kind, detail))
trace.step(kind, detail)
if on_step:
on_step(kind, detail)
tools = ScopedTools(corpus, allowed, on_step=log)
trace.start_agent() # step offsets in the log count from here
notes, with_chat_tools = ("", False)
if chat_memory is not None:
with trace.stage("build-memory"):
notes, with_chat_tools = build_context(chat_memory, route.topic, route.memory)
if notes or with_chat_tools:
log("think", "Recalling earlier in this chat")
chat_tools = ChatTools(chat_memory.corpus, on_step=log) if chat_memory is not None and with_chat_tools else None
budget = min(QUICK_STEPS, settings.research_max_steps) if route.depth == "quick" else settings.research_max_steps
tool_list = [tools.list_files, tools.search, tools.read]
if chat_tools:
tool_list += [chat_tools.list_chat_topics, chat_tools.search_chats, chat_tools.read_chat]
inputs = dict(question=route.question, conversation=conversation_text(history),
archive_guide=archive_guide(tools), writer_profile=profile or "(none)")
if notes or chat_tools:
signature, inputs["chat_memory"] = ResearchArchiveWithMemory, notes or "(nothing filed yet)"
else:
signature = ResearchArchive
if context_log_enabled():
context_log.info(context_banner(chat_memory, route.topic, route.memory, inputs.get("chat_memory", "")))
context_log.info(describe_context(question, route, inputs, signature, [t.__name__ for t in tool_list],
chat_memory, chat_tools is not None))
agent = _retry_malformed_steps(dspy.ReAct(signature, tools=tool_list, max_iters=budget), trace)
lm = main_lm()
if not settings.chat_memory_read:
memory_line = "chat memory: OFF (CHAT_MEMORY_READ=false), so only the last messages of this chat are used"
elif chat_memory is None:
memory_line = "chat memory: nothing loaded (no topics filed for this chat yet, or loading failed)"
elif not notes and not chat_tools:
memory_line = (f"chat memory: loaded ({len(chat_memory.tf.entries)} topic(s)) but memory={route.memory}, "
"so nothing was passed")
else:
memory_line = (f"chat memory block: {len(inputs.get('chat_memory', ''))} chars = {_memory_parts(notes)}"
f"; chat tools {'given' if chat_tools else 'not given'}")
turns = min(len(history), HISTORY_TURNS)
trace.add("context",
f"signature: {signature.__name__} | model {getattr(lm, 'model', '?')} | "
f"up to {budget} reasoning steps ({route.depth})",
f"tools : {', '.join(t.__name__ for t in tool_list)}",
f"question : {len(inputs['question'])} chars ("
+ ("the writer's raw message: Jev does not rewrite it" if route.engine == "jev"
else "rewritten as a standalone question by the triage model") + ")",
f"conversation: {turns} turn(s), {len(inputs['conversation'])} chars [Postgres, last messages]",
f"writer notes: {len([ln for ln in profile.splitlines() if ln.strip()]) if profile else 0} line(s), "
f"{len(profile)} chars [Postgres WorkspaceMemory]",
f"archive guide: {len(inputs['archive_guide'])} chars [built in code]",
memory_line)
try:
with trace.stage("agent"), track_usage(lm) as usage, dspy.context(lm=lm):
pred = agent(**inputs)
except AdapterParseError as exc:
# Even the final answer step came back malformed twice. Save a plain reply instead of an unanswered question.
# not `log`: inside research() that name is the step recorder
logging.getLogger(__name__).warning("Research failed: the model's reply could not be read: %s",
" ".join(str(exc.lm_response).split())[:200])
trace.add("result", "! the final answer step was malformed twice, so a plain 'please send that again' reply "
"was saved")
return ResearchResult(FAILED_REPLY, True, [], route.prompt_tokens, route.completion_tokens, steps=steps,
kind="error", question=route.question,
recall={"route": _route_info(route), "triage_detail": _triage_detail(route),
"memory_loaded": chat_memory is not None, "failed": "malformed model reply"})
with trace.stage("verify"):
text, cites = verify_citations(tools, pred.answer, pred.citations or [], chat_tools)
unsupported = bool(pred.unsupported) or not cites
refused = False
if not cites and not pred.unsupported:
# The model answered without anything we could verify: do not pass it off as grounded.
text = "I could not find support for that in these posts, so I would rather not guess."
refused = True
proposed = pred.citations or []
kept = {(c.path, c.line_start) for c in cites}
dropped = [c for c in proposed if (c.path, c.line_start) not in kept]
trace.add("citations",
f"the model proposed {len(proposed)}, verified {len(cites)} "
f"(posts {sum(1 for c in cites if not is_chat_path(c.path))}, "
f"earlier chats {sum(1 for c in cites if is_chat_path(c.path))}), dropped {len(dropped)}",
*[f"dropped: {c.path}:{c.line_start}-{c.line_end} (file not allowed here, or lines do not exist)"
for c in dropped[:6]])
trace.add("result",
f"answer {len(text)} chars | unsupported={unsupported} | "
f"tokens prompt/completion: triage {route.prompt_tokens}/{route.completion_tokens}, "
f"agent {usage.prompt_tokens}/{usage.completion_tokens}",
*(["the model gave no verifiable citation, so the answer was replaced with the 'could not find "
"support' message"] if refused else []))
return ResearchResult(
text=strip_em_dashes(text),
unsupported=unsupported,
citations=cites,
prompt_tokens=usage.prompt_tokens + route.prompt_tokens,
completion_tokens=usage.completion_tokens + route.completion_tokens,
steps=steps,
kind="archive",
question=route.question,
recall={
"route": _route_info(route),
"triage_detail": _triage_detail(route),
"memory_loaded": chat_memory is not None,
"memory_notes": notes[:4000], # exactly what the agent was shown from earlier chats
"chat_tools": chat_tools is not None,
"chat_topics": [e.slug for e in chat_memory.tf.entries] if chat_memory is not None else [],
},
)
|