Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 路径不受影响)
Expand Down
19 changes: 19 additions & 0 deletions emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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:
Expand DownExpand Up@@ -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))

Expand All@@ -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
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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({
Expand DownExpand Up@@ -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",
Expand All@@ -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 ──────────────────────────────
Expand Down
30 changes: 29 additions & 1 deletion emrg/server/llm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand All@@ -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
Expand All@@ -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:
Expand DownExpand Up@@ -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)
Expand DownExpand Up@@ -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,
Expand All@@ -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
Expand Down
118 changes: 118 additions & 0 deletions tests/test_llm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 路径不受影响)
Expand Down
19 changes: 19 additions & 0 deletions emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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:
Expand DownExpand Up@@ -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))

Expand All@@ -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
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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({
Expand DownExpand Up@@ -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",
Expand All@@ -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 ──────────────────────────────
Expand Down
30 changes: 29 additions & 1 deletion emrg/server/llm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand All@@ -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
Expand All@@ -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:
Expand DownExpand Up@@ -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)
Expand DownExpand Up@@ -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,
Expand All@@ -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
Expand Down
118 changes: 118 additions & 0 deletions tests/test_llm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 路径不受影响)
Expand Down
19 changes: 19 additions & 0 deletions emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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:
Expand DownExpand Up@@ -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))

Expand All@@ -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
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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({
Expand DownExpand Up@@ -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",
Expand All@@ -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 ──────────────────────────────
Expand Down
30 changes: 29 additions & 1 deletion emrg/server/llm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand All@@ -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
Expand All@@ -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:
Expand DownExpand Up@@ -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)
Expand DownExpand Up@@ -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,
Expand All@@ -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
Expand Down
118 changes: 118 additions & 0 deletions tests/test_llm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 路径不受影响)
Expand Down
19 changes: 19 additions & 0 deletions emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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:
Expand DownExpand Up@@ -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))

Expand All@@ -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
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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({
Expand DownExpand Up@@ -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",
Expand All@@ -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 ──────────────────────────────
Expand Down
30 changes: 29 additions & 1 deletion emrg/server/llm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand All@@ -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
Expand All@@ -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:
Expand DownExpand Up@@ -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)
Expand DownExpand Up@@ -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,
Expand All@@ -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
Expand Down
118 changes: 118 additions & 0 deletions tests/test_llm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 路径不受影响)
Expand Down
19 changes: 19 additions & 0 deletions emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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:
Expand DownExpand Up@@ -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))

Expand All@@ -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
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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({
Expand DownExpand Up@@ -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",
Expand All@@ -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 ──────────────────────────────
Expand Down
30 changes: 29 additions & 1 deletion emrg/server/llm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand All@@ -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
Expand All@@ -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:
Expand DownExpand Up@@ -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)
Expand DownExpand Up@@ -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,
Expand All@@ -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
Expand Down
118 changes: 118 additions & 0 deletions tests/test_llm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 路径不受影响)
Expand Down
19 changes: 19 additions & 0 deletions emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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:
Expand DownExpand Up@@ -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))

Expand All@@ -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
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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({
Expand DownExpand Up@@ -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",
Expand All@@ -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 ──────────────────────────────
Expand Down
30 changes: 29 additions & 1 deletion emrg/server/llm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand All@@ -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
Expand All@@ -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:
Expand DownExpand Up@@ -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)
Expand DownExpand Up@@ -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,
Expand All@@ -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
Expand Down
118 changes: 118 additions & 0 deletions tests/test_llm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 路径不受影响)
Expand Down
19 changes: 19 additions & 0 deletions emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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:
Expand DownExpand Up@@ -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))

Expand All@@ -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
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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({
Expand DownExpand Up@@ -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",
Expand All@@ -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 ──────────────────────────────
Expand Down
30 changes: 29 additions & 1 deletion emrg/server/llm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand All@@ -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
Expand All@@ -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:
Expand DownExpand Up@@ -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)
Expand DownExpand Up@@ -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,
Expand All@@ -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
Expand Down
118 changes: 118 additions & 0 deletions tests/test_llm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 路径不受影响)
Expand Down
19 changes: 19 additions & 0 deletions emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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:
Expand DownExpand Up@@ -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))

Expand All@@ -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
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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({
Expand DownExpand Up@@ -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",
Expand All@@ -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 ──────────────────────────────
Expand Down
30 changes: 29 additions & 1 deletion emrg/server/llm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand All@@ -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
Expand All@@ -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:
Expand DownExpand Up@@ -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)
Expand DownExpand Up@@ -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,
Expand All@@ -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
Expand Down
118 changes: 118 additions & 0 deletions tests/test_llm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Loading