diff --git a/Agent.md b/Agent.md index 1857c368..7a2edacc 100644 --- a/Agent.md +++ b/Agent.md @@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` -Python: `uv run pytest tests/ -v` (925) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (929) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (257: 45 daemon_client + 19 conn-manager + 22 app-commands + 129 renderer smoke + 16 i18n + 7 integration + 3 commands + 7 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 497d3076..3a06fbe2 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -2221,6 +2221,7 @@ async def _run_tool_loop( # Streaming call to LLM content_parts: list[str] = [] + reasoning_parts: list[str] = [] # think block, llm.jsonl only (rant 2026-08-18T09:43:23) tc_by_index: dict[int, dict] = {} final_finish = None final_usage: dict | None = None @@ -2242,6 +2243,13 @@ async def _run_tool_loop( "session_id": session.session_id, }) + # Accumulate reasoning (think) — NOT broadcast, NOT persisted + # into session messages; only lands in the llm.jsonl response + # record via _log_llm_exchange (rant 2026-08-18T09:43:23). + r = delta.get("reasoning") + if r: + reasoning_parts.append(r) + # Track accumulated tool calls for finalization tcs = delta.get("tool_calls") if tcs: @@ -2280,6 +2288,7 @@ async def _run_tool_loop( return full_content = "".join(content_parts) + full_reasoning = "".join(reasoning_parts) or None logger.debug("round %d finish: %s, tool_calls=%d, content_len=%d", round_num, final_finish, len(tc_by_index), len(full_content)) @@ -2289,6 +2298,7 @@ async def _run_tool_loop( self._log_llm_exchange( session, [dict(m) for m in messages], tools_openai, full_content, final_finish, final_usage, + reasoning=full_reasoning, ) # Persist assistant message @@ -2340,6 +2350,7 @@ async def _run_tool_loop( "arguments": tc.get("function", {}).get("arguments", "")}} for tc in tool_calls ], + reasoning=full_reasoning, ) # Build the assistant message with tool_calls @@ -2473,6 +2484,7 @@ async def _run_tool_loop( self._log_llm_exchange( session, [dict(m) for m in messages], tools_openai, full_content, final_finish, final_usage, + reasoning=full_reasoning, ) session.append_message({ @@ -2514,11 +2526,16 @@ def _log_llm_exchange( self, session: Session, messages, tools, content: str, finish_reason: str = "stop", usage=None, tool_calls=None, + reasoning: str | None = None, ) -> None: """Log a complete LLM request/response exchange to the session. Centralizes the identical append_llm patterns from _run_tool_loop, ensuring consistent logging format. + + ``reasoning`` (rant 2026-08-18T09:43:23) is written ONLY into the + response record (when the model produced a think block); the request + record stays untouched so llm.jsonl history/context is not bloated. """ session.append_llm({ "type": "request", @@ -2538,6 +2555,8 @@ def _log_llm_exchange( response["usage"] = usage if tool_calls is not None: response["tool_calls"] = tool_calls + if reasoning is not None: + response["reasoning"] = reasoning session.append_llm(response) # ── Token estimation helpers ────────────────────────────── diff --git a/emrg/server/llm.py b/emrg/server/llm.py index be6284d7..850149c8 100644 --- a/emrg/server/llm.py +++ b/emrg/server/llm.py @@ -209,11 +209,17 @@ async def chat_stream( Yields dicts of shape: {"content": str | None, "tool_calls": list[dict] | None, - "finish_reason": str | None, "usage": dict | None} + "finish_reason": str | None, "usage": dict | None, + "reasoning": str | None} tool_calls are accumulated across chunks (by index). Each yield carries the current accumulated state so callers can track progress. + ``reasoning`` (rant 2026-08-18T09:43:23) carries the accumulated + think/chain-of-thought text (``reasoning_content`` / ``reasoning`` + deltas) or None when the model does not reason. Usage may include + ``reasoning_tokens`` when the API reports it. + The final yield includes usage (prompt_tokens, completion_tokens) when the API provides it. @@ -237,6 +243,7 @@ async def chat_stream( # Accumulated state across chunks (reset on retry) content_parts: list[str] = [] + reasoning_parts: list[str] = [] tc_by_index: dict[int, dict] = {} last_error = None @@ -247,6 +254,7 @@ async def chat_stream( logger.debug("LLM stream attempt %d/%d", attempt + 1, MAX_RETRIES + 1) # Reset accumulators before each attempt content_parts[:] = [] + reasoning_parts[:] = [] tc_by_index.clear() async with client.stream("POST", url, headers=headers, json=payload) as resp: @@ -300,6 +308,14 @@ async def chat_stream( if text_content: content_parts.append(text_content) + # Accumulate reasoning / think block (rant 2026-08-18T09:43:23): + # DeepSeek sends `reasoning_content`, OpenAI-compatible + # endpoints may send `reasoning` — accept both field names, + # accumulating per-delta like content_parts. + reasoning = delta.get("reasoning_content") or delta.get("reasoning") or "" + if reasoning: + reasoning_parts.append(reasoning) + # Accumulate tool_calls from delta for tc in delta.get("tool_calls", []): idx = tc.get("index", 0) @@ -327,6 +343,14 @@ async def chat_stream( # Capture usage from chunk (may appear in any chunk or only in final) usage = chunk.get("usage") + # reasoning_tokens may sit at the top level or nested under + # completion_tokens_details (rant 2026-08-18T09:43:23) + reasoning_tokens = ( + usage.get("reasoning_tokens") + if usage and usage.get("reasoning_tokens") is not None + else (usage.get("completion_tokens_details") or {}).get("reasoning_tokens") + if usage else None + ) yield { "content": text_content or None, @@ -335,7 +359,11 @@ async def chat_stream( "usage": { "prompt_tokens": usage.get("prompt_tokens"), "completion_tokens": usage.get("completion_tokens"), + "reasoning_tokens": reasoning_tokens, } if usage else None, + # accumulated think text; None when the model does not + # reason (rant 2026-08-18T09:43:23 — only on response side) + "reasoning": "".join(reasoning_parts) or None, } # On finish, we're done with this stream diff --git a/tests/test_llm.py b/tests/test_llm.py index 69488a1f..c7a70d3d 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -413,3 +413,121 @@ async def aiter_lines(self): assert "LLM stream attempt 2/4" in caplog.text assert "attempt 1/4" not in caplog.text assert "LLM stream transient error 500" in caplog.text + + +# ── Reasoning / think-block capture (rant 2026-08-18T09:43:23) ── + +def _make_stream(*objs): + """Build a one-shot 200 stream yielding the given SSE chunk objects.""" + + class _GoodStream: + status_code = 200 + headers = {} + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def aiter_lines(self): + import json + for obj in objs: + yield "data: " + json.dumps(obj) + + return _GoodStream() + + +def _collect_chunks(client): + import asyncio + chunks = [] + + async def _run(): + async for chunk in client.chat_stream([{"role": "user", "content": "hi"}]): + chunks.append(chunk) + + asyncio.run(_run()) + return chunks + + +def test_stream_accumulates_reasoning_content(monkeypatch, client): + """DeepSeek-style `reasoning_content` deltas accumulate into the yielded + `reasoning` field; the final chunk carries the full think text.""" + _patch_fast_sleep(monkeypatch) + fake = _FakeStreamClient([_make_stream( + {"choices": [{"delta": {"reasoning_content": "Let me "}}]}, + {"choices": [{"delta": {"reasoning_content": "think step by step"}}]}, + {"choices": [{"delta": {"content": "final answer"}}]}, + {"choices": [{"delta": {}, "finish_reason": "stop"}]}, + )]) + client._client = fake + chunks = _collect_chunks(client) + last = chunks[-1] + assert last["reasoning"] == "Let me think step by step" + # content is unaffected + assert "".join(c.get("content") or "" for c in chunks) == "final answer" + assert fake.calls == 1 + + +def test_stream_accumulates_openai_reasoning(monkeypatch, client): + """OpenAI-style `reasoning` field name is also accepted.""" + _patch_fast_sleep(monkeypatch) + fake = _FakeStreamClient([_make_stream( + {"choices": [{"delta": {"reasoning": "think 1"}}]}, + {"choices": [{"delta": {"reasoning": " think 2"}}]}, + {"choices": [{"delta": {}, "finish_reason": "stop"}]}, + )]) + client._client = fake + chunks = _collect_chunks(client) + assert chunks[-1]["reasoning"] == "think 1 think 2" + + +def test_stream_no_reasoning_means_none(monkeypatch, client): + """A model that does not reason → `reasoning` stays None (regression-safe: + no think block, no field pollution in llm.jsonl).""" + _patch_fast_sleep(monkeypatch) + fake = _FakeStreamClient([_make_stream( + {"choices": [{"delta": {"content": "plain"}}]}, + {"choices": [{"delta": {}, "finish_reason": "stop"}]}, + )]) + client._client = fake + chunks = _collect_chunks(client) + assert all(c.get("reasoning") is None for c in chunks) + + +def test_stream_usage_reasoning_tokens_top_level_and_nested(monkeypatch, client): + """usage.reasoning_tokens is captured from the top level AND from the + completion_tokens_details nesting (two provider conventions).""" + _patch_fast_sleep(monkeypatch) + + # top-level reasoning_tokens + fake = _FakeStreamClient([_make_stream( + {"choices": [{"delta": {"reasoning_content": "x"}}]}, + {"choices": [{"delta": {}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 10, "completion_tokens": 20, + "reasoning_tokens": 7}}, + )]) + client._client = fake + chunks = _collect_chunks(client) + assert chunks[-1]["usage"]["reasoning_tokens"] == 7 + assert chunks[-1]["usage"]["prompt_tokens"] == 10 + + # nested under completion_tokens_details + fake2 = _FakeStreamClient([_make_stream( + {"choices": [{"delta": {"reasoning_content": "x"}}]}, + {"choices": [{"delta": {}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 1, "completion_tokens": 2, + "completion_tokens_details": {"reasoning_tokens": 9}}}, + )]) + client._client = fake2 + chunks2 = _collect_chunks(client) + assert chunks2[-1]["usage"]["reasoning_tokens"] == 9 + + # no usage → None (unchanged behavior) + fake3 = _FakeStreamClient([_make_stream( + {"choices": [{"delta": {"content": "hi"}}]}, + {"choices": [{"delta": {}, "finish_reason": "stop"}]}, + )]) + client._client = fake3 + chunks3 = _collect_chunks(client) + assert chunks3[-1]["usage"] is None