phase: 158_thinking_timeline
Build and Push Containers / build-and-push-app (push) Successful in 8m38s
Build and Push Containers / build-and-push-db (push) Successful in 32s

All verification complete. Every gate is green and the docs check out. Final report:

## Phase 158 final verification pass — COMPLETE

**Verified (all tasks pre-completed; this pass re-ran the full gate):**
- Unit + integration: `uv run pytest --cov=app --cov-report=term-missing` → **3618 passed, 99% coverage on `app/`** (>90% gate)
- Phase E2E in isolation: `uv run pytest tests/e2e/test_thinking_timeline.py -v --no-cov` → **6/6 passed** (order pin, fold-at-rest, live-open, restore+`segments` array, shared page, no-thinking single disclosure)
- Regression E2E, each in isolation: `test_thinking_display` 5✓, `test_tool_call_compact` 4✓, `test_turn_progress_loader` 3✓ (per-run pin updated), `test_big_read_progress` 4✓, `test_agent_document_tools` 4✓, `test_share_chat` 4✓, `test_chat_persistence` 4✓, `test_smoke` 3✓
- Lint/types: `uv run ruff check . && uv run pyright` → **clean (0 errors)**

**Completion criteria:** all 9 met — timeline E2E pins ✓, restore/shared `segments` ✓, no-thinking single-disclosure + the three UNCHANGED regression suites green ✓, pre-phase byte-identical round-trip (integration pin: key absent, never null; >50 / >32 000 / empty text / unknown kind all 422) ✓, full suite + coverage + E2E + lint ✓, `git diff --stat` footprint limited to `app/schemas.py`, `frontend/assets/{app,shared}.js`, `tests/`, README, phase files (a pre-existing owner `TODO.md` edit is out of scope, not staged by the phase commit) ✓, D15→per-run supersession recorded in the updated pin ✓. (Commit + phase move are the harness's job; task dirs already in `complete/`.)

**Documentation:** README.md — Thinking section correctly describes the phase-158 timeline; config reference matches `.env.example` exactly; page table matches registered routes; no stale single-block wording. AGENTS.md — rules and quick-reference commands all accurate. **Verified consistent — no changes needed.**

**Decisions/deviations:** none. `test_tool_call_compact.py` carries a one-line docstring-only update (referenced the removed `closeToolCalls`); its test logic is byte-identical to the phase-117 contract and green.

**Next pending phase:** none — `todo/` contains only 158.
This commit is contained in:
2026-10-05 02:52:55 -04:00
parent 3f752fb77f
commit 31bd903a93
49 changed files with 4280 additions and 482 deletions
+203 -1
View File
@@ -153,6 +153,59 @@ Implements just enough of the aipi surface:
offered — e.g. ``agent_max_rounds=0``) behave exactly as today.
``E2E_REAL_LLM=1`` ignores the mock entirely (the real model does
what it does).
- user message containing ``think out loud and use your tools``
(``THINK_TOOLS_TRIGGER``, phase 158 task 04 — the
thinking/tool-call timeline story's dedicated suite
``tests/e2e/test_thinking_timeline.py``) **and** the system
prompt carries the ``<tools>`` section -> the deterministic
INTERLEAVED tool flow: the phase-37 single-read shape (list →
drill → read → the quoted answer) with a DISTINCT
``reasoning_content`` run streamed AHEAD of every tool call and
ahead of the final answer — the wire order the client's
per-run timeline renders (Thinking → Tool call (1) → Thinking →
Tool call (1) → Thinking → Tool call (1) → Thinking → answer).
Discriminated statelessly from the messages (streaming only —
the app's chat path is the only streaming consumer; a
non-streaming marker request never issues and falls through to
the plain ``think out loud`` scratchpad answer, like the other
marker flows): the ``tools`` parameter gates the list/drill/read
steps (a no-tools request with no tool results is not the flow),
and the answer is reached regardless of the ``tools`` parameter
(phase 45 keeps the tools offered until the round cap):
* request 1 (``tools`` offered, no tool results yet): stream
``reasoning_content`` (``Step 1: list the documents to find
the reference server config.``) FIRST — the 12-char
``_sse_stream`` convention — then ONLY ``tool_calls``
deltas: ``ls`` (synthetic id ``call_0``, no arguments),
``finish_reason: "tool_calls"``;
* request 2 (the top-level source listing in the messages —
the agent's ``ls`` tree format, phase 94: no file lines
yet): ``Step 2: drill into the first source.`` first, then
``tool_calls`` — ``ls`` scoped to the FIRST source of the
listing (id ``call_1``) — the drill step (``_drill_target``
skips already-drilled sources, so the second listing is the
folder level);
* request 3 (a ``tool``-role folder listing with file lines in
the messages — the labeled ``source:`` / ``path:`` fields,
phase 63/70): ``Step 3: read the file line's document.``
first, then ``tool_calls`` — ``read`` on the JOINED
combined ``source/path`` of the FIRST file line (id
``call_2``);
* request 4 (a ``tool``-role read result in the messages —
content starting with the agent's ``"Document
<source/path>:"`` header): ``Step 4: compose the answer from
the read document.`` first, then the phase-37 content
answer, deterministic: ``Read <source/path>. <first 80
chars of the read document's content>`` — so a suite can
assert the read document reached the model and landed in
the answer.
Checked BEFORE the plain ``use your tools`` flow: the trigger
CONTAINS ``use your tools`` (and ``think out loud``), so the
plain tool flow must not see it (the more-specific-phrase-wins
convention — the ``SLOW_PRETOKEN_TRIGGER`` / ``THINK_PARAS_-
TRIGGER`` / ``SCAFFOLD_ALWAYS_TRIGGER`` precedent). Verified
2026-10-04: no existing E2E question or fixture file contains
the phrase, so every other suite is unaffected.
- user message containing ``search your documents``
(``SEARCH_TRIGGER``, phase 68 search tool — renamed to the
harness-aligned ``grep`` in phase 70, same match/output contract)
@@ -993,6 +1046,26 @@ LONG_ANSWER_END = "LONG-ANSWER-END"
#: contain the substring, so every other suite is unaffected.
THINKING_TRIGGER = "think out loud"
#: Phase 158 (task 04 — the thinking/tool-call timeline story): a
#: user message containing this substring (case-insensitive) —
#: combined with the ``<tools>`` section in the system prompt —
#: drives the deterministic INTERLEAVED flow documented in the module
#: docstring: a DISTINCT ``reasoning_content`` run ahead of EVERY
#: tool call and ahead of the final answer (the ``Step N`` markers
#: make the runs individually assertable). Verified 2026-10-04: no
#: existing E2E question or fixture file contains the phrase, so
#: every other suite is unaffected. NOTE: the phrase CONTAINS
#: ``THINKING_TRIGGER`` ("think out loud") and ``TOOLS_TRIGGER``
#: ("use your tools") — the more-specific-phrase-wins convention
#: (``SLOW_PRETOKEN_TRIGGER`` / ``THINK_PARAS_TRIGGER`` /
#: ``SCAFFOLD_ALWAYS_TRIGGER``): the streaming dispatch checks the
#: flow BEFORE the plain ``_tool_flow`` (so the plain tool flow
#: never sees the marker). A non-streaming marker request (never
#: issued by the app — the chat path is the only streaming consumer)
#: falls through to the plain ``think out loud`` scratchpad answer,
#: exactly like the other marker flows.
THINK_TOOLS_TRIGGER = "think out loud and use your tools"
#: Regression pin (2026-08-29, owner report): a user message containing
#: this substring (case-insensitive) gets the phase-17 scratchpad WITH
#: REAL paragraph breaks ("\n\n"), streamed at ``THINK_PARAS_CHUNK``
@@ -1907,6 +1980,72 @@ def _tool_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
return ("list", "", "")
def _thinking_tools_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
"""Classify a phase-158 marker request into one step of the
INTERLEAVED thinking + tool flow (see the module docstring).
The phase-37 single-read shape — list → drill → read → the
quoted answer — with a DISTINCT ``reasoning_content`` run
streamed AHEAD of every tool call and ahead of the final answer
(the ``Step N`` markers make each run individually assertable —
the per-run timeline the story E2E pins):
* ``("list", <step 1 thinking>)`` — ``tools`` are offered and no
tool results are in the messages yet: the model thinks, then
lists the catalog (``ls``, id ``call_0``, no arguments).
* ``("drill", source, <step 2 thinking>)`` — the top-level
source listing is in the messages but no file line yet (phase
94: the drill — ``_drill_target`` skips already-drilled
sources, so the second listing is the folder level): the model
thinks, then drills into the first source (id ``call_1``).
* ``("read", "source/path", <step 3 thinking>)`` — a
``tool``-role folder listing with file lines: the model
thinks, then reads the FIRST file line's document — the
labeled ``source:`` / ``path:`` fields (phase 63) joined into
the combined ``source/path`` (phase 70), id ``call_2``.
* ``("answer", "source/path", content, <step 4 thinking>)`` — a
``tool``-role read result (``"Document <source/path>:\n
<content>"``): the model thinks, then answers, quoting the
read document (the phase-37 shape). Reached regardless of the
``tools`` parameter (phase 45 keeps the tools offered until
the round cap).
* ``None`` — not the marker flow: the marker is absent, the
``<tools>`` section is not in the system prompt, or a
no-tools request with no tool results (e.g. ``agent_max_
rounds=0``) — the request behaves exactly as before.
"""
user = _user(body).lower()
if THINK_TOOLS_TRIGGER not in user:
return None
if "<tools>" not in _system(body):
return None
reads = _read_results(body)
if reads:
sp, content = reads[0]
return (
"answer",
sp,
content,
"Step 4: compose the answer from the read document.",
)
if not body.get("tools"):
return None
first = _first_file_line(body)
if first:
match = _CATALOG_LINE_RE.match(first)
if match:
return (
"read",
f"{match.group('source')}/{match.group('path')}",
"Step 3: read the file line's document.",
)
return None # a malformed line (never at runtime — defensive)
drill = _drill_target(body)
if drill is not None:
return ("drill", drill, "Step 2: drill into the first source.")
return ("list", "Step 1: list the documents to find the reference server config.")
def _ls_teach_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
"""Classify a phase-72 LS-TEACH request (see the module docstring).
@@ -3814,7 +3953,12 @@ def _apply_max_tokens(answer: str, max_tokens: Any) -> str:
return " ".join(words[:max_tokens])
def _tool_call_stream(name: str, arguments: dict[str, Any], call_id: str) -> Any:
def _tool_call_stream(
name: str,
arguments: dict[str, Any],
call_id: str,
thinking: str = "",
) -> Any:
"""SSE frames for one tool-call-only chat completion (phase 37).
The OpenAI wire convention the app accumulates (``app/rag/llm.py``):
@@ -3831,9 +3975,32 @@ def _tool_call_stream(name: str, arguments: dict[str, Any], call_id: str) -> Any
from the first ``tool`` frame until the first answer ``delta``) is a
comfortable observation window for the story E2E (~1 s across the
two tool requests).
Phase 158 (task 04 — the phase-158 flow): the optional fourth
parameter — when ``thinking`` is non-empty its 12-char
``_sse_stream``-convention slices go out FIRST as
``delta.reasoning_content`` frames (the 0.02 s cadence, the
aipi wire convention — reasoning before the tool call), then the
tool-call frames + ``finish_reason: "tool_calls"`` below are
UNCHANGED. Default ``""`` → every pre-phase 3-arg caller
streams BYTE-IDENTICAL (the other marker flows' byte-
identity pins stay green).
"""
model = "turbo"
chunk_id = f"chatcmpl-{uuid.uuid4()}"
if thinking:
for piece in re.findall(r".{1,12}", thinking, re.S):
payload = {
"id": chunk_id,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [
{"index": 0, "delta": {"reasoning_content": piece}, "finish_reason": None}
],
}
yield f"data: {json_dumps(payload)}\n\n"
time.sleep(0.02)
raw_args = json_dumps(arguments) if arguments else "{}"
frags = [raw_args[i : i + 16] for i in range(0, len(raw_args), 16)] or ["{}"]
for i, frag in enumerate(frags):
@@ -4449,6 +4616,41 @@ def chat_completions(body: dict[str, Any]) -> Any:
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# Phase 158 (task 04 — the thinking/tool-call timeline
# story): the deterministic INTERLEAVED flow — a DISTINCT
# reasoning run ahead of every tool call and ahead of the
# final answer (see the module docstring). Checked BEFORE
# the plain TOOLS_TRIGGER flow: the trigger CONTAINS
# ``use your tools`` (and ``think out loud``), so the plain
# tool flow must not see it (the more-specific-phrase-wins
# convention — the SLOW_PRETOKEN / THINK_PARAS / SCAFFOLD_
# ALWAYS precedent; verified 2026-10-04: no existing E2E
# question contains the phrase, so every other suite is
# unaffected). The trigger needs the ``<tools>`` section, so
# deflected turns never hit it.
think_tools = _thinking_tools_flow(body)
if think_tools is not None:
if think_tools[0] == "list":
stream = _tool_call_stream("ls", {}, "call_0", think_tools[1])
elif think_tools[0] == "drill":
stream = _tool_call_stream(
"ls", {"path": think_tools[1]}, "call_1", think_tools[2]
)
elif think_tools[0] == "read":
stream = _tool_call_stream(
"read", {"path": think_tools[1]}, "call_2", think_tools[2]
)
else: # "answer" — the phase-37 shape, thinking first
answer = _apply_max_tokens(
f"Read {think_tools[1]}. {think_tools[2][:80]}",
body.get("max_tokens"),
)
stream = _sse_stream(answer, 0.0, thinking=think_tools[3])
return StreamingResponse(
stream,
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
flow = _tool_flow(body)
if flow is not None:
if flow[0] == "list":