Skip to content

Commit 85b637f

Browse files
saxmanclaude
andcommitted
feat(models): stamp every appended message with an append-time timestamp on both surfaces
Route all content-bearing message appends through _ChatStateMixin._append_message so the inert `timestamp` key is set uniformly. Previously only the async providers, async tool loop, and the sync ConversationManager set it, leaving sync provider assistant messages and the non-streamed tool-result path unstamped. - Switch the sync providers (anthropic, ollama, openai_compat, llamacpp, hf.text) assistant-response appends to _append_message. - Switch both tool-loop engines' tool-result appends (streaming and non-streamed _dispatch) to _append_message. - Docs: add a "Message Timestamps" section, note _append_message on the mixin, and correct the timestamp attribution in the provenance section; update CHANGELOG. - Tests: cover the tool-loop dispatch stamping; give hand-rolled _chat/_Client fakes the _append_message seam and ignore the inert key in exact-content asserts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 40f7ec4 commit 85b637f

21 files changed

Lines changed: 214 additions & 73 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
- **New** **Model strings carry an endpoint and capabilities inline** (`aimu.models.model_client.resolve_model`, mirrored on the async path). The text model string grammar is now `provider:model_id[@base_url][;flags]`. Appending `@<base_url>` overrides the endpoint for the OpenAI-compatible local-server providers (`llamaserver`, `lmstudio`, `vllm`, `hf-openai`, `sglang`, `ollama-openai`), so a single string can target a remote llama.cpp / vLLM server. A new generic `openai-compat:<model_id>@<base_url>` prefix reaches any OpenAI-compatible server not tied to a known provider (the `@<base_url>` is required there). A model id **not** in the provider catalog is allowed for these providers when its capabilities are declared with `;<flags>` (comma-separated from `tools,thinking,vision,audio,structured`); such ids resolve to a new `AdHocModel` (exported from `aimu.models`) instead of raising. Known ids keep their catalog spec and reject `;flags`. Cloud providers (`openai`, `gemini`) and non-OpenAI-compat providers (`anthropic`, `ollama`, `hf`, `llamacpp`) reject `@<base_url>` with an actionable error. No authentication is added; `api_key` stays unset. Tests: `tests/test_model_string.py`, `tests/test_adhoc_model.py`, `tests/test_resolve_model.py`, `tests/test_model_client_base_url.py`, `tests/test_aio_model_client_base_url.py`.
88
- **New** **`ModelConnectionError` when an inference server is unreachable** (`aimu.models.base`, exported from `aimu.models` and `aimu.aio`). The OpenAI-compatible clients (`aimu.models.providers.openai_compat`, `aimu.aio.providers.openai_compat`; sync + async, streaming + non-streaming) now catch the OpenAI SDK's `APIConnectionError` at the `chat.completions.create` call (and during stream consumption, where a mid-stream drop can surface it) and re-raise it as `ModelConnectionError` **from** the original error, so the specific transport cause (e.g. `httpx.ConnectError: [Errno 61] Connection refused`) is preserved on the exception chain. This mirrors the existing `MCPConnectionError` / `A2AConnectionError` wrappers and lets a front end distinguish "server is down" from a generic failure instead of receiving a raw, provider-specific exception. Only `APIConnectionError` is wrapped; genuine HTTP/API errors still propagate with their own detail. Tests: `tests/test_openai_compat_connection_error.py`.
9+
- **New** **Messages are timestamped at append time** (`aimu.models._internal.chat_state._ChatStateMixin._append_message`). Every message appended to `self.messages` now carries an inert `timestamp` (ISO-8601, append time) via a single `_append_message` seam that **every** content-bearing append routes through, on **both** surfaces: the shared mixin's user-turn / system-seed / tool-call-record paths, each concrete provider's assistant-response append (sync `aimu.models.providers.{anthropic,ollama,openai_compat,llamacpp,hf.text}`, async `aimu.aio.providers.{anthropic,ollama,openai_compat}`), and the tool-loop engines' tool-result appends (`aimu.agents._tool_loop`, `aimu.aio._tool_loop`, streaming and non-streaming). The user message is stamped at request time and the assistant/tool messages when they arrive, so a consumer gets accurate per-message times without its own bookkeeping. `timestamp` was already in `INERT_MESSAGE_KEYS`, so it is still stripped from every provider request; stamping never changes the payload sent to a model. Previously only the sync `ConversationManager` set the key; the client now fills it on every path (sync and async), and `ConversationManager.update_conversation` `setdefault`s it so a client-stamped value wins. `_append_message` uses `setdefault`, so a restore replay's existing timestamps are preserved. Tests: `tests/test_message_timestamps.py`.
910
- **New** **Gemma 4 model catalog** for the OpenAI-compatible providers (`aimu.models.providers.openai_compat`). Added the full suite (`GEMMA_4_E4B`, `GEMMA_4_12B`, `GEMMA_4_26B`, `GEMMA_4_31B`) to every local-server enum (`OllamaOpenAIModel`, `LMStudioOpenAIModel`, `VLLMOpenAIModel`, `HFOpenAIModel`, `LlamaServerOpenAIModel`, `SGLangOpenAIModel`), replacing the lone `GEMMA_4_12B` entry each previously carried. Capabilities are set from Google's Gemma 4 model card: `tools=True, thinking=True, vision=True` on all four (thinking surfaces over OpenAI-compat via `<think>`-tag parsing). Provider-appropriate ids include the MoE `google/gemma-4-26B-A4B-it` and the dense `google/gemma-4-31B-it` for the HuggingFace-repo servers. `vision=True` was also backfilled onto the existing Gemma 3/4 entries. **Audio is deliberately left off** (only E4B/12B are natively audio-capable, and audio input isn't reliably exposed by these local servers); each enum carries an inline comment recording the transport-specific reason. The async providers inherit these enums, so `aimu.aio` picks up the new members automatically.
1011

1112
### Agents and workflows

CLAUDE.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ async def main():
230230

231231
**Shared infrastructure** (used by both sync and async surfaces, no duplication):
232232

233-
- **[aimu/models/_internal/chat_state.py](aimu/models/_internal/chat_state.py)**: `_ChatStateMixin` provides the I/O-free helpers shared by both bases: `system_message` lifecycle (always-live setter that swaps the in-history system entry), `reset()`, `_append_user_turn()`, `_collect_python_tool_specs()`, the capability properties (`is_thinking_model`, etc.), tool-call recording (`_prepare_tool_calls` / `_append_assistant_tool_calls` / `_record_tool_calls`), and structured-request resolution (`_structured_request`). Both `BaseModelClient` and `AsyncBaseModelClient` inherit it. None of this is duplicated across the two bases.
233+
- **[aimu/models/_internal/chat_state.py](aimu/models/_internal/chat_state.py)**: `_ChatStateMixin` provides the I/O-free helpers shared by both bases: `system_message` lifecycle (always-live setter that swaps the in-history system entry), `reset()`, `_append_message()` (the single append seam that stamps every message with an inert append-time `timestamp`; see "Message Timestamps"), `_append_user_turn()`, `_collect_python_tool_specs()`, the capability properties (`is_thinking_model`, etc.), tool-call recording (`_prepare_tool_calls` / `_append_assistant_tool_calls` / `_record_tool_calls`), and structured-request resolution (`_structured_request`). Both `BaseModelClient` and `AsyncBaseModelClient` inherit it. None of this is duplicated across the two bases.
234234
- **[aimu/models/_internal/streaming.py](aimu/models/_internal/streaming.py)**: `resolve_include()`, `filter_chunks()` (sync), `afilter_chunks()` (async).
235235
- **[aimu/models/_internal/json.py](aimu/models/_internal/json.py)**: `parse_json_response(text, schema=None)`, `generate_json(client, prompt, schema=None, *, retries=2, generate_kwargs=None)`, `extract_tool_calls(messages)`. Utilities for getting structured data out of model responses. Exported from `aimu.models` and top-level `aimu`.
236236
- **[aimu/tools/mcp_format.py](aimu/tools/mcp_format.py)**: `mcp_tools_to_openai(tool_specs)` (spec conversion) and `mcp_content_to_text(tool_response)` (flatten a `call_tool` result to a string), shared by sync `MCPClient` and async `aio.MCPClient` (`get_tools()` / `as_tools()`).
@@ -740,6 +740,10 @@ When adding new models to a client:
740740
- Message history persists across `chat()` calls on the same client instance.
741741
- Use `ConversationManager` to persist conversations across sessions.
742742

743+
### Message Timestamps
744+
745+
Every message appended to `self.messages` carries an inert `timestamp` (ISO-8601, stamped at append time). The single append seam is `_ChatStateMixin._append_message(message)` ([aimu/models/_internal/chat_state.py](aimu/models/_internal/chat_state.py)): it `setdefault`s `timestamp` (so a restore replay's existing stamps are preserved), then appends. **Every** content-bearing append routes through it, on both surfaces: the shared mixin's user-turn / system-seed / tool-call-record paths, each concrete provider's assistant-response append (sync in `aimu/models/providers/*`, async in `aimu/aio/providers/*`), and the tool-loop engines' tool-result appends (`aimu/agents/_tool_loop.py`, `aimu/aio/_tool_loop.py`, streaming and non-streaming). The user turn is stamped at request time and the assistant/tool turns when they arrive, so a consumer reading `client.messages` (a UI, `ConversationManager`, a `SessionStore`) gets accurate per-message times without its own bookkeeping. `timestamp` is in `INERT_MESSAGE_KEYS`, so `strip_inert_keys()` removes it before every provider request; stamping never changes the payload sent to a model. `ConversationManager.update_conversation` `setdefault`s the key too, so a client-stamped value wins over its own persist-time stamp. In-process providers (HuggingFace, LlamaCpp) route their sync appends through the same seam; their async wrappers delegate to the wrapped sync client.
746+
743747
### Thinking Models
744748

745749
Models with `supports_thinking=True` support extended reasoning:
@@ -750,7 +754,7 @@ Models with `supports_thinking=True` support extended reasoning:
750754

751755
### Message Provenance (agent-loop vs user input)
752756

753-
Framework-injected turns are marked with a non-standard `"provenance"` key so a replayed or persisted transcript can distinguish them from genuine user input (both are `{"role": "user", ...}` and were otherwise byte-for-byte identical). The `Agent` loop tags its `final_answer_prompt` turn `PROVENANCE_FINAL_ANSWER`; the personal-assistant example tags scheduler pushes `PROVENANCE_PROACTIVE`. (`PROVENANCE_CONTINUATION` is **legacy / no longer produced** — the agent used to inject a "continue" user turn between tool rounds but now continues via `chat()` with no user message, so nothing is injected; the constant is kept only so older persisted transcripts still parse.) Genuine user input and ordinary assistant turns are **left untagged** (absence = ordinary), so display logic is just `message.get(PROVENANCE_KEY)`. The constants and the key live in [aimu/models/_internal/message_meta.py](aimu/models/_internal/message_meta.py), re-exported from `aimu.models` and top-level `aimu` (`PROVENANCE_KEY`, `PROVENANCE_CONTINUATION`, `PROVENANCE_FINAL_ANSWER`, `PROVENANCE_PROACTIVE`). Tagging is done by index after the injecting `chat()` turn completes (the shared `_AgentLoopMixin._tag_injected_turn`, sync + async), mirroring how providers attach the `"thinking"` key by index, so the public `chat()` signature is untouched. `"provenance"` is in `INERT_MESSAGE_KEYS`, so `strip_inert_keys()` removes it (alongside `"thinking"` and the `ConversationManager` `"timestamp"`) from every OpenAI-compat and Ollama request; Anthropic/HuggingFace drop it by rebuilding the payload. The Streamlit examples hide/mute tagged turns when re-rendering history. Streaming needs no marker: the injected prompts are *input*, never emitted as chunks, so the problem is purely at the replayed-history level. Tests: `tests/test_provenance.py` (+ async mirror in `tests/test_aio_agents.py`).
757+
Framework-injected turns are marked with a non-standard `"provenance"` key so a replayed or persisted transcript can distinguish them from genuine user input (both are `{"role": "user", ...}` and were otherwise byte-for-byte identical). The `Agent` loop tags its `final_answer_prompt` turn `PROVENANCE_FINAL_ANSWER`; the personal-assistant example tags scheduler pushes `PROVENANCE_PROACTIVE`. (`PROVENANCE_CONTINUATION` is **legacy / no longer produced** — the agent used to inject a "continue" user turn between tool rounds but now continues via `chat()` with no user message, so nothing is injected; the constant is kept only so older persisted transcripts still parse.) Genuine user input and ordinary assistant turns are **left untagged** (absence = ordinary), so display logic is just `message.get(PROVENANCE_KEY)`. The constants and the key live in [aimu/models/_internal/message_meta.py](aimu/models/_internal/message_meta.py), re-exported from `aimu.models` and top-level `aimu` (`PROVENANCE_KEY`, `PROVENANCE_CONTINUATION`, `PROVENANCE_FINAL_ANSWER`, `PROVENANCE_PROACTIVE`). Tagging is done by index after the injecting `chat()` turn completes (the shared `_AgentLoopMixin._tag_injected_turn`, sync + async), mirroring how providers attach the `"thinking"` key by index, so the public `chat()` signature is untouched. `"provenance"` is in `INERT_MESSAGE_KEYS`, so `strip_inert_keys()` removes it (alongside `"thinking"` and the append-time `"timestamp"`) from every OpenAI-compat and Ollama request; Anthropic/HuggingFace drop it by rebuilding the payload. The Streamlit examples hide/mute tagged turns when re-rendering history. Streaming needs no marker: the injected prompts are *input*, never emitted as chunks, so the problem is purely at the replayed-history level. Tests: `tests/test_provenance.py` (+ async mirror in `tests/test_aio_agents.py`).
754758

755759
`supports_thinking` is the universal flag; *how* reasoning is requested is provider-specific and handled inside each client (Anthropic `thinking` param, HuggingFace `enable_thinking` template kwarg, `reasoning_effort` for OpenAI o-series). For OpenAI-compat and llama-cpp, reasoning arrives one of two ways and both are handled: a separate `reasoning_content` field on the delta/message (llama-server with the default `--reasoning-format deepseek`/`auto`, vLLM/SGLang reasoning parsers, which strip the tags server-side), **or** inline `<think>...</think>` tags in `content` (servers configured to pass tags through, e.g. `--reasoning-format none`). When `reasoning_content` is present it takes precedence and is not gated on `supports_thinking` (if the server sent it, it is reasoning); otherwise the `<think>` parser runs (`_ThinkingParser` streaming, `_split_thinking` non-streaming). Native Ollama uses its own `thinking` message field. The Anthropic client distinguishes two request shapes via a `ThinkingStyle` enum carried on each `AnthropicModel` member (analogous to HuggingFace's `ToolCallFormat`):
756760
- **`ThinkingStyle.ENABLED`**: `thinking={"type": "enabled", "budget_tokens": N}`; the model always thinks up to the budget. Opus 4.6, Sonnet 4.6, Haiku 4.5.

aimu/agents/_tool_loop.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,8 @@ def _dispatch(self) -> None:
293293
results = [f.result() for f in futures]
294294
else:
295295
results = [self._call_plain_tool(tc, tc_id) for tc, tc_id in prepared]
296-
self._client.messages.extend(results)
296+
for result_msg in results:
297+
self._client._append_message(result_msg)
297298

298299
def _dispatch_streamed(self, iteration: int) -> Iterator[StreamChunk]:
299300
from aimu.tools.decorator import ToolArgumentError
@@ -316,7 +317,7 @@ def _tool_chunk(tc: dict, response: str) -> StreamChunk:
316317
futures = [executor.submit(self._call_plain_tool, tc, tc_id) for tc, tc_id in prepared]
317318
results = [f.result() for f in futures]
318319
for (tc, _tc_id), result_msg in zip(prepared, results):
319-
self._client.messages.append(result_msg)
320+
self._client._append_message(result_msg)
320321
yield _tool_chunk(tc, result_msg["content"])
321322
return
322323

@@ -329,7 +330,7 @@ def _tool_chunk(tc: dict, response: str) -> StreamChunk:
329330
)
330331
if not self._tool_call_approved(tc["name"], tc["arguments"]):
331332
result_msg = self._not_approved(tc, tc_id)
332-
self._client.messages.append(result_msg)
333+
self._client._append_message(result_msg)
333334
yield _tool_chunk(tc, result_msg["content"])
334335
continue
335336
try:
@@ -360,7 +361,7 @@ def _tool_chunk(tc: dict, response: str) -> StreamChunk:
360361
else:
361362
result_msg = self._call_plain_tool(tc, tc_id)
362363

363-
self._client.messages.append(result_msg)
364+
self._client._append_message(result_msg)
364365
yield _tool_chunk(tc, result_msg["content"])
365366

366367
def _call_plain_tool(self, tc: dict, tc_id: str) -> dict:

aimu/aio/_tool_loop.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,8 @@ async def _dispatch(self) -> None:
148148
results = [t.result() for t in tasks]
149149
else:
150150
results = [await self._call_plain_tool(tc, tc_id) for tc, tc_id in prepared]
151-
self._client.messages.extend(results)
151+
for result_msg in results:
152+
self._client._append_message(result_msg)
152153

153154
async def _dispatch_streamed(self, iteration: int) -> AsyncIterator[StreamChunk]:
154155
from aimu.tools.decorator import ToolArgumentError
@@ -169,7 +170,7 @@ def _tool_chunk(tc: dict, response: str) -> StreamChunk:
169170
tasks = [tg.create_task(self._call_plain_tool(tc, tc_id)) for tc, tc_id in prepared]
170171
results = [t.result() for t in tasks]
171172
for (tc, _tc_id), result_msg in zip(prepared, results):
172-
self._client.messages.append(result_msg)
173+
self._client._append_message(result_msg)
173174
yield _tool_chunk(tc, result_msg["content"])
174175
return
175176

@@ -178,7 +179,7 @@ def _tool_chunk(tc: dict, response: str) -> StreamChunk:
178179
if fn is not None and getattr(fn, "__tool_is_streaming__", False):
179180
if not await self._tool_call_approved(tc["name"], tc["arguments"]):
180181
result_msg = self._not_approved(tc, tc_id)
181-
self._client.messages.append(result_msg)
182+
self._client._append_message(result_msg)
182183
yield _tool_chunk(tc, result_msg["content"])
183184
continue
184185
try:
@@ -223,7 +224,7 @@ def _next_chunk(_gen=gen):
223224
else:
224225
result_msg = await self._call_plain_tool(tc, tc_id)
225226

226-
self._client.messages.append(result_msg)
227+
self._client._append_message(result_msg)
227228
yield _tool_chunk(tc, result_msg["content"])
228229

229230
async def _call_plain_tool(self, tc: dict, tc_id: str) -> dict:

aimu/aio/providers/anthropic.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ async def _structured_call_streamed(
144144
text = json.dumps(block.input)
145145
break
146146
if append_message:
147-
self.messages.append({"role": "assistant", "content": text})
147+
self._append_message({"role": "assistant", "content": text})
148148

149149
# Pure helpers reused from sync client; no I/O involved.
150150
_update_generate_kwargs = _SyncAnthropicClient._update_generate_kwargs
@@ -247,7 +247,7 @@ async def _chat(
247247
system, ant_messages, generate_kwargs, response_format, append_message=True
248248
)
249249
text = await self._structured_call(system, ant_messages, generate_kwargs, response_format)
250-
self.messages.append({"role": "assistant", "content": text})
250+
self._append_message({"role": "assistant", "content": text})
251251
return text
252252

253253
generate_kwargs = self._thinking_kwargs(generate_kwargs)
@@ -292,7 +292,7 @@ async def _chat(
292292
assistant_msg: dict = {"role": "assistant", "content": text_content}
293293
if self.last_thinking:
294294
assistant_msg["thinking"] = self.last_thinking
295-
self.messages.append(assistant_msg)
295+
self._append_message(assistant_msg)
296296
return text_content
297297

298298
async def _chat_streamed(self, generate_kwargs: dict[str, Any], tools: list) -> AsyncIterator[StreamChunk]:
@@ -337,7 +337,7 @@ async def _chat_streamed(self, generate_kwargs: dict[str, Any], tools: list) ->
337337
assistant_msg: dict = {"role": "assistant", "content": full_content}
338338
if self.last_thinking:
339339
assistant_msg["thinking"] = self.last_thinking
340-
self.messages.append(assistant_msg)
340+
self._append_message(assistant_msg)
341341
return
342342

343343
# Single turn: parse accumulated JSON, dispatch, yield TOOL_CALLING chunks, and return.

0 commit comments

Comments
 (0)