personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

generate: thread structured messages through the chat talent path (req_h5iyql3s)

Carry conversation history as list[{"role", "content"}] from the chat talent's
pre-hook through _execute_generate into each provider SDK's native turn array.
List-of-strings callers continue unchanged - no dual runtime, no feature flag.
Google gets a new types.Content/Part mapper (assistant -> "model"). chat.md
drops $chat_stream_tail; the tail is now a first-class structured list on the
pre-hook return.

Decisions (req_h5iyql3s):
- messages key on pre-hook return dict; plain list[dict[str, str]] with role
in {"user","assistant"}
- owner_message trigger: triggering owner turn already persisted in the chat
stream; for talent_finished / talent_errored synthesize a final user turn
"[talent <name> finished: <summary>]" / "[talent <name> errored: <reason>]"
- drop $chat_stream_tail from chat.md; tests/baselines/api/sol/preview.json
regenerated to match
- drop in-tail talent_* markers from the structured history (the system prompt
$active_talents block still carries in-flight exec context)
- per-provider mapping lives in its own module; no shared/abstract messages
type; system instruction stays in each provider's native field

Test plan:
- make test-only TEST=tests/test_chat_context.py (passed)
- make test-only TEST=tests/test_anthropic.py (passed, including structured+schema)
- make test-only TEST=tests/test_openai.py (passed)
- make test-only TEST=tests/test_ollama.py (passed)
- make test-only TEST=tests/test_google.py (passed, sync+async Content/Part mapping)
- make test-only TEST=tests/test_talent_fallback.py (passed, _execute_generate regression locks)
- PYTEST_ADDOPTS="--basetemp=$(mktemp -d /tmp/pytest-qufbiyo2.XXXXXX)" make ci
(passed - isolated tempdir to avoid a concurrent-worktree
/var/tmp/pytest-of-jer collision)
- make review: browser verify 19/19 pass; API verify hit one pre-existing
drift on sol/badge-count (expected {"count": 1}, got {"count": 0}) that
reproduces on a clean tree with this diff stashed - unrelated to structured
messages, filed as separate follow-up. Manual chat-tail sandbox spot-check
was skipped since the 19/19 browser verify exercises the chat UI path.

Co-Authored-By: OpenAI Codex <codex@openai.com>

+400 -35
+21
cpo/specs/in-flight/generate-structured-message-history.md
··· 1 + ## Decision Log 2 + 3 + Request: `req_h5iyql3s` 4 + 5 + - D1 — Config key name: `messages` 6 + The chat pre-hook returns a top-level `messages` key because `_run_talent()` copies non-`template_vars` keys directly onto config, `_apply_template_vars()` does not touch them, and no existing pre-hook key collides with `messages`. 7 + 8 + - D2 — Shape: plain dict 9 + Structured history uses a plain `list[dict[str, str]]` with `role` and `content` because every provider adapter already accepts dict-based message lists or can map them locally without adding shared abstractions. 10 + 11 + - D3 — Owner-turn handling 12 + `owner_message` triggers rely on the current owner turn already being present in the chat tail, while `talent_finished` and `talent_errored` triggers synthesize a final user turn like `[talent <name> finished: <summary>]` or `[talent <name> errored: <reason>]` to keep the model input user-final and explicit. 13 + 14 + - D4 — `chat.md` reconciliation 15 + The flattened `$chat_stream_tail` prompt variable is removed from `talent/chat.md` so structured history lives only in `messages`, and the preview baseline is expected to change because it reflects the raw prompt template. 16 + 17 + - D5 — Talent-event markers in structured history 18 + Mid-tail `talent_spawned`, `talent_finished`, and `talent_errored` events are dropped from the structured message list because they are side-channel metadata that disrupt conversational role alternation; only the current finished/errored trigger is preserved via the synthesized final user turn. 19 + 20 + - D6 — Google mapper 21 + Google generate paths convert structured `{role, content}` dicts into Gemini-native `types.Content` objects, mapping `assistant` to `model`, while leaving `system_instruction` on `GenerateContentConfig` and keeping the legacy string/list-of-strings path unchanged.
-2
talent/chat.md
··· 24 24 25 25 $trigger_context 26 26 27 - $chat_stream_tail 28 - 29 27 $active_talents 30 28 31 29 $active_routines
+31 -9
talent/chat_context.py
··· 11 11 from typing import Any 12 12 13 13 from convey.chat_stream import read_chat_tail, reduce_chat_state 14 - from think.chat_formatter import format_chat 15 14 from think.utils import get_config, get_journal 16 15 17 16 logger = logging.getLogger(__name__) ··· 249 248 day = _resolve_day(context, trigger_payload) 250 249 template_vars = { 251 250 "digest_contents": "", 252 - "chat_stream_tail": "", 253 251 "active_talents": "", 254 252 "trigger_context": "", 255 253 "location": "", 256 254 "active_routines": "", 257 255 "routine_suggestion": "", 258 256 } 257 + result = {"template_vars": template_vars} 259 258 260 259 try: 261 260 template_vars["digest_contents"] = _load_digest_contents() ··· 264 263 265 264 try: 266 265 tail = read_chat_tail(day, limit=20) 267 - if tail: 268 - chunks, _meta = format_chat(tail) 269 - body = "\n\n".join( 270 - chunk["markdown"] for chunk in chunks if chunk.get("markdown") 266 + messages: list[dict[str, str]] = [] 267 + for event in tail: 268 + if event["kind"] == "owner_message": 269 + messages.append({"role": "user", "content": event["text"]}) 270 + elif event["kind"] == "sol_message": 271 + messages.append({"role": "assistant", "content": event["text"]}) 272 + 273 + if trigger_kind == "talent_finished": 274 + messages.append( 275 + { 276 + "role": "user", 277 + "content": ( 278 + f"[talent {trigger_payload['name']} finished: " 279 + f"{trigger_payload['summary']}]" 280 + ), 281 + } 282 + ) 283 + elif trigger_kind == "talent_errored": 284 + messages.append( 285 + { 286 + "role": "user", 287 + "content": ( 288 + f"[talent {trigger_payload['name']} errored: " 289 + f"{trigger_payload['reason']}]" 290 + ), 291 + } 271 292 ) 272 - if body: 273 - template_vars["chat_stream_tail"] = f"## Recent Chat\n\n{body}" 293 + 294 + if messages: 295 + result["messages"] = messages 274 296 except Exception: 275 297 logger.debug("Chat tail enrichment failed", exc_info=True) 276 298 ··· 343 365 except Exception: 344 366 logger.debug("Routine suggestion eligibility check failed", exc_info=True) 345 367 346 - return {"template_vars": template_vars} 368 + return result 347 369 348 370 349 371 def _load_digest_contents() -> str:
+1 -1
tests/baselines/api/sol/preview.json
··· 1 1 { 2 - "full_prompt": "## Instructions\n\n## Available Facets\n\n- **Capulet Industries** (`capulet`)\n Capulet Industries enterprise division\n - **Capulet Industries Entities**: Capulet Industries; Juliet Capulet; Nurse Angela; Paris Duke; Tybalt Capulet\n - **Capulet Industries Activities**: Meetings; Coding; Browsing; Email; Messaging; AI Conversation; Writing; Reading; Video; Gaming; Social Media; Planning; Productivity; Terminal; Design; Music\n\n- **Empty Entities Test** (`empty-entities`)\n - **Empty Entities Test Activities**: Meetings; Coding; Browsing; Email; Messaging; AI Conversation; Writing; Reading; Video; Gaming; Social Media; Planning; Productivity; Terminal; Design; Music\n\n- **Full Featured Facet** (`full-featured`)\n A facet for testing all features\n - **Full Featured Facet Entities**: First test entity; Second test entity; Third test entity with description\n - **Full Featured Facet Activities**: Meetings; Coding; Custom Activity; Email; Messaging\n\n- **Minimal Facet** (`minimal-facet`)\n - **Minimal Facet Activities**: Meetings; Coding; Browsing; Email; Messaging; AI Conversation; Writing; Reading; Video; Gaming; Social Media; Planning; Productivity; Terminal; Design; Music\n\n- **Montague Tech** (`montague`)\n Montague Tech startup operations\n - **Tester's Role**: CTO and co-founder of Montague Tech. Visionary full-stack engineer.\n - **Montague Tech Entities**: Balcony App; Balthasar Davi; Benvolio Montague; Friar Lawrence; Juliet Capulet; Mercutio Escalus; Mesh Routing; Montague Tech; Prince Escalus; Rosaline Prince; Schema Bridge; Verona Platform; Verona Ventures\n - **Montague Tech Activities**: Engineering; Meetings; Email; Messaging\n\n- **Priority Test** (`priority-test`)\n - **Priority Test Activities**: Meetings; Coding; Browsing; Email; Messaging; AI Conversation; Writing; Reading; Video; Gaming; Social Media; Planning; Productivity; Terminal; Design; Music\n\n- **Test Facet** (`test-facet`)\n A test facet for validating functionality\n - **Test Facet Entities**: Acme Corp; API Optimization; Bob Wilson; Dashboard Redesign; Docker; Jane Doe; John Smith; PostgreSQL; Tech Solutions Inc; Visual Studio Code\n - **Test Facet Activities**: Meetings; Coding; Browsing; Email; Messaging; AI Conversation; Writing; Reading; Video; Gaming; Social Media; Planning; Productivity; Terminal; Design; Music\n\n- **Verona** (`verona`)\n Cross-company Verona Platform collaboration\n - **Tester's Role**: Co-lead of the Verona Platform joint venture from Montague Tech.\n - **Verona Entities**: Balcony App; Friar Lawrence; Juliet Capulet; Verona Platform\n - **Verona Activities**: Engineering; Meetings; Design Review; Email; Messaging\n\n## Identity Frame\n\nYou are sol, responding to Tester inside the chat backend. You are not the research worker and you do not have tools in this step. Work only from the context already provided to you.\n\n## Current Digest\n\n$digest_contents\n\n$location\n\n$trigger_context\n\n$chat_stream_tail\n\n$active_talents\n\n$active_routines\n\n$routine_suggestion\n\n## Tonal Range\n\nMatch the owner's tone and stakes:\n- Be direct and brief for simple replies.\n- Be warm when the owner is sharing something difficult or personal.\n- Be analytical when the owner needs synthesis or a plan.\n- Be challenging only when there is a clear pattern worth naming.\n\n## Routine Etiquette\n\n- If a routine suggestion appears in context, mention it once and only at the end.\n- Do not raise routine suggestions on machine-driven follow-ups unless the context explicitly includes one.\n- Do not mention internal systems, hooks, or prompt assembly.\n\n## Import And Naming Awareness\n\n- If the owner is asking about imports, naming, or system readiness, answer plainly from the supplied context.\n- Request exec only when answering well requires deeper lookup, synthesis, or tool use.\n\n## When To Dispatch Exec\n\nSet `talent_request` only when the owner needs work that cannot be answered well from the supplied digest, chat history, active routines, and trigger context alone.\n\nDispatch exec for:\n- Journal exploration across days, entities, or transcripts\n- Multi-step synthesis or research\n- Meeting prep that needs fresh participant or activity lookup\n- Any request that clearly needs tool use or external state inspection\n\nDo not dispatch exec for:\n- Simple acknowledgements\n- Straightforward follow-up chat\n- Routine suggestions already supported by the supplied context\n- Brief guidance that can be answered from the current digest and chat tail\n\n## JSON Contract\n\nReturn exactly one JSON object matching `chat.schema.json`.\n\n- `message`: The owner-facing reply. Use `null` only when you genuinely have no safe or useful message to send.\n- `notes`: Brief internal summary of why you responded this way. Keep it factual and concise. Do not dump long reasoning.\n- `talent_request`: `null` unless exec should be dispatched. When dispatching, include:\n - `task`: the exact work exec should perform\n - `context`: optional structured hints that will help exec start fast\n\n## Output Rules\n\n- Return JSON only.\n- `message` should stand on its own without referring to hidden machinery.\n- If `talent_request` is present, the `message` should still be useful to the owner right now.\n- Prefer no dispatch over a weak or redundant dispatch.", 2 + "full_prompt": "## Instructions\n\n## Available Facets\n\n- **Capulet Industries** (`capulet`)\n Capulet Industries enterprise division\n - **Capulet Industries Entities**: Capulet Industries; Juliet Capulet; Nurse Angela; Paris Duke; Tybalt Capulet\n - **Capulet Industries Activities**: Meetings; Coding; Browsing; Email; Messaging; AI Conversation; Writing; Reading; Video; Gaming; Social Media; Planning; Productivity; Terminal; Design; Music\n\n- **Empty Entities Test** (`empty-entities`)\n - **Empty Entities Test Activities**: Meetings; Coding; Browsing; Email; Messaging; AI Conversation; Writing; Reading; Video; Gaming; Social Media; Planning; Productivity; Terminal; Design; Music\n\n- **Full Featured Facet** (`full-featured`)\n A facet for testing all features\n - **Full Featured Facet Entities**: First test entity; Second test entity; Third test entity with description\n - **Full Featured Facet Activities**: Meetings; Coding; Custom Activity; Email; Messaging\n\n- **Minimal Facet** (`minimal-facet`)\n - **Minimal Facet Activities**: Meetings; Coding; Browsing; Email; Messaging; AI Conversation; Writing; Reading; Video; Gaming; Social Media; Planning; Productivity; Terminal; Design; Music\n\n- **Montague Tech** (`montague`)\n Montague Tech startup operations\n - **Tester's Role**: CTO and co-founder of Montague Tech. Visionary full-stack engineer.\n - **Montague Tech Entities**: Balcony App; Balthasar Davi; Benvolio Montague; Friar Lawrence; Juliet Capulet; Mercutio Escalus; Mesh Routing; Montague Tech; Prince Escalus; Rosaline Prince; Schema Bridge; Verona Platform; Verona Ventures\n - **Montague Tech Activities**: Engineering; Meetings; Email; Messaging\n\n- **Priority Test** (`priority-test`)\n - **Priority Test Activities**: Meetings; Coding; Browsing; Email; Messaging; AI Conversation; Writing; Reading; Video; Gaming; Social Media; Planning; Productivity; Terminal; Design; Music\n\n- **Test Facet** (`test-facet`)\n A test facet for validating functionality\n - **Test Facet Entities**: Acme Corp; API Optimization; Bob Wilson; Dashboard Redesign; Docker; Jane Doe; John Smith; PostgreSQL; Tech Solutions Inc; Visual Studio Code\n - **Test Facet Activities**: Meetings; Coding; Browsing; Email; Messaging; AI Conversation; Writing; Reading; Video; Gaming; Social Media; Planning; Productivity; Terminal; Design; Music\n\n- **Verona** (`verona`)\n Cross-company Verona Platform collaboration\n - **Tester's Role**: Co-lead of the Verona Platform joint venture from Montague Tech.\n - **Verona Entities**: Balcony App; Friar Lawrence; Juliet Capulet; Verona Platform\n - **Verona Activities**: Engineering; Meetings; Design Review; Email; Messaging\n\n## Identity Frame\n\nYou are sol, responding to Tester inside the chat backend. You are not the research worker and you do not have tools in this step. Work only from the context already provided to you.\n\n## Current Digest\n\n$digest_contents\n\n$location\n\n$trigger_context\n\n$active_talents\n\n$active_routines\n\n$routine_suggestion\n\n## Tonal Range\n\nMatch the owner's tone and stakes:\n- Be direct and brief for simple replies.\n- Be warm when the owner is sharing something difficult or personal.\n- Be analytical when the owner needs synthesis or a plan.\n- Be challenging only when there is a clear pattern worth naming.\n\n## Routine Etiquette\n\n- If a routine suggestion appears in context, mention it once and only at the end.\n- Do not raise routine suggestions on machine-driven follow-ups unless the context explicitly includes one.\n- Do not mention internal systems, hooks, or prompt assembly.\n\n## Import And Naming Awareness\n\n- If the owner is asking about imports, naming, or system readiness, answer plainly from the supplied context.\n- Request exec only when answering well requires deeper lookup, synthesis, or tool use.\n\n## When To Dispatch Exec\n\nSet `talent_request` only when the owner needs work that cannot be answered well from the supplied digest, chat history, active routines, and trigger context alone.\n\nDispatch exec for:\n- Journal exploration across days, entities, or transcripts\n- Multi-step synthesis or research\n- Meeting prep that needs fresh participant or activity lookup\n- Any request that clearly needs tool use or external state inspection\n\nDo not dispatch exec for:\n- Simple acknowledgements\n- Straightforward follow-up chat\n- Routine suggestions already supported by the supplied context\n- Brief guidance that can be answered from the current digest and chat tail\n\n## JSON Contract\n\nReturn exactly one JSON object matching `chat.schema.json`.\n\n- `message`: The owner-facing reply. Use `null` only when you genuinely have no safe or useful message to send.\n- `notes`: Brief internal summary of why you responded this way. Keep it factual and concise. Do not dump long reasoning.\n- `talent_request`: `null` unless exec should be dispatched. When dispatching, include:\n - `task`: the exact work exec should perform\n - `context`: optional structured hints that will help exec start fast\n\n## Output Rules\n\n- Return JSON only.\n- `message` should stand on its own without referring to hidden machinery.\n- If `talent_request` is present, the `message` should still be useful to the owner right now.\n- Prefer no dispatch over a weak or redundant dispatch.", 3 3 "multi_facet": false, 4 4 "name": "chat", 5 5 "title": "Chat"
+54
tests/test_anthropic.py
··· 429 429 430 430 431 431 class TestRunGenerateJsonSchema: 432 + def test_structured_messages_passthrough(self, monkeypatch): 433 + provider = importlib.reload( 434 + importlib.import_module("think.providers.anthropic") 435 + ) 436 + mock_client = MagicMock() 437 + mock_response = MagicMock() 438 + mock_response.content = [SimpleNamespace(type="text", text="ok")] 439 + mock_response.usage = None 440 + mock_response.stop_reason = "end_turn" 441 + mock_client.messages.create.return_value = mock_response 442 + monkeypatch.setattr(provider, "_get_anthropic_client", lambda: mock_client) 443 + messages = [ 444 + {"role": "user", "content": "first"}, 445 + {"role": "assistant", "content": "second"}, 446 + {"role": "user", "content": "third"}, 447 + ] 448 + 449 + provider.run_generate(messages, system_instruction="base") 450 + 451 + call_kwargs = mock_client.messages.create.call_args.kwargs 452 + assert call_kwargs["messages"] == messages 453 + assert call_kwargs["system"] == "base" 454 + 432 455 def test_no_schema_keeps_prompt_append(self, monkeypatch): 433 456 provider = importlib.reload( 434 457 importlib.import_module("think.providers.anthropic") ··· 472 495 ) 473 496 474 497 call_kwargs = mock_client.messages.create.call_args.kwargs 498 + assert call_kwargs["output_config"] == { 499 + "format": {"type": "json_schema", "schema": schema} 500 + } 501 + assert call_kwargs["system"] == "base" 502 + 503 + def test_structured_messages_with_schema_uses_output_config(self, monkeypatch): 504 + provider = importlib.reload( 505 + importlib.import_module("think.providers.anthropic") 506 + ) 507 + mock_client = MagicMock() 508 + mock_response = MagicMock() 509 + mock_response.content = [SimpleNamespace(type="text", text="{}")] 510 + mock_response.usage = None 511 + mock_response.stop_reason = "end_turn" 512 + mock_client.messages.create.return_value = mock_response 513 + monkeypatch.setattr(provider, "_get_anthropic_client", lambda: mock_client) 514 + schema = {"type": "object"} 515 + messages = [ 516 + {"role": "user", "content": "first"}, 517 + {"role": "assistant", "content": "second"}, 518 + {"role": "user", "content": "third"}, 519 + ] 520 + 521 + provider.run_generate( 522 + messages, 523 + system_instruction="base", 524 + json_schema=schema, 525 + ) 526 + 527 + call_kwargs = mock_client.messages.create.call_args.kwargs 528 + assert call_kwargs["messages"] == messages 475 529 assert call_kwargs["output_config"] == { 476 530 "format": {"type": "json_schema", "schema": schema} 477 531 }
+62 -12
tests/test_chat_context.py
··· 12 12 13 13 TEMPLATE_VAR_KEYS = { 14 14 "digest_contents", 15 - "chat_stream_tail", 16 15 "active_talents", 17 16 "trigger_context", 18 17 "location", ··· 146 145 147 146 template_vars = _assert_template_vars_result(result) 148 147 assert template_vars["digest_contents"] == "Digest notes for today." 149 - assert "## Recent Chat" in template_vars["chat_stream_tail"] 150 - assert ( 151 - "**Alice** Please brief me for my meeting" in template_vars["chat_stream_tail"] 152 - ) 153 - assert "**Sol-agent** I can help with that." in template_vars["chat_stream_tail"] 154 - assert ( 155 - "*[exec spawned: Prepare the meeting brief]*" 156 - in template_vars["chat_stream_tail"] 157 - ) 148 + assert result["messages"] == [ 149 + {"role": "user", "content": "Please brief me for my meeting"}, 150 + {"role": "assistant", "content": "I can help with that."}, 151 + ] 152 + assert all("exec spawned" not in msg["content"] for msg in result["messages"]) 158 153 assert "## Active Execs" in template_vars["active_talents"] 159 154 assert "Prepare the meeting brief" in template_vars["active_talents"] 160 155 assert "## Trigger Context" in template_vars["trigger_context"] ··· 216 211 assert len(save_calls) == 1 217 212 218 213 214 + def test_chat_context_talent_finished_appends_final_user_message(monkeypatch, tmp_path): 215 + journal = tmp_path / "journal" 216 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) 217 + 218 + append_chat_event( 219 + "owner_message", 220 + ts=_ts(10, 0), 221 + text="What happened?", 222 + app="home", 223 + path="/app/home", 224 + facet="work", 225 + ) 226 + append_chat_event( 227 + "sol_message", 228 + ts=_ts(10, 1), 229 + use_id="use-chat-2", 230 + text="Looking into it.", 231 + notes="Acknowledged request.", 232 + requested_exec=False, 233 + requested_task=None, 234 + ) 235 + append_chat_event( 236 + "talent_finished", 237 + ts=_ts(10, 2), 238 + use_id="use-exec-2", 239 + name="exec", 240 + summary="Found the latest notes.", 241 + ) 242 + 243 + monkeypatch.setattr("think.routines.get_routine_state", lambda: []) 244 + monkeypatch.setattr( 245 + "think.routines.get_config", 246 + lambda: {"_meta": {"suggestions_enabled": False, "suggestions": {}}}, 247 + ) 248 + monkeypatch.setattr("think.routines.save_config", lambda config: None) 249 + 250 + result = _load_chat_context_module().pre_process( 251 + { 252 + "day": "20260420", 253 + "trigger_kind": "talent_finished", 254 + "trigger_payload": { 255 + "name": "exec", 256 + "summary": "Found the latest notes.", 257 + }, 258 + } 259 + ) 260 + 261 + _assert_template_vars_result(result) 262 + assert result["messages"] == [ 263 + {"role": "user", "content": "What happened?"}, 264 + {"role": "assistant", "content": "Looking into it."}, 265 + {"role": "user", "content": "[talent exec finished: Found the latest notes.]"}, 266 + ] 267 + 268 + 219 269 def test_chat_context_preserves_save_routines_config_side_effect(monkeypatch, tmp_path): 220 270 journal = tmp_path / "journal" 221 271 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) ··· 260 310 261 311 template_vars = _assert_template_vars_result(result) 262 312 assert template_vars["active_routines"] == "" 263 - assert template_vars["chat_stream_tail"] == "" 264 313 assert template_vars["active_talents"] == "" 314 + assert "messages" not in result 265 315 266 316 267 317 def test_chat_context_enrichment_errors_are_graceful(monkeypatch, tmp_path): ··· 294 344 295 345 template_vars = _assert_template_vars_result(result) 296 346 assert template_vars["digest_contents"] == "" 297 - assert template_vars["chat_stream_tail"] == "" 298 347 assert template_vars["active_talents"] == "" 299 348 assert template_vars["active_routines"] == "" 300 349 assert template_vars["routine_suggestion"] == "" 301 350 assert "Type: owner_message" in template_vars["trigger_context"] 302 351 assert "/app/home" in template_vars["location"] 352 + assert "messages" not in result 303 353 304 354 305 355 def test_chat_context_drops_legacy_memory_imports(monkeypatch):
+61
tests/test_google.py
··· 67 67 return process 68 68 69 69 70 + def _assert_structured_contents(contents): 71 + assert [content.role for content in contents] == ["user", "model", "user"] 72 + assert [[part.text for part in content.parts] for content in contents] == [ 73 + ["first"], 74 + ["second"], 75 + ["third"], 76 + ] 77 + 78 + 70 79 def test_google_main(monkeypatch, tmp_path, capsys): 71 80 setup_google_genai_stub(monkeypatch, with_thinking=False) 72 81 sys.modules.pop("think.providers.google", None) ··· 262 271 263 272 264 273 class TestRunGenerateJsonSchema: 274 + def test_structured_messages_sync_mapped_to_google_contents(self, monkeypatch): 275 + setup_google_genai_stub(monkeypatch, with_thinking=False) 276 + sys.modules.pop("think.providers.google", None) 277 + provider = importlib.reload(importlib.import_module("think.providers.google")) 278 + 279 + mock_client = MagicMock() 280 + mock_client.models.generate_content.return_value = SimpleNamespace( 281 + text="[]", 282 + candidates=[], 283 + usage_metadata=None, 284 + ) 285 + monkeypatch.setattr( 286 + provider, "get_or_create_client", lambda _client=None: mock_client 287 + ) 288 + messages = [ 289 + {"role": "user", "content": "first"}, 290 + {"role": "assistant", "content": "second"}, 291 + {"role": "user", "content": "third"}, 292 + ] 293 + 294 + provider.run_generate(messages, model=GEMINI_FLASH) 295 + 296 + contents = mock_client.models.generate_content.call_args.kwargs["contents"] 297 + _assert_structured_contents(contents) 298 + 299 + def test_structured_messages_async_mapped_to_google_contents(self, monkeypatch): 300 + setup_google_genai_stub(monkeypatch, with_thinking=False) 301 + sys.modules.pop("think.providers.google", None) 302 + provider = importlib.reload(importlib.import_module("think.providers.google")) 303 + 304 + mock_client = MagicMock() 305 + mock_client.aio.models.generate_content = AsyncMock( 306 + return_value=SimpleNamespace( 307 + text="[]", 308 + candidates=[], 309 + usage_metadata=None, 310 + ) 311 + ) 312 + monkeypatch.setattr( 313 + provider, "get_or_create_client", lambda _client=None: mock_client 314 + ) 315 + messages = [ 316 + {"role": "user", "content": "first"}, 317 + {"role": "assistant", "content": "second"}, 318 + {"role": "user", "content": "third"}, 319 + ] 320 + 321 + asyncio.run(provider.run_agenerate(messages, model=GEMINI_FLASH)) 322 + 323 + contents = mock_client.aio.models.generate_content.call_args.kwargs["contents"] 324 + _assert_structured_contents(contents) 325 + 265 326 def test_no_schema_kwargs_unchanged(self, monkeypatch): 266 327 setup_google_genai_stub(monkeypatch, with_thinking=False) 267 328 sys.modules.pop("think.providers.google", None)
+29
tests/test_ollama.py
··· 476 476 assert messages[0] == {"role": "system", "content": "be concise"} 477 477 assert messages[1] == {"role": "user", "content": "hello"} 478 478 479 + def test_structured_messages_body(self): 480 + provider = _ollama_provider() 481 + mock_response = MagicMock() 482 + mock_response.json.return_value = _make_ollama_response() 483 + mock_response.raise_for_status = MagicMock() 484 + input_messages = [ 485 + {"role": "user", "content": "first"}, 486 + {"role": "assistant", "content": "second"}, 487 + {"role": "user", "content": "third"}, 488 + ] 489 + 490 + with patch.object(provider, "_get_client") as mock_get: 491 + mock_client = MagicMock() 492 + mock_client.post.return_value = mock_response 493 + mock_get.return_value = mock_client 494 + 495 + provider.run_generate( 496 + input_messages, 497 + model=OLLAMA_FLASH, 498 + system_instruction="be concise", 499 + ) 500 + 501 + call_kwargs = mock_client.post.call_args 502 + body = call_kwargs.kwargs["json"] 503 + assert body["messages"] == [ 504 + {"role": "system", "content": "be concise"}, 505 + *input_messages, 506 + ] 507 + 479 508 def test_timeout(self): 480 509 provider = _ollama_provider() 481 510 mock_response = MagicMock()
+26
tests/test_openai.py
··· 685 685 "total_tokens": 15, 686 686 } 687 687 688 + def test_structured_messages_passthrough(self): 689 + provider = _openai_provider() 690 + mock_client = MagicMock() 691 + mock_client.responses.create = MagicMock() 692 + mock_response = MagicMock() 693 + mock_response.output_text = "Hello world" 694 + mock_response.status = "completed" 695 + mock_response.incomplete_details = None 696 + mock_response.usage = None 697 + mock_response.output = [] 698 + mock_client.responses.create.return_value = mock_response 699 + messages = [ 700 + {"role": "user", "content": "first"}, 701 + {"role": "assistant", "content": "second"}, 702 + {"role": "user", "content": "third"}, 703 + ] 704 + 705 + with patch( 706 + "think.providers.openai._get_openai_client", return_value=mock_client 707 + ): 708 + provider.run_generate(messages, system_instruction="Be helpful") 709 + 710 + called_kwargs = mock_client.responses.create.call_args.kwargs 711 + assert called_kwargs["input"] == messages 712 + assert called_kwargs["instructions"] == "Be helpful" 713 + 688 714 def test_with_effort_suffix(self): 689 715 provider = _openai_provider() 690 716 mock_client = MagicMock()
+64
tests/test_talent_fallback.py
··· 266 266 assert seen["context"] == "talent.system.default" 267 267 268 268 269 + def test_execute_generate_uses_messages_when_present(monkeypatch): 270 + from think.talents import _execute_generate 271 + 272 + events = [] 273 + seen = {} 274 + messages = [ 275 + {"role": "user", "content": "first"}, 276 + {"role": "assistant", "content": "second"}, 277 + {"role": "user", "content": "third"}, 278 + ] 279 + 280 + def mock_generate_with_result(**kwargs): 281 + seen["contents"] = kwargs["contents"] 282 + return {"text": "ok", "usage": {"input_tokens": 1, "output_tokens": 1}} 283 + 284 + monkeypatch.setattr( 285 + "think.talent.key_to_context", lambda _name: "talent.system.default" 286 + ) 287 + monkeypatch.setattr("think.models.generate_with_result", mock_generate_with_result) 288 + 289 + config = { 290 + "name": "chat", 291 + "messages": messages, 292 + "transcript": "ignored transcript", 293 + "user_instruction": "ignored instruction", 294 + "prompt": "ignored prompt", 295 + "health_stale": False, 296 + } 297 + 298 + asyncio.run(_execute_generate(config, events.append)) 299 + 300 + assert seen["contents"] == messages 301 + assert events[-1]["event"] == "finish" 302 + 303 + 304 + def test_execute_generate_preserves_string_contents_order(monkeypatch): 305 + from think.talents import _execute_generate 306 + 307 + events = [] 308 + seen = {} 309 + 310 + def mock_generate_with_result(**kwargs): 311 + seen["contents"] = kwargs["contents"] 312 + return {"text": "ok", "usage": {"input_tokens": 1, "output_tokens": 1}} 313 + 314 + monkeypatch.setattr( 315 + "think.talent.key_to_context", lambda _name: "talent.system.default" 316 + ) 317 + monkeypatch.setattr("think.models.generate_with_result", mock_generate_with_result) 318 + 319 + config = { 320 + "name": "chat", 321 + "transcript": "transcript", 322 + "user_instruction": "instruction", 323 + "prompt": "prompt", 324 + "health_stale": False, 325 + } 326 + 327 + asyncio.run(_execute_generate(config, events.append)) 328 + 329 + assert seen["contents"] == ["transcript", "instruction", "prompt"] 330 + assert events[-1]["event"] == "finish" 331 + 332 + 269 333 def test_on_failure_retry_generate(monkeypatch): 270 334 from think.talents import _execute_generate 271 335
+36
think/providers/google.py
··· 65 65 _detected_backend: str | None = None 66 66 67 67 68 + def _structured_to_google_contents( 69 + messages: list[dict[str, str]], 70 + ) -> list[types.Content]: 71 + """Map role/content dicts to Gemini-native Content objects.""" 72 + mapped: list[types.Content] = [] 73 + for msg in messages: 74 + role = msg["role"] 75 + if role == "user": 76 + google_role = "user" 77 + elif role == "assistant": 78 + google_role = "model" 79 + else: 80 + raise ValueError(f"Unknown message role: {role!r}") 81 + mapped.append( 82 + types.Content( 83 + role=google_role, 84 + parts=[types.Part(text=msg["content"])], 85 + ) 86 + ) 87 + return mapped 88 + 89 + 68 90 # --------------------------------------------------------------------------- 69 91 # Client and helper functions for generate/agenerate 70 92 # --------------------------------------------------------------------------- ··· 473 495 client = get_or_create_client(client) 474 496 if isinstance(contents, str): 475 497 contents = [contents] 498 + elif ( 499 + isinstance(contents, list) 500 + and contents 501 + and isinstance(contents[0], dict) 502 + and "role" in contents[0] 503 + ): 504 + contents = _structured_to_google_contents(contents) 476 505 config = _build_generate_config( 477 506 temperature=temperature, 478 507 max_output_tokens=max_output_tokens, ··· 519 548 client = get_or_create_client(client) 520 549 if isinstance(contents, str): 521 550 contents = [contents] 551 + elif ( 552 + isinstance(contents, list) 553 + and contents 554 + and isinstance(contents[0], dict) 555 + and "role" in contents[0] 556 + ): 557 + contents = _structured_to_google_contents(contents) 522 558 config = _build_generate_config( 523 559 temperature=temperature, 524 560 max_output_tokens=max_output_tokens,
+15 -11
think/talents.py
··· 929 929 from think.talent import key_to_context 930 930 931 931 name = config["name"] 932 + messages = config.get("messages") 932 933 transcript = config.get("transcript", "") 933 934 user_instruction = config.get("user_instruction", "") 934 935 prompt = config.get("prompt", "") ··· 947 948 480, max(120, (max_output_tokens + thinking_budget) // 100) 948 949 ) 949 950 950 - # Build contents: transcript + instruction + prompt 951 - contents = [] 952 - if transcript: 953 - contents.append(transcript) 954 - if user_instruction: 955 - contents.append(user_instruction) 956 - if prompt: 957 - contents.append(prompt) 951 + if messages and isinstance(messages, list): 952 + contents = messages 953 + else: 954 + # Build contents: transcript + instruction + prompt 955 + contents = [] 956 + if transcript: 957 + contents.append(transcript) 958 + if user_instruction: 959 + contents.append(user_instruction) 960 + if prompt: 961 + contents.append(prompt) 958 962 959 - # Fallback if no contents 960 - if not contents: 961 - contents = ["No input provided."] 963 + # Fallback if no contents 964 + if not contents: 965 + contents = ["No input provided."] 962 966 963 967 context = key_to_context(name) 964 968 try: