phase: 158_thinking_timeline
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:
+203
-1
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user