personal memory agent
0
fork

Configure Feed

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

Simplify agents.py architecture with unified config dict flow

Remove typed structures (InputContext, AgentConfig, HookContext,
PreHookContext) in favor of passing config dicts directly through
the system. Providers now use config.get() instead of extracting
into dataclasses. Unify generate and agent/tools branches into
single _run_agent() function.

- Delete dataclasses and TypedDicts from agents.py and shared.py
- Update all three providers to access config directly
- Fix tests to match new architecture
- Update documentation to reflect config dict approach

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+523 -1394
+2 -2
docs/APPS.md
··· 308 308 - Resolution: `"name"` → `muse/{name}.py`, `"app:name"` → `apps/{app}/muse/{name}.py`, or explicit path 309 309 310 310 **Pre-hooks** (`pre_process`): Modify inputs before the LLM call 311 - - `context` is a `PreHookContext` with: `name`, `agent_id`, `provider`, `model`, `prompt`, `system_instruction`, `user_instruction`, `extra_context`, `output_format`, `meta`, and for generators: `day`, `segment`, `span`, `transcript`, `output_path` 311 + - `context` is the full config dict with: `name`, `agent_id`, `provider`, `model`, `prompt`, `system_instruction`, `user_instruction`, `extra_context`, `output`, `meta`, and for generators: `day`, `segment`, `span`, `span_mode`, `transcript`, `output_path` 312 312 - Return a dict of modified fields to merge back (e.g., `{"prompt": "modified"}`) 313 313 - Return `None` for no changes 314 314 315 315 **Post-hooks** (`post_process`): Transform output after the LLM call 316 316 - `result` is the LLM output (markdown or JSON string) 317 - - `context` is a `HookContext` with: `name`, `agent_id`, `provider`, `model`, `prompt`, `output_format`, `meta`, and for generators: `day`, `segment`, `span`, `transcript`, `output_path` 317 + - `context` is the full config dict with: `name`, `agent_id`, `provider`, `model`, `prompt`, `output`, `meta`, and for generators: `day`, `segment`, `span`, `span_mode`, `transcript`, `output_path` 318 318 - Return modified string, or `None` to use original result 319 319 320 320 Hook errors are logged but don't crash the pipeline (graceful degradation).
+2 -2
docs/PROVIDERS.md
··· 131 131 132 132 **Event emission:** 133 133 134 - Providers must emit events via the `on_event` callback. See `think/agents.py` for TypedDict definitions: 134 + Providers must emit events via the `on_event` callback. See `think/providers/shared.py` for TypedDict definitions: 135 135 136 136 | Event | When | 137 137 |-------|------| ··· 142 142 | `FinishEvent` | Agent run completes successfully | 143 143 | `ErrorEvent` | Error occurs | 144 144 145 - Use `JSONEventCallback` from `think/agents.py` to wrap the callback and auto-add timestamps. 145 + Use `JSONEventCallback` from `think/providers/shared.py` to wrap the callback and auto-add timestamps. 146 146 147 147 **Finish event format:** 148 148
+2 -2
muse/anticipation.py
··· 31 31 32 32 Args: 33 33 result: The generated output markdown content. 34 - context: HookContext with keys including day, segment, name, 35 - output_path, meta, transcript, span. 34 + context: Config dict with keys including day, segment, name, 35 + output_path, meta, transcript, span, span_mode. 36 36 37 37 Returns: 38 38 None - this hook does not modify the output result.
+2 -2
muse/occurrence.py
··· 31 31 32 32 Args: 33 33 result: The generated output markdown content. 34 - context: HookContext with keys including day, segment, name, 35 - output_path, meta, transcript, span. 34 + context: Config dict with keys including day, segment, name, 35 + output_path, meta, transcript, span, span_mode. 36 36 37 37 Returns: 38 38 None - this hook does not modify the output result.
+6 -4
tests/test_agents_ndjson.py
··· 122 122 123 123 start_event = events[0] 124 124 assert start_event["event"] == "start" 125 - assert start_event["prompt"] == "What is 2+2?" 125 + # Prompt includes system instruction prepended during enrichment 126 + assert "What is 2+2?" in start_event["prompt"] 126 127 assert start_event["provider"] == "openai" 127 128 assert start_event["model"] == GPT_5 128 129 ··· 179 180 start_events = [e for e in events if e["event"] == "start"] 180 181 181 182 assert len(start_events) == 3 182 - assert start_events[0]["prompt"] == "First question" 183 - assert start_events[1]["prompt"] == "Second question" 183 + # Prompts include system instruction prepended during enrichment 184 + assert "First question" in start_events[0]["prompt"] 185 + assert "Second question" in start_events[1]["prompt"] 184 186 assert start_events[1]["provider"] == "anthropic" 185 - assert start_events[2]["prompt"] == "Third question" 187 + assert "Third question" in start_events[2]["prompt"] 186 188 assert start_events[2]["name"] == "technical" 187 189 188 190
+4 -2
tests/test_anthropic.py
··· 186 186 events = [json.loads(line) for line in out_lines] 187 187 assert events[0]["event"] == "start" 188 188 assert isinstance(events[0]["ts"], int) 189 - assert events[0]["prompt"] == "hello" 189 + # Prompt includes system instruction prepended during enrichment 190 + assert "hello" in events[0]["prompt"] 190 191 assert events[0]["name"] == "default" 191 192 assert events[0]["model"] == CLAUDE_SONNET_4 192 193 assert events[-1]["event"] == "finish" ··· 230 231 events = [json.loads(line) for line in out_lines] 231 232 assert events[0]["event"] == "start" 232 233 assert isinstance(events[0]["ts"], int) 233 - assert events[0]["prompt"] == "hello" 234 + # Prompt includes system instruction prepended during enrichment 235 + assert "hello" in events[0]["prompt"] 234 236 assert events[0]["name"] == "default" 235 237 assert events[0]["model"] == CLAUDE_SONNET_4 236 238 assert events[-1]["event"] == "finish"
-2
tests/test_batch.py
··· 465 465 # Verify client was passed through 466 466 call_kwargs = mock_agenerate.call_args[1] 467 467 assert call_kwargs["client"] is mock_client 468 - 469 -
+22 -18
tests/test_generate_full.py
··· 81 81 ) 82 82 83 83 try: 84 + # Mock the underlying generation function in think.models 85 + import think.models 86 + 84 87 monkeypatch.setattr( 85 - mod, 86 - "generate_agent_output", 87 - lambda *a, **k: ( 88 - MOCK_RESULT if k.get("return_result") else MOCK_RESULT["text"] 89 - ), 88 + think.models, 89 + "generate_with_result", 90 + lambda *a, **k: MOCK_RESULT, 90 91 ) 91 92 monkeypatch.setenv("GOOGLE_API_KEY", "x") 92 93 monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) ··· 134 135 ctx_copy = { 135 136 "day": context.get("day"), 136 137 "segment": context.get("segment"), 137 - "span": context.get("span"), 138 + "span": context.get("span_mode"), 138 139 "name": context.get("name"), 139 140 "has_transcript": bool(context.get("transcript")), 140 141 "has_meta": bool(context.get("meta")), ··· 151 152 ) 152 153 153 154 try: 155 + # Mock the underlying generation function in think.models 156 + import think.models 157 + 154 158 monkeypatch.setattr( 155 - mod, 156 - "generate_agent_output", 157 - lambda *a, **k: ( 158 - MOCK_RESULT if k.get("return_result") else MOCK_RESULT["text"] 159 - ), 159 + think.models, 160 + "generate_with_result", 161 + lambda *a, **k: MOCK_RESULT, 160 162 ) 161 163 monkeypatch.setenv("GOOGLE_API_KEY", "x") 162 164 monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) ··· 181 183 182 184 assert captured["day"] == "20240101" 183 185 assert captured["segment"] is None 186 + # span_mode is a bool in the new config structure 184 187 assert captured["span"] is False 185 188 assert captured["name"] == "hooked_gen" 186 189 assert captured["has_transcript"] is True ··· 207 210 ) 208 211 209 212 try: 213 + # Mock the underlying generation function in think.models 214 + import think.models 215 + 210 216 monkeypatch.setattr( 211 - mod, 212 - "generate_agent_output", 213 - lambda *a, **k: ( 214 - MOCK_RESULT if k.get("return_result") else MOCK_RESULT["text"] 215 - ), 217 + think.models, 218 + "generate_with_result", 219 + lambda *a, **k: MOCK_RESULT, 216 220 ) 217 221 monkeypatch.setenv("GOOGLE_API_KEY", "x") 218 222 monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) ··· 301 305 302 306 def test_named_hook_resolution(tmp_path, monkeypatch): 303 307 """Test that named hooks are resolved via load_post_hook.""" 304 - agents = importlib.import_module("think.agents") 308 + from think.muse import load_post_hook 305 309 306 310 # Config with named hook (new format) 307 311 config = {"hook": {"post": "occurrence"}} 308 - hook_fn = agents.load_post_hook(config) 312 + hook_fn = load_post_hook(config) 309 313 310 314 # Should resolve to muse/occurrence.py and be callable 311 315 assert callable(hook_fn)
+1 -1
tests/test_google.py
··· 52 52 events = [json.loads(line) for line in out_lines] 53 53 assert events[0]["event"] == "start" 54 54 assert isinstance(events[0]["ts"], int) 55 - assert events[0]["prompt"] == "hello" 55 + assert "hello" in events[0]["prompt"] 56 56 assert events[0]["name"] == "default" 57 57 assert events[0]["model"] == GEMINI_FLASH 58 58 assert events[-1]["event"] == "finish"
+1 -1
tests/test_google_thinking.py
··· 50 50 # Check that we have start, thinking, and finish events 51 51 assert events[0]["event"] == "start" 52 52 assert isinstance(events[0]["ts"], int) 53 - assert events[0]["prompt"] == "hello" 53 + assert "hello" in events[0]["prompt"] 54 54 55 55 # Look for thinking event 56 56 thinking_events = [e for e in events if e["event"] == "thinking"]
+6 -6
tests/test_openai.py
··· 71 71 events = [json.loads(line) for line in out_lines] 72 72 assert events[0]["event"] == "start" 73 73 assert isinstance(events[0]["ts"], int) 74 - assert events[0]["prompt"] == "hello" 74 + assert "hello" in events[0]["prompt"] 75 75 assert events[0]["name"] == "default" 76 76 assert events[0]["model"] == GPT_5 77 77 assert events[-1]["event"] == "finish" ··· 128 128 # Check that we have start, thinking, and finish events 129 129 assert events[0]["event"] == "start" 130 130 assert isinstance(events[0]["ts"], int) 131 - assert events[0]["prompt"] == "hello" 131 + assert "hello" in events[0]["prompt"] 132 132 133 133 # Look for thinking event 134 134 thinking_events = [e for e in events if e["event"] == "thinking"] ··· 211 211 events = [json.loads(line) for line in out_lines] 212 212 assert events[0]["event"] == "start" 213 213 assert isinstance(events[0]["ts"], int) 214 - assert events[0]["prompt"] == "hello" 214 + assert "hello" in events[0]["prompt"] 215 215 assert events[0]["name"] == "default" 216 216 assert events[0]["model"] == GPT_5 217 217 assert events[-1]["event"] == "finish" ··· 265 265 # Check that we have start, thinking, and finish events 266 266 assert events[0]["event"] == "start" 267 267 assert isinstance(events[0]["ts"], int) 268 - assert events[0]["prompt"] == "hello" 268 + assert "hello" in events[0]["prompt"] 269 269 270 270 # Look for thinking event 271 271 thinking_events = [e for e in events if e["event"] == "thinking"] ··· 375 375 # Check that we have start, thinking, and finish events 376 376 assert events[0]["event"] == "start" 377 377 assert isinstance(events[0]["ts"], int) 378 - assert events[0]["prompt"] == "hello" 378 + assert "hello" in events[0]["prompt"] 379 379 380 380 # Look for thinking event 381 381 thinking_events = [e for e in events if e["event"] == "thinking"] ··· 438 438 439 439 # Check start event 440 440 assert events[0]["event"] == "start" 441 - assert events[0]["prompt"] == "hello" 441 + assert "hello" in events[0]["prompt"] 442 442 443 443 # Look for tool_start event 444 444 tool_start_events = [e for e in events if e["event"] == "tool_start"]
+59 -223
tests/test_output_hooks.py
··· 4 4 """Tests for the generator output hooks system. 5 5 6 6 Tests cover: 7 - - Hook loading and validation via load_post_hook 7 + - Hook loading and validation via load_post_hook / load_pre_hook 8 8 - Hook invocation via NDJSON protocol 9 9 - Hook error handling 10 10 """ ··· 16 16 import shutil 17 17 from pathlib import Path 18 18 19 + from think.muse import load_post_hook, load_pre_hook 19 20 from think.utils import day_path 20 21 21 22 FIXTURES = Path("fixtures") ··· 64 65 65 66 def test_load_post_hook_success(tmp_path): 66 67 """Test loading a valid hook with post_process function.""" 67 - agents = importlib.import_module("think.agents") 68 - 69 68 hook_file = tmp_path / "test_hook.py" 70 69 hook_file.write_text(""" 71 70 def post_process(result, context): ··· 74 73 75 74 # Config with explicit path 76 75 config = {"hook": {"post": str(hook_file)}} 77 - hook_fn = agents.load_post_hook(config) 76 + hook_fn = load_post_hook(config) 78 77 assert callable(hook_fn) 79 78 80 79 # Test the hook transforms content ··· 84 83 85 84 def test_load_post_hook_missing_post_process(tmp_path): 86 85 """Test that hook without post_process function raises ValueError.""" 87 - agents = importlib.import_module("think.agents") 88 - 89 86 hook_file = tmp_path / "bad_hook.py" 90 87 hook_file.write_text(""" 91 88 def other_function(): ··· 94 91 95 92 config = {"hook": {"post": str(hook_file)}} 96 93 try: 97 - agents.load_post_hook(config) 94 + load_post_hook(config) 98 95 assert False, "Should have raised ValueError" 99 96 except ValueError as e: 100 97 assert "must define a 'post_process' function" in str(e) ··· 102 99 103 100 def test_load_post_hook_not_callable(tmp_path): 104 101 """Test that hook with non-callable post_process raises ValueError.""" 105 - agents = importlib.import_module("think.agents") 106 - 107 102 hook_file = tmp_path / "bad_hook.py" 108 103 hook_file.write_text(""" 109 104 post_process = "not a function" ··· 111 106 112 107 config = {"hook": {"post": str(hook_file)}} 113 108 try: 114 - agents.load_post_hook(config) 109 + load_post_hook(config) 115 110 assert False, "Should have raised ValueError" 116 111 except ValueError as e: 117 112 assert "'post_process' must be callable" in str(e) ··· 119 114 120 115 def test_load_post_hook_no_hook_config(): 121 116 """Test that missing hook config returns None.""" 122 - agents = importlib.import_module("think.agents") 123 - 124 - assert agents.load_post_hook({}) is None 125 - assert agents.load_post_hook({"hook": {}}) is None 126 - assert agents.load_post_hook({"hook": {"pre": "something"}}) is None 117 + assert load_post_hook({}) is None 118 + assert load_post_hook({"hook": {}}) is None 119 + assert load_post_hook({"hook": {"pre": "something"}}) is None 127 120 128 121 129 122 def test_load_post_hook_named_resolution(): 130 123 """Test that named hooks resolve to muse/{name}.py.""" 131 - agents = importlib.import_module("think.agents") 132 - 133 124 # occurrence.py exists in muse/ 134 125 config = {"hook": {"post": "occurrence"}} 135 - hook_fn = agents.load_post_hook(config) 126 + hook_fn = load_post_hook(config) 136 127 assert callable(hook_fn) 137 128 138 129 139 130 def test_load_post_hook_file_not_found(tmp_path): 140 131 """Test that nonexistent hook file raises ImportError.""" 141 - agents = importlib.import_module("think.agents") 142 - 143 132 config = {"hook": {"post": str(tmp_path / "nonexistent.py")}} 144 133 try: 145 - agents.load_post_hook(config) 134 + load_post_hook(config) 146 135 assert False, "Should have raised ImportError" 147 136 except ImportError as e: 148 137 assert "not found" in str(e) ··· 193 182 """) 194 183 195 184 try: 185 + # Mock the underlying generation function in think.models 186 + import think.models 187 + 196 188 monkeypatch.setattr( 197 - mod, 198 - "generate_agent_output", 199 - lambda *a, **k: ( 200 - MOCK_RESULT if k.get("return_result") else MOCK_RESULT["text"] 201 - ), 189 + think.models, 190 + "generate_with_result", 191 + lambda *a, **k: MOCK_RESULT, 202 192 ) 203 193 monkeypatch.setenv("GOOGLE_API_KEY", "x") 204 194 monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) ··· 247 237 """) 248 238 249 239 try: 240 + # Mock the underlying generation function in think.models 241 + import think.models 242 + 250 243 monkeypatch.setattr( 251 - mod, 252 - "generate_agent_output", 253 - lambda *a, **k: ( 254 - MOCK_RESULT if k.get("return_result") else MOCK_RESULT["text"] 255 - ), 244 + think.models, 245 + "generate_with_result", 246 + lambda *a, **k: MOCK_RESULT, 256 247 ) 257 248 monkeypatch.setenv("GOOGLE_API_KEY", "x") 258 249 monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) ··· 297 288 """) 298 289 299 290 try: 291 + # Mock the underlying generation function in think.models 292 + import think.models 293 + 300 294 monkeypatch.setattr( 301 - mod, 302 - "generate_agent_output", 303 - lambda *a, **k: ( 304 - MOCK_RESULT if k.get("return_result") else MOCK_RESULT["text"] 305 - ), 295 + think.models, 296 + "generate_with_result", 297 + lambda *a, **k: MOCK_RESULT, 306 298 ) 307 299 monkeypatch.setenv("GOOGLE_API_KEY", "x") 308 300 monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) ··· 329 321 prompt_file.unlink() 330 322 331 323 332 - def test_build_hook_context(): 333 - """Test that build_hook_context creates correct context.""" 334 - agents = importlib.import_module("think.agents") 335 - 336 - config = { 337 - "name": "test_gen", 338 - "agent_id": "123456", 339 - "provider": "google", 340 - "model": "gemini-2.0-flash", 341 - "prompt": "test prompt", 342 - "output": "md", 343 - "day": "20240101", 344 - "segment": "120000_3600", 345 - } 346 - 347 - context = agents.build_hook_context( 348 - config, 349 - transcript="test transcript", 350 - output_path="/tmp/test.md", 351 - span=False, 352 - ) 353 - 354 - assert context["name"] == "test_gen" 355 - assert context["agent_id"] == "123456" 356 - assert context["provider"] == "google" 357 - assert context["model"] == "gemini-2.0-flash" 358 - assert context["prompt"] == "test prompt" 359 - assert context["output_format"] == "md" 360 - assert context["day"] == "20240101" 361 - assert context["segment"] == "120000_3600" 362 - assert context["transcript"] == "test transcript" 363 - assert context["output_path"] == "/tmp/test.md" 364 - assert context["span"] is False 365 - assert context["meta"] == config 366 - 367 - 368 - def test_run_post_hook_transforms_result(): 369 - """Test that run_post_hook applies transformation.""" 370 - agents = importlib.import_module("think.agents") 371 - 372 - def hook(result, context): 373 - return result.upper() 374 - 375 - context = agents.build_hook_context({"name": "test"}) 376 - output = agents.run_post_hook("hello world", context, hook) 377 - 378 - assert output == "HELLO WORLD" 379 - 380 - 381 - def test_run_post_hook_none_keeps_original(): 382 - """Test that run_post_hook keeps original when hook returns None.""" 383 - agents = importlib.import_module("think.agents") 384 - 385 - def hook(result, context): 386 - return None 387 - 388 - context = agents.build_hook_context({"name": "test"}) 389 - output = agents.run_post_hook("original", context, hook) 390 - 391 - assert output == "original" 392 - 393 - 394 - def test_run_post_hook_error_keeps_original(): 395 - """Test that run_post_hook keeps original on error.""" 396 - agents = importlib.import_module("think.agents") 397 - 398 - def hook(result, context): 399 - raise RuntimeError("boom") 400 - 401 - context = agents.build_hook_context({"name": "test"}) 402 - output = agents.run_post_hook("original", context, hook) 403 - 404 - assert output == "original" 405 - 406 - 407 324 # ============================================================================= 408 325 # Pre-hook Tests 409 326 # ============================================================================= ··· 411 328 412 329 def test_load_pre_hook_success(tmp_path): 413 330 """Test loading a valid hook with pre_process function.""" 414 - agents = importlib.import_module("think.agents") 415 - 416 331 hook_file = tmp_path / "test_pre_hook.py" 417 332 hook_file.write_text(""" 418 333 def pre_process(context): ··· 420 335 """) 421 336 422 337 config = {"hook": {"pre": str(hook_file)}} 423 - hook_fn = agents.load_pre_hook(config) 338 + hook_fn = load_pre_hook(config) 424 339 assert callable(hook_fn) 425 340 426 341 # Test the hook returns modifications ··· 430 345 431 346 def test_load_pre_hook_missing_pre_process(tmp_path): 432 347 """Test that hook without pre_process function raises ValueError.""" 433 - agents = importlib.import_module("think.agents") 434 - 435 348 hook_file = tmp_path / "bad_hook.py" 436 349 hook_file.write_text(""" 437 350 def other_function(): ··· 440 353 441 354 config = {"hook": {"pre": str(hook_file)}} 442 355 try: 443 - agents.load_pre_hook(config) 356 + load_pre_hook(config) 444 357 assert False, "Should have raised ValueError" 445 358 except ValueError as e: 446 359 assert "must define a 'pre_process' function" in str(e) ··· 448 361 449 362 def test_load_pre_hook_not_callable(tmp_path): 450 363 """Test that hook with non-callable pre_process raises ValueError.""" 451 - agents = importlib.import_module("think.agents") 452 - 453 364 hook_file = tmp_path / "bad_hook.py" 454 365 hook_file.write_text(""" 455 366 pre_process = "not a function" ··· 457 368 458 369 config = {"hook": {"pre": str(hook_file)}} 459 370 try: 460 - agents.load_pre_hook(config) 371 + load_pre_hook(config) 461 372 assert False, "Should have raised ValueError" 462 373 except ValueError as e: 463 374 assert "'pre_process' must be callable" in str(e) ··· 465 376 466 377 def test_load_pre_hook_no_hook_config(): 467 378 """Test that missing hook config returns None.""" 468 - agents = importlib.import_module("think.agents") 469 - 470 - assert agents.load_pre_hook({}) is None 471 - assert agents.load_pre_hook({"hook": {}}) is None 472 - assert agents.load_pre_hook({"hook": {"post": "something"}}) is None 379 + assert load_pre_hook({}) is None 380 + assert load_pre_hook({"hook": {}}) is None 381 + assert load_pre_hook({"hook": {"post": "something"}}) is None 473 382 474 383 475 384 def test_load_pre_hook_file_not_found(tmp_path): 476 385 """Test that nonexistent hook file raises ImportError.""" 477 - agents = importlib.import_module("think.agents") 478 - 479 386 config = {"hook": {"pre": str(tmp_path / "nonexistent.py")}} 480 387 try: 481 - agents.load_pre_hook(config) 388 + load_pre_hook(config) 482 389 assert False, "Should have raised ImportError" 483 390 except ImportError as e: 484 391 assert "not found" in str(e) 485 392 486 393 487 - def test_build_pre_hook_context(): 488 - """Test that build_pre_hook_context creates correct context.""" 489 - agents = importlib.import_module("think.agents") 490 - 491 - config = { 492 - "name": "test_gen", 493 - "agent_id": "123456", 494 - "provider": "google", 495 - "model": "gemini-2.0-flash", 496 - "prompt": "test prompt", 497 - "system_instruction": "be helpful", 498 - "user_instruction": "answer questions", 499 - "extra_context": "extra info", 500 - "output": "md", 501 - "day": "20240101", 502 - "segment": "120000_3600", 503 - } 504 - 505 - context = agents.build_pre_hook_context( 506 - config, 507 - transcript="test transcript", 508 - output_path="/tmp/test.md", 509 - span=False, 510 - ) 511 - 512 - assert context["name"] == "test_gen" 513 - assert context["agent_id"] == "123456" 514 - assert context["provider"] == "google" 515 - assert context["model"] == "gemini-2.0-flash" 516 - assert context["prompt"] == "test prompt" 517 - assert context["system_instruction"] == "be helpful" 518 - assert context["user_instruction"] == "answer questions" 519 - assert context["extra_context"] == "extra info" 520 - assert context["output_format"] == "md" 521 - assert context["day"] == "20240101" 522 - assert context["segment"] == "120000_3600" 523 - assert context["transcript"] == "test transcript" 524 - assert context["output_path"] == "/tmp/test.md" 525 - assert context["span"] is False 526 - assert context["meta"] == config 527 - 528 - 529 - def test_run_pre_hook_returns_modifications(): 530 - """Test that run_pre_hook returns modifications dict.""" 531 - agents = importlib.import_module("think.agents") 532 - 533 - def hook(context): 534 - return {"prompt": "modified prompt", "transcript": "modified transcript"} 535 - 536 - context = agents.build_pre_hook_context({"name": "test", "prompt": "original"}) 537 - result = agents.run_pre_hook(context, hook) 538 - 539 - assert result == {"prompt": "modified prompt", "transcript": "modified transcript"} 540 - 541 - 542 - def test_run_pre_hook_none_returns_none(): 543 - """Test that run_pre_hook returns None when hook returns None.""" 544 - agents = importlib.import_module("think.agents") 545 - 546 - def hook(context): 547 - return None 548 - 549 - context = agents.build_pre_hook_context({"name": "test"}) 550 - result = agents.run_pre_hook(context, hook) 551 - 552 - assert result is None 553 - 554 - 555 - def test_run_pre_hook_error_returns_none(): 556 - """Test that run_pre_hook returns None on error.""" 557 - agents = importlib.import_module("think.agents") 558 - 559 - def hook(context): 560 - raise RuntimeError("boom") 561 - 562 - context = agents.build_pre_hook_context({"name": "test"}) 563 - result = agents.run_pre_hook(context, hook) 564 - 565 - assert result is None 566 - 567 - 568 394 def test_pre_hook_invocation(tmp_path, monkeypatch): 569 395 """Test that agents.py invokes pre-hook and uses modified inputs.""" 570 396 mod = importlib.import_module("think.agents") ··· 589 415 """) 590 416 591 417 try: 592 - # Track what generate_agent_output receives 593 - received_args = {} 418 + # Track what generate_with_result receives 419 + received_kwargs = {} 594 420 595 421 def mock_generate(*args, **kwargs): 596 - received_args["transcript"] = args[0] 597 - received_args["prompt"] = args[1] 598 - return MOCK_RESULT if kwargs.get("return_result") else MOCK_RESULT["text"] 422 + received_kwargs.update(kwargs) 423 + received_kwargs["contents"] = args[0] if args else kwargs.get("contents") 424 + return MOCK_RESULT 599 425 600 - monkeypatch.setattr(mod, "generate_agent_output", mock_generate) 426 + import think.models 427 + 428 + monkeypatch.setattr(think.models, "generate_with_result", mock_generate) 601 429 monkeypatch.setenv("GOOGLE_API_KEY", "x") 602 430 monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 603 431 ··· 611 439 612 440 events = run_generator_with_config(mod, config, monkeypatch) 613 441 614 - # Verify pre-hook modified the prompt 615 - assert "[pre-processed]" in received_args["prompt"] 442 + # Verify pre-hook modified the prompt - check in contents 443 + contents = received_kwargs.get("contents", []) 444 + # The prompt should contain [pre-processed] 445 + prompt_found = any("[pre-processed]" in str(c) for c in contents) 446 + assert prompt_found, f"Expected [pre-processed] in contents: {contents}" 616 447 617 448 # Verify generator still completed successfully 618 449 finish_events = [e for e in events if e["event"] == "finish"] ··· 647 478 """) 648 479 649 480 try: 650 - received_args = {} 481 + received_kwargs = {} 651 482 652 483 def mock_generate(*args, **kwargs): 653 - received_args["prompt"] = args[1] 654 - return MOCK_RESULT if kwargs.get("return_result") else MOCK_RESULT["text"] 484 + received_kwargs.update(kwargs) 485 + received_kwargs["contents"] = args[0] if args else kwargs.get("contents") 486 + return MOCK_RESULT 487 + 488 + import think.models 655 489 656 - monkeypatch.setattr(mod, "generate_agent_output", mock_generate) 490 + monkeypatch.setattr(think.models, "generate_with_result", mock_generate) 657 491 monkeypatch.setenv("GOOGLE_API_KEY", "x") 658 492 monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 659 493 ··· 667 501 668 502 events = run_generator_with_config(mod, config, monkeypatch) 669 503 670 - # Verify pre-hook modified the prompt 671 - assert "[pre]" in received_args["prompt"] 504 + # Verify pre-hook modified the prompt - check in contents 505 + contents = received_kwargs.get("contents", []) 506 + prompt_found = any("[pre]" in str(c) for c in contents) 507 + assert prompt_found, f"Expected [pre] in contents: {contents}" 672 508 673 509 # Verify post-hook modified the result 674 510 finish_events = [e for e in events if e["event"] == "finish"]
+301 -964
think/agents.py
··· 19 19 import os 20 20 import sys 21 21 import traceback 22 - from dataclasses import dataclass 23 22 from datetime import datetime 24 23 from pathlib import Path 25 - from typing import Any, Callable, Optional, TypedDict 24 + from typing import Any, Callable, Optional 26 25 27 26 from think.cluster import cluster, cluster_period, cluster_span 28 27 from think.muse import ( ··· 36 35 source_is_enabled, 37 36 source_is_required, 38 37 ) 39 - from think.providers.shared import Event, GenerateResult 38 + from think.providers.shared import Event 40 39 from think.utils import ( 41 40 day_log, 42 41 day_path, ··· 48 47 ) 49 48 50 49 LOG = logging.getLogger("think.agents") 50 + 51 + # Minimum content length for transcript-based generation 52 + MIN_INPUT_CHARS = 50 51 53 52 54 53 55 def setup_logging(verbose: bool = False) -> logging.Logger: ··· 129 131 Note: 130 132 Incomplete turns (missing finish event) are skipped 131 133 """ 132 - import logging 133 - 134 134 from think.cortex_client import read_agent_events 135 135 136 - logger = logging.getLogger(__name__) 137 - 138 136 try: 139 137 events = read_agent_events(conversation_id) 140 138 except FileNotFoundError: 141 - logger.warning(f"Cannot continue from {conversation_id}: log not found") 139 + LOG.warning(f"Cannot continue from {conversation_id}: log not found") 142 140 return [] 143 141 144 142 turns = [] ··· 182 180 183 181 184 182 # ============================================================================= 185 - # Hook Framework (unified for agents and generators) 186 - # ============================================================================= 187 - 188 - 189 - class HookContext(TypedDict, total=False): 190 - """Context passed to hook functions. 191 - 192 - Provides unified context for both tool-using agents and generators. 193 - Not all fields are present for all modalities. 194 - """ 195 - 196 - # Identity 197 - name: str # Agent/generator name 198 - agent_id: str # Unique agent ID 199 - provider: str # google/anthropic/openai 200 - model: str # Model used 201 - 202 - # Temporal (generators) 203 - day: str # YYYYMMDD 204 - segment: str # Segment key 205 - span: bool # True if span mode 206 - 207 - # Content 208 - prompt: str # Original prompt (agents) or empty (generators) 209 - transcript: str # Clustered transcript (generators only) 210 - 211 - # Output 212 - output_path: str # Where result will be written 213 - output_format: str # 'md' or 'json' 214 - 215 - # Full config 216 - meta: dict # Full frontmatter/config 217 - 218 - 219 - class PreHookContext(TypedDict, total=False): 220 - """Context passed to pre-processing hook functions. 221 - 222 - Pre-hooks receive all inputs before the LLM call and can modify them. 223 - Returns a dict of modified fields to merge back. 224 - """ 225 - 226 - # Identity 227 - name: str # Agent/generator name 228 - agent_id: str # Unique agent ID 229 - provider: str # google/anthropic/openai 230 - model: str # Model used 231 - 232 - # Temporal (generators) 233 - day: str # YYYYMMDD 234 - segment: str # Segment key 235 - span: bool # True if span mode 236 - 237 - # Modifiable inputs 238 - prompt: str # User prompt (can modify) 239 - system_instruction: str # System prompt (can modify) 240 - user_instruction: str # User instruction (agents, can modify) 241 - extra_context: str # Extra context (agents, can modify) 242 - transcript: str # Clustered transcript (generators, can modify) 243 - 244 - # Output settings 245 - output_path: str # Where result will be written 246 - output_format: str # 'md' or 'json' 247 - 248 - # Full config (read-only reference) 249 - meta: dict # Full frontmatter/config 250 - 251 - 252 - def _build_base_context(config: dict) -> dict: 253 - """Build common context fields shared by pre and post hooks.""" 254 - context = { 255 - "name": config.get("name", ""), 256 - "agent_id": config.get("agent_id", ""), 257 - "provider": config.get("provider", ""), 258 - "model": config.get("model", ""), 259 - "prompt": config.get("prompt", ""), 260 - "output_format": config.get("output", "md"), 261 - "meta": config, 262 - } 263 - 264 - # Add generator-specific fields if present 265 - if "day" in config: 266 - context["day"] = config["day"] 267 - if "segment" in config: 268 - context["segment"] = config["segment"] 269 - 270 - return context 271 - 272 - 273 - def build_pre_hook_context(config: dict, **extras: Any) -> PreHookContext: 274 - """Build PreHookContext from config and extra values.""" 275 - context: PreHookContext = _build_base_context(config) 276 - 277 - # Add pre-hook specific fields 278 - context["system_instruction"] = config.get("system_instruction", "") 279 - context["user_instruction"] = config.get("user_instruction", "") 280 - context["extra_context"] = config.get("extra_context", "") 281 - 282 - # Merge extras (transcript, output_path, span, etc.) 283 - context.update(extras) 284 - 285 - return context 286 - 287 - 288 - def build_hook_context(config: dict, **extras: Any) -> HookContext: 289 - """Build HookContext from config and extra values.""" 290 - context: HookContext = _build_base_context(config) 291 - 292 - # Merge extras (transcript, output_path, span, etc.) 293 - context.update(extras) 294 - 295 - return context 296 - 297 - 298 - def run_pre_hook( 299 - context: PreHookContext, 300 - hook_fn: Callable[[PreHookContext], dict | None], 301 - ) -> dict | None: 302 - """Execute pre-processing hook and return modifications dict. 303 - 304 - Hook errors are logged and return None (graceful degradation). 305 - """ 306 - try: 307 - modifications = hook_fn(context) 308 - if modifications is not None: 309 - logging.info( 310 - "Pre-hook returned modifications: %s", list(modifications.keys()) 311 - ) 312 - return modifications 313 - except Exception as exc: 314 - logging.error("Pre-hook failed: %s", exc) 315 - 316 - return None 317 - 318 - 319 - def run_post_hook( 320 - result: str, 321 - context: HookContext, 322 - hook_fn: Callable[[str, HookContext], str | None], 323 - ) -> str: 324 - """Execute post-processing hook and return (potentially transformed) result. 325 - 326 - Args: 327 - result: The LLM-generated output text 328 - context: Hook context with metadata 329 - hook_fn: The post_process function to call 330 - 331 - Returns: 332 - Transformed result if hook returns string, original result otherwise. 333 - """ 334 - try: 335 - hook_result = hook_fn(result, context) 336 - if hook_result is not None: 337 - logging.info("Hook transformed result") 338 - return hook_result 339 - except Exception as exc: 340 - logging.error("Hook failed: %s", exc) 341 - 342 - return result 343 - 344 - 345 - __all__ = [ 346 - # Re-exported from think.providers.shared 347 - "Event", 348 - "GenerateResult", 349 - # Local definitions 350 - "HookContext", 351 - "PreHookContext", 352 - "InputContext", 353 - "JSONEventWriter", 354 - "format_tool_summary", 355 - "parse_agent_events_to_turns", 356 - "load_post_hook", 357 - "load_pre_hook", 358 - "build_hook_context", 359 - "build_pre_hook_context", 360 - "run_post_hook", 361 - "run_pre_hook", 362 - "assemble_inputs", 363 - "scan_day", 364 - "generate_agent_output", 365 - "hydrate_config", 366 - "expand_tools", 367 - "validate_config", 368 - ] 369 - 370 - 371 - # ============================================================================= 372 - # Config Hydration and Validation (moved from cortex.py) 183 + # Config Hydration, Enrichment, and Validation 373 184 # ============================================================================= 374 185 375 186 ··· 496 307 return None 497 308 498 309 499 - # ============================================================================= 500 - # Unified Input Assembly (shared by generate and tools paths) 501 - # ============================================================================= 502 - 503 - # Minimum content length for transcript-based generation 504 - MIN_INPUT_CHARS = 50 505 - 506 - 507 - @dataclass 508 - class InputContext: 509 - """Assembled inputs for generation or tool execution. 510 - 511 - Contains all resolved inputs ready for LLM call, including transcript, 512 - prompts, and output path. Used by generate path always, and by tools path 513 - when day is specified for transcript loading. 514 - """ 515 - 516 - # Transcript (from day/segment/span clustering) 517 - transcript: str 518 - source_counts: dict[str, int] 519 - 520 - # Prompts 521 - prompt: str # Final prompt (with template substitution) 522 - system_instruction: str 523 - system_prompt_name: str # For diagnostic output (dry-run) 524 - 525 - # Output 526 - output_path: Optional[Path] 527 - output_format: Optional[str] # 'md' or 'json' 528 - 529 - # Metadata for hooks and logging 530 - meta: dict # Agent config metadata 531 - agent_path: Optional[Path] # Path to agent .md file 532 - 533 - # Skip reason (if should skip execution) 534 - skip_reason: Optional[str] 535 - 536 - # Day/segment context 537 - day: Optional[str] 538 - segment: Optional[str] 539 - span_mode: bool 540 - 541 - 542 - def assemble_inputs(config: dict) -> InputContext: 543 - """Assemble all inputs for generation or tool execution. 310 + def enrich_config(config: dict) -> None: 311 + """Enrich config with transcript, system instruction, output path, etc. 544 312 545 - Handles: 546 - - Loading agent config metadata 547 - - Transcript loading from journal (if day specified) 548 - - Source filtering and required source validation 549 - - Minimum content checks 550 - - Prompt template substitution 551 - - System instruction composition 552 - - Output path resolution 313 + Mutates config in place, adding: 314 + - transcript: Clustered transcript content (if day specified) 315 + - system_instruction: System prompt (if not already set) 316 + - output_path: Where to write output (if output format specified) 317 + - source_counts: Dict of source type -> count 318 + - skip_reason: Why to skip execution (if applicable) 319 + - span_mode: Whether in span mode 320 + - meta: Agent metadata from muse config 553 321 554 322 Args: 555 - config: Hydrated config dict from cortex 556 - 557 - Returns: 558 - InputContext with all resolved inputs, or skip_reason if should skip 323 + config: Hydrated config dict to enrich 559 324 """ 560 325 name = config.get("name", "default") 561 326 day = config.get("day") 562 327 segment = config.get("segment") 563 328 span = config.get("span") # List of sequential segment keys 564 - facet = config.get("facet") # For multi-facet agents 329 + facet = config.get("facet") 565 330 output_format = config.get("output") 566 331 output_path_override = config.get("output_path") 567 332 user_prompt = config.get("prompt", "") ··· 576 341 else: 577 342 meta = {} 578 343 agent_path = None 344 + 345 + config["meta"] = meta 346 + config["agent_path"] = agent_path 347 + config["span_mode"] = bool(span) 348 + config["source_counts"] = {} 579 349 580 350 # Check if config is disabled 581 351 if meta.get("disabled"): 582 - return InputContext( 583 - transcript="", 584 - source_counts={}, 585 - prompt=user_prompt, 586 - system_instruction="", 587 - system_prompt_name="journal", 588 - output_path=None, 589 - output_format=output_format, 590 - meta=meta, 591 - agent_path=agent_path, 592 - skip_reason="disabled", 593 - day=day, 594 - segment=segment, 595 - span_mode=bool(span), 596 - ) 352 + config["skip_reason"] = "disabled" 353 + return 597 354 598 355 # Extract instructions config for source filtering and system prompt 599 356 instructions_config = meta.get("instructions") ··· 603 360 config_overrides=instructions_config, 604 361 ) 605 362 sources = instructions.get("sources", {}) 606 - system_prompt_name = instructions.get("system_prompt_name", "journal") 607 - system_instruction = instructions["system_instruction"] 363 + config["system_prompt_name"] = instructions.get("system_prompt_name", "journal") 608 364 609 - # Append extra_context (facets, etc.) to system instruction if present 610 - extra_context = instructions.get("extra_context") 611 - if extra_context: 612 - system_instruction = f"{system_instruction}\n\n{extra_context}" 613 - 614 - # Track span mode 615 - span_mode = bool(span) 616 - 617 - # Initialize transcript variables 618 - transcript = "" 619 - source_counts: dict[str, int] = {} 365 + # Set system_instruction if not already provided 366 + if not config.get("system_instruction"): 367 + system_instruction = instructions["system_instruction"] 368 + # Append extra_context (facets, etc.) to system instruction if present 369 + extra_context = instructions.get("extra_context") 370 + if extra_context: 371 + system_instruction = f"{system_instruction}\n\n{extra_context}" 372 + config["system_instruction"] = system_instruction 620 373 621 374 # Transcript loading (only if day is provided) 622 375 if day: ··· 627 380 os.environ["SEGMENT_KEY"] = span[0] 628 381 629 382 # Convert sources for clustering 630 - # For audio/screen: use source_is_enabled to get bool 631 - # For agents: pass through dict for selective filtering, or use source_is_enabled 632 383 cluster_sources: dict = {} 633 384 for k, v in sources.items(): 634 385 if k == "agents": 635 386 agent_filter = get_agent_filter(v) 636 387 if agent_filter is None: 637 - # All agents (True or "required") 638 388 cluster_sources[k] = source_is_enabled(v) 639 389 elif not agent_filter: 640 - # No agents (False or empty dict) 641 390 cluster_sources[k] = False 642 391 else: 643 - # Selective filtering - pass dict through 644 392 cluster_sources[k] = agent_filter 645 393 else: 646 394 cluster_sources[k] = source_is_enabled(v) ··· 655 403 else: 656 404 transcript, source_counts = cluster(day, sources=cluster_sources) 657 405 406 + config["transcript"] = transcript 407 + config["source_counts"] = source_counts 658 408 total_count = sum(source_counts.values()) 659 409 660 410 # Check required sources have content 661 411 for source_type, mode in sources.items(): 662 412 if source_is_required(mode) and source_counts.get(source_type, 0) == 0: 663 - return InputContext( 664 - transcript=transcript, 665 - source_counts=source_counts, 666 - prompt=user_prompt, 667 - system_instruction=system_instruction, 668 - system_prompt_name=system_prompt_name, 669 - output_path=None, 670 - output_format=output_format, 671 - meta=meta, 672 - agent_path=agent_path, 673 - skip_reason=f"missing_required_{source_type}", 674 - day=day, 675 - segment=segment, 676 - span_mode=span_mode, 677 - ) 413 + config["skip_reason"] = f"missing_required_{source_type}" 414 + return 678 415 679 416 # Skip when there's nothing to analyze 680 417 if total_count == 0 or len(transcript.strip()) < MIN_INPUT_CHARS: 681 - return InputContext( 682 - transcript=transcript, 683 - source_counts=source_counts, 684 - prompt=user_prompt, 685 - system_instruction=system_instruction, 686 - system_prompt_name=system_prompt_name, 687 - output_path=None, 688 - output_format=output_format, 689 - meta=meta, 690 - agent_path=agent_path, 691 - skip_reason="no_input", 692 - day=day, 693 - segment=segment, 694 - span_mode=span_mode, 695 - ) 418 + config["skip_reason"] = "no_input" 419 + return 696 420 697 421 # Prepend input context note for limited recordings 698 422 if total_count < 3: ··· 700 424 "**Input Note:** Limited recordings for this day. " 701 425 "Scale analysis to available input.\n\n" 702 426 ) 703 - transcript = input_note + transcript 427 + config["transcript"] = input_note + transcript 704 428 705 429 # Build context for template substitution 706 430 prompt_context: dict[str, str] = {} ··· 743 467 agent_path.stem, base_dir=agent_path.parent, context=prompt_context 744 468 ) 745 469 prompt = agent_prompt_obj.text 746 - else: 747 - prompt = user_prompt 748 - 749 - # Append user prompt if both agent prompt and user prompt exist 750 - if agent_path and user_prompt and prompt != user_prompt: 751 - prompt = f"{prompt}\n\n{user_prompt}" 470 + # Append user prompt if both exist 471 + if user_prompt and prompt != user_prompt: 472 + prompt = f"{prompt}\n\n{user_prompt}" 473 + config["prompt"] = prompt 752 474 753 475 # Determine output path 754 - output_path: Optional[Path] = None 755 476 if output_format: 756 477 if output_path_override: 757 - output_path = Path(output_path_override) 478 + config["output_path"] = Path(output_path_override) 758 479 elif day: 759 480 day_dir = str(day_path(day)) 760 - output_path = get_output_path( 481 + config["output_path"] = get_output_path( 761 482 day_dir, name, segment=segment, output_format=output_format, facet=facet 762 483 ) 763 484 764 - return InputContext( 765 - transcript=transcript, 766 - source_counts=source_counts, 767 - prompt=prompt, 768 - system_instruction=system_instruction, 769 - system_prompt_name=system_prompt_name, 770 - output_path=output_path, 771 - output_format=output_format, 772 - meta=meta, 773 - agent_path=agent_path, 774 - skip_reason=None, 775 - day=day, 776 - segment=segment, 777 - span_mode=span_mode, 778 - ) 779 - 780 485 781 486 # ============================================================================= 782 - # Unified Execution Helpers (shared by generate and tools paths) 487 + # Hook Execution 783 488 # ============================================================================= 784 489 785 490 786 - def _emit_start_event( 787 - emit_event: Callable[[dict], None], 788 - name: str, 789 - model: str, 790 - provider: str, 791 - prompt: str, 792 - continue_from: Optional[str] = None, 793 - ) -> None: 794 - """Emit a unified start event for both generate and tools paths.""" 795 - start_event: dict[str, Any] = { 796 - "event": "start", 797 - "ts": now_ms(), 798 - "prompt": prompt, 799 - "name": name, 800 - "model": model or "unknown", 801 - "provider": provider, 802 - } 803 - if continue_from: 804 - start_event["continue_from"] = continue_from 805 - emit_event(start_event) 806 - 807 - 808 - def _handle_skip( 809 - inputs: InputContext, 810 - name: str, 811 - path_type: str, 812 - emit_event: Callable[[dict], None], 813 - ) -> bool: 814 - """Handle skip conditions from input assembly. 491 + def _run_pre_hooks(config: dict) -> dict: 492 + """Run pre-processing hooks, return dict of modifications. 815 493 816 494 Args: 817 - inputs: InputContext with skip_reason set 818 - name: Agent/generator name 819 - path_type: "generate" or "agent" for logging 820 - emit_event: Event emitter callback 495 + config: Full config dict (hooks receive this directly) 821 496 822 497 Returns: 823 - True if skipped and caller should return/continue, False otherwise 498 + Dict of field modifications to apply to config 824 499 """ 825 - if not inputs.skip_reason: 826 - return False 500 + meta = config.get("meta", {}) 501 + pre_hook = load_pre_hook(meta) 502 + if not pre_hook: 503 + return {} 504 + 505 + try: 506 + modifications = pre_hook(config) 507 + if modifications: 508 + LOG.info("Pre-hook returned modifications: %s", list(modifications.keys())) 509 + return modifications 510 + except Exception as exc: 511 + LOG.error("Pre-hook failed: %s", exc) 827 512 828 - logging.info("Config %s skipped: %s", name, inputs.skip_reason) 829 - emit_event( 830 - { 831 - "event": "finish", 832 - "ts": now_ms(), 833 - "result": "", 834 - "skipped": inputs.skip_reason, 835 - } 836 - ) 837 - if inputs.day: 838 - day_log(inputs.day, f"{path_type} {name} skipped ({inputs.skip_reason})") 839 - return True 513 + return {} 840 514 841 515 842 - def _execute_pre_hooks( 843 - meta: dict, 844 - modifiable: dict[str, str], 845 - output_path: Optional[Path] = None, 846 - day: Optional[str] = None, 847 - segment: Optional[str] = None, 848 - span_mode: bool = False, 849 - ) -> tuple[dict[str, str], dict[str, Any]]: 850 - """Execute pre-processing hooks and return modified values. 516 + def _run_post_hooks(result: str, config: dict) -> str: 517 + """Run post-processing hooks, return transformed result. 851 518 852 519 Args: 853 - meta: Agent metadata containing hook config 854 - modifiable: Dict of modifiable field values (prompt, system_instruction, 855 - transcript, etc.) - these are passed to the hook context 856 - output_path: Output path for context 857 - day: Day string for context 858 - segment: Segment string for context 859 - span_mode: Whether in span mode 520 + result: LLM output text 521 + config: Full config dict (hooks receive this directly) 860 522 861 523 Returns: 862 - Tuple of (modified_values, hook_info for dry-run) 863 - modified_values contains only fields that were modified 864 - hook_info contains name and list of modifications for dry-run display 524 + Transformed result (or original if no hook) 865 525 """ 866 - hook_info: dict[str, Any] = {} 867 - modified: dict[str, str] = {} 526 + meta = config.get("meta", {}) 527 + post_hook = load_post_hook(meta) 528 + if not post_hook: 529 + return result 868 530 869 - pre_hook = load_pre_hook(meta) 870 - if not pre_hook: 871 - return modified, hook_info 531 + try: 532 + hook_result = post_hook(result, config) 533 + if hook_result is not None: 534 + LOG.info("Post-hook transformed result") 535 + return hook_result 536 + except Exception as exc: 537 + LOG.error("Post-hook failed: %s", exc) 872 538 873 - # Get hook name for logging 874 - hook_config = meta.get("hook", {}) 875 - hook_name = hook_config.get("pre") if isinstance(hook_config, dict) else None 876 - hook_info["name"] = hook_name 539 + return result 877 540 878 - # Build context with all modifiable fields 879 - # Note: modifiable may contain transcript, so we don't pass it separately 880 - pre_context = build_pre_hook_context( 881 - meta, 882 - output_path=str(output_path) if output_path else "", 883 - day=day, 884 - segment=segment, 885 - span=span_mode, 886 - **modifiable, 887 - ) 888 541 889 - modifications = run_pre_hook(pre_context, pre_hook) 890 - if modifications: 891 - # Only include fields that were actually modified 892 - for key in modifiable: 893 - if key in modifications: 894 - modified[key] = modifications[key] 895 - hook_info["modifications"] = list(modifications.keys()) 542 + # ============================================================================= 543 + # Unified Agent Execution 544 + # ============================================================================= 896 545 897 - return modified, hook_info 898 546 547 + def _write_output(output_path: Path, result: str) -> None: 548 + """Write result to output file.""" 549 + output_path.parent.mkdir(parents=True, exist_ok=True) 550 + with open(output_path, "w", encoding="utf-8") as f: 551 + f.write(result) 552 + LOG.info("Wrote output to %s", output_path) 899 553 900 - def _build_dry_run_event( 901 - run_type: str, 902 - name: str, 903 - provider: str, 904 - model: str, 905 - config: dict, 906 - inputs: Optional[InputContext], 907 - hook_info: dict[str, Any], 908 - before_values: dict[str, str], 909 - current_values: dict[str, str], 910 - ) -> dict[str, Any]: 911 - """Build a dry-run event with all context. 912 554 913 - Args: 914 - run_type: "generate" or "agent" 915 - name: Agent/generator name 916 - provider: Provider name 917 - model: Model name 918 - config: Full config dict 919 - inputs: InputContext if available 920 - hook_info: Pre-hook info from _execute_pre_hooks 921 - before_values: Values before hook execution 922 - current_values: Values after hook execution 555 + def _build_dry_run_event(config: dict, before_values: dict) -> dict: 556 + """Build a dry-run event with all context.""" 557 + has_tools = bool(config.get("tools")) 558 + run_type = "agent" if has_tools else "generate" 923 559 924 - Returns: 925 - Complete dry-run event dict 926 - """ 927 560 event: dict[str, Any] = { 928 561 "event": "dry_run", 929 562 "ts": now_ms(), 930 563 "type": run_type, 931 - "name": name, 932 - "provider": provider, 933 - "model": model or "unknown", 934 - "system_instruction": current_values.get("system_instruction", ""), 935 - "prompt": current_values.get("prompt", ""), 564 + "name": config.get("name", "default"), 565 + "provider": config.get("provider", ""), 566 + "model": config.get("model") or "unknown", 567 + "system_instruction": config.get("system_instruction", ""), 568 + "prompt": config.get("prompt", ""), 936 569 } 937 570 938 - # Add agent-specific fields 939 - if run_type == "agent": 940 - event["user_instruction"] = current_values.get("user_instruction", "") 941 - event["extra_context"] = current_values.get("extra_context", "") 571 + if has_tools: 572 + event["user_instruction"] = config.get("user_instruction", "") 573 + event["extra_context"] = config.get("extra_context", "") 942 574 event["tools"] = config.get("tools", []) 943 - 944 - # Add generate-specific fields 945 - if run_type == "generate": 946 - event["system_instruction_source"] = ( 947 - inputs.system_prompt_name if inputs else "journal" 948 - ) 949 - event["prompt_source"] = ( 950 - str(inputs.agent_path) if inputs and inputs.agent_path else "request" 951 - ) 575 + else: 576 + event["system_instruction_source"] = config.get("system_prompt_name", "journal") 577 + agent_path = config.get("agent_path") 578 + event["prompt_source"] = str(agent_path) if agent_path else "request" 952 579 953 - # Add day-based fields if inputs available 954 - if inputs: 955 - event["day"] = inputs.day 956 - event["segment"] = inputs.segment 957 - transcript = current_values.get("transcript", "") 580 + # Day-based fields 581 + if config.get("day"): 582 + event["day"] = config["day"] 583 + event["segment"] = config.get("segment") 584 + transcript = config.get("transcript", "") 958 585 if transcript: 959 586 event["transcript"] = transcript 960 587 event["transcript_chars"] = len(transcript) 961 - event["transcript_files"] = sum(inputs.source_counts.values()) 962 - if inputs.output_path: 963 - event["output_path"] = str(inputs.output_path) 588 + event["transcript_files"] = sum(config.get("source_counts", {}).values()) 589 + output_path = config.get("output_path") 590 + if output_path: 591 + event["output_path"] = str(output_path) 964 592 965 - # Add hook before/after info 966 - if hook_info: 967 - event["pre_hook"] = hook_info.get("name") 968 - event["pre_hook_modifications"] = hook_info.get("modifications", []) 969 - # Include before values for modified fields 970 - for key, before_val in before_values.items(): 971 - current_val = current_values.get(key, "") 972 - if current_val != before_val: 973 - if key == "transcript": 974 - event["transcript_before"] = before_val 975 - event["transcript_before_chars"] = len(before_val) 976 - else: 977 - event[f"{key}_before"] = before_val 593 + # Show before values for comparison 594 + for key, before_val in before_values.items(): 595 + current_val = config.get(key, "") 596 + if current_val != before_val: 597 + if key == "transcript": 598 + event["transcript_before_chars"] = len(before_val) 599 + else: 600 + event[f"{key}_before"] = before_val 978 601 979 602 return event 980 603 981 604 982 - def _write_output(output_path: Path, result: str, output_format: str) -> None: 983 - """Write result to output file. 984 - 985 - Args: 986 - output_path: Path to write to 987 - result: Content to write 988 - output_format: Format type ('md' or 'json') 989 - """ 990 - output_path.parent.mkdir(parents=True, exist_ok=True) 991 - with open(output_path, "w", encoding="utf-8") as f: 992 - f.write(result) 993 - logging.info("Wrote output to %s", output_path) 994 - 995 - 996 - def _execute_post_hooks( 997 - result: str, 998 - meta: dict, 999 - transcript: str = "", 1000 - output_path: Optional[Path] = None, 1001 - day: Optional[str] = None, 1002 - segment: Optional[str] = None, 1003 - span_mode: bool = False, 1004 - name: str = "", 1005 - ) -> str: 1006 - """Execute post-processing hooks and return transformed result. 1007 - 1008 - Args: 1009 - result: LLM output text 1010 - meta: Agent metadata containing hook config 1011 - transcript: Transcript for context 1012 - output_path: Output path for context 1013 - day: Day for context 1014 - segment: Segment for context 1015 - span_mode: Span mode flag 1016 - name: Agent name 1017 - 1018 - Returns: 1019 - Transformed result (or original if no hook or hook returns None) 1020 - """ 1021 - post_hook = load_post_hook(meta) 1022 - if not post_hook: 1023 - return result 1024 - 1025 - hook_context = build_hook_context( 1026 - meta, 1027 - name=name, 1028 - day=day, 1029 - segment=segment, 1030 - span=span_mode, 1031 - output_path=str(output_path) if output_path else "", 1032 - transcript=transcript, 1033 - ) 1034 - return run_post_hook(result, hook_context, post_hook) 1035 - 1036 - 1037 - # ============================================================================= 1038 - # Generator Functions (for transcript analysis without tools) 1039 - # ============================================================================= 1040 - 1041 - 1042 - def scan_day(day: str) -> dict[str, list[str]]: 1043 - """Return lists of processed and pending daily generator output files. 1044 - 1045 - Only scans daily generators (schedule='daily'). Segment generators are 1046 - stored within segment directories and are not included here. 1047 - 1048 - Note: Multi-facet generators would produce {topic}_{facet}.{ext} files, 1049 - but currently no multi-facet generators exist (all multi-facet agents 1050 - have tools, not output). 1051 - """ 1052 - day_dir = day_path(day) 1053 - daily_generators = get_muse_configs( 1054 - has_tools=False, has_output=True, schedule="daily", include_disabled=True 1055 - ) 1056 - processed: list[str] = [] 1057 - pending: list[str] = [] 1058 - for key, meta in sorted(daily_generators.items()): 1059 - output_format = meta.get("output") 1060 - output_path = get_output_path(day_dir, key, output_format=output_format) 1061 - if output_path.exists(): 1062 - processed.append(os.path.join("agents", output_path.name)) 1063 - else: 1064 - pending.append(os.path.join("agents", output_path.name)) 1065 - return {"processed": sorted(processed), "repairable": sorted(pending)} 1066 - 1067 - 1068 - def generate_agent_output( 1069 - transcript: str, 1070 - prompt: str, 1071 - name: str | None = None, 1072 - json_output: bool = False, 1073 - system_instruction: str | None = None, 1074 - thinking_budget: int | None = None, 1075 - max_output_tokens: int | None = None, 1076 - return_result: bool = False, 1077 - ) -> str | GenerateResult: 1078 - """Send clustered transcript to LLM for agent output generation. 1079 - 1080 - Args: 1081 - transcript: Clustered transcript content (markdown format). 1082 - prompt: Agent prompt text. 1083 - name: Agent name for token logging context. 1084 - json_output: If True, request JSON response format. 1085 - system_instruction: System instruction text. If None, loads default 1086 - from journal.md via compose_instructions(). 1087 - thinking_budget: Token budget for model thinking. If None, uses default. 1088 - max_output_tokens: Maximum output tokens. If None, uses default. 1089 - return_result: If True, return full GenerateResult with usage data. 1090 - 1091 - Returns: 1092 - Generated agent output content (markdown or JSON string), or 1093 - GenerateResult dict if return_result=True. 1094 - """ 1095 - from think.models import generate_with_result 1096 - 1097 - # Use provided system_instruction or fall back to default 1098 - if system_instruction is None: 1099 - instructions = compose_instructions(include_datetime=False) 1100 - system_instruction = instructions["system_instruction"] 1101 - 1102 - # Use defaults if not specified 1103 - if thinking_budget is None: 1104 - thinking_budget = 8192 * 3 1105 - if max_output_tokens is None: 1106 - max_output_tokens = 8192 * 6 1107 - 1108 - # Build context for provider routing and token logging 1109 - from think.muse import key_to_context 1110 - 1111 - context = key_to_context(name) if name else "muse.system.unknown" 1112 - 1113 - result = generate_with_result( 1114 - contents=[transcript, prompt], 1115 - context=context, 1116 - temperature=0.3, 1117 - max_output_tokens=max_output_tokens, 1118 - thinking_budget=thinking_budget, 1119 - system_instruction=system_instruction, 1120 - json_output=json_output, 1121 - ) 1122 - 1123 - if return_result: 1124 - return result 1125 - return result["text"] 1126 - 1127 - 1128 - def _run_generate( 605 + async def _run_agent( 1129 606 config: dict, 1130 607 emit_event: Callable[[dict], None], 1131 - *, 1132 608 dry_run: bool = False, 1133 - inputs: InputContext | None = None, 1134 609 ) -> None: 1135 - """Execute single-shot generation with optional features based on config. 610 + """Execute agent or generator based on config. 1136 611 1137 - This is the generation path for non-tool requests. Uses assemble_inputs() for 1138 - input assembly, which can be shared with the tools path. 612 + Unified execution path for both tool-using agents and transcript generators. 613 + The only branch is at the LLM call - everything else is shared. 1139 614 1140 615 Args: 1141 - config: Merged config from cortex 616 + config: Fully hydrated and enriched config dict 1142 617 emit_event: Callback to emit JSONL events 1143 618 dry_run: If True, emit dry_run event instead of calling LLM 1144 - inputs: Pre-assembled InputContext (if None, will call assemble_inputs) 1145 619 """ 1146 620 name = config.get("name", "default") 1147 - force = config.get("force", False) 1148 621 provider = config.get("provider", "google") 1149 622 model = config.get("model") 1150 - user_prompt = config.get("prompt", "") 623 + has_tools = bool(config.get("tools")) 624 + force = config.get("force", False) 1151 625 1152 - # Assemble inputs first (before start event, so we can skip cleanly) 1153 - if inputs is None: 1154 - inputs = assemble_inputs(config) 626 + # Emit start event 627 + start_event: dict[str, Any] = { 628 + "event": "start", 629 + "ts": now_ms(), 630 + "prompt": config.get("prompt", ""), 631 + "name": name, 632 + "model": model or "unknown", 633 + "provider": provider, 634 + } 635 + if config.get("continue_from"): 636 + start_event["continue_from"] = config["continue_from"] 637 + emit_event(start_event) 638 + 639 + # Handle skip conditions 640 + skip_reason = config.get("skip_reason") 641 + if skip_reason: 642 + LOG.info("Config %s skipped: %s", name, skip_reason) 643 + emit_event( 644 + { 645 + "event": "finish", 646 + "ts": now_ms(), 647 + "result": "", 648 + "skipped": skip_reason, 649 + } 650 + ) 651 + if config.get("day"): 652 + day_log(config["day"], f"agent {name} skipped ({skip_reason})") 653 + return 654 + 655 + # Check if output already exists (generators only, not tool agents) 656 + output_path = config.get("output_path") 657 + output_format = config.get("output") 658 + if not has_tools and output_path and not force and not dry_run: 659 + if output_path.exists() and output_path.stat().st_size > 0: 660 + LOG.info("Output exists, loading: %s", output_path) 661 + with open(output_path, "r") as f: 662 + result = f.read() 663 + emit_event( 664 + { 665 + "event": "finish", 666 + "ts": now_ms(), 667 + "result": result, 668 + } 669 + ) 670 + return 1155 671 1156 - # Emit unified start event 1157 - _emit_start_event(emit_event, name, model, provider, user_prompt) 672 + # Capture state before pre-hooks 673 + before_values = { 674 + "prompt": config.get("prompt", ""), 675 + "system_instruction": config.get("system_instruction", ""), 676 + "transcript": config.get("transcript", ""), 677 + } 678 + if has_tools: 679 + before_values["user_instruction"] = config.get("user_instruction", "") 680 + before_values["extra_context"] = config.get("extra_context", "") 681 + 682 + # Run pre-hooks 683 + modifications = _run_pre_hooks(config) 684 + for key, value in modifications.items(): 685 + config[key] = value 1158 686 1159 - # Handle skip conditions using helper 1160 - if _handle_skip(inputs, name, "generate", emit_event): 687 + # Dry-run mode 688 + if dry_run: 689 + emit_event(_build_dry_run_event(config, before_values)) 1161 690 return 1162 691 1163 - # Extract values from InputContext 1164 - day = inputs.day 1165 - segment = inputs.segment 1166 - span_mode = inputs.span_mode 1167 - transcript = inputs.transcript 1168 - prompt = inputs.prompt 1169 - system_instruction = inputs.system_instruction 1170 - output_path = inputs.output_path 1171 - output_format = inputs.output_format 1172 - meta = inputs.meta 692 + # Execute LLM call - this is the only real branch 693 + if has_tools: 694 + # Tool-using agent path 695 + from .providers import PROVIDER_REGISTRY, get_provider_module 1173 696 1174 - # Check if output exists 1175 - output_exists = False 1176 - is_json_output = output_format == "json" 1177 - if output_path: 1178 - output_exists = output_path.exists() and output_path.stat().st_size > 0 697 + if provider not in PROVIDER_REGISTRY: 698 + valid = ", ".join(sorted(PROVIDER_REGISTRY.keys())) 699 + raise ValueError( 700 + f"Unknown provider: {provider!r}. Valid providers: {valid}" 701 + ) 1179 702 1180 - # Extract generation parameters from metadata 1181 - meta_thinking_budget = meta.get("thinking_budget") 1182 - meta_max_output_tokens = meta.get("max_output_tokens") 703 + provider_mod = get_provider_module(provider) 1183 704 1184 - usage_data = None 705 + # Create wrapper to intercept finish event 706 + def agent_emit_event(data: Event) -> None: 707 + if data.get("event") == "finish": 708 + result = data.get("result", "") 709 + result = _run_post_hooks(result, config) 710 + if result != data.get("result", ""): 711 + data = {**data, "result": result} 712 + if output_path and result: 713 + _write_output(output_path, result) 714 + if config.get("handoff"): 715 + data = {**data, "handoff": config["handoff"]} 1185 716 1186 - # Dry-run always goes through prompt assembly, regardless of existing output 1187 - if output_exists and not force and not dry_run: 1188 - # Load existing content (no LLM call) 1189 - logging.info("Output exists, loading: %s", output_path) 1190 - with open(output_path, "r") as f: 1191 - result = f.read() 1192 - else: 1193 - # Generate new content 1194 - if output_exists and force: 1195 - logging.info("Force regenerating: %s", output_path) 717 + # Filter out start events from providers (we already emitted ours) 718 + if data.get("event") == "start": 719 + return 1196 720 1197 - # Capture state before pre-hook for dry-run comparison 1198 - before_values = { 1199 - "transcript": transcript, 1200 - "prompt": prompt, 1201 - "system_instruction": system_instruction, 1202 - } 721 + emit_event(data) 1203 722 1204 - # Run pre-processing hooks using helper 1205 - modifications, hook_info = _execute_pre_hooks( 1206 - meta, 1207 - modifiable={ 1208 - "prompt": prompt, 1209 - "system_instruction": system_instruction, 1210 - "transcript": transcript, 1211 - }, 1212 - output_path=output_path, 1213 - day=day, 1214 - segment=segment, 1215 - span_mode=span_mode, 1216 - ) 723 + await provider_mod.run_tools(config=config, on_event=agent_emit_event) 1217 724 1218 - # Apply modifications 1219 - transcript = modifications.get("transcript", transcript) 1220 - prompt = modifications.get("prompt", prompt) 1221 - system_instruction = modifications.get("system_instruction", system_instruction) 725 + else: 726 + # Generator path - single-shot generation 727 + from think.models import generate_with_result 728 + from think.muse import key_to_context 1222 729 1223 - # Current values after hook 1224 - current_values = { 1225 - "transcript": transcript, 1226 - "prompt": prompt, 1227 - "system_instruction": system_instruction, 1228 - } 730 + transcript = config.get("transcript", "") 731 + prompt = config.get("prompt", "") 732 + system_instruction = config.get("system_instruction", "") 733 + meta = config.get("meta", {}) 1229 734 1230 - # Dry-run mode: emit context and return without LLM call 1231 - if dry_run: 1232 - dry_run_event = _build_dry_run_event( 1233 - "generate", 1234 - name, 1235 - provider, 1236 - model, 1237 - config, 1238 - inputs, 1239 - hook_info, 1240 - before_values, 1241 - current_values, 1242 - ) 1243 - emit_event(dry_run_event) 1244 - return 735 + # Get generation parameters 736 + thinking_budget = meta.get("thinking_budget") or 8192 * 3 737 + max_output_tokens = meta.get("max_output_tokens") or 8192 * 6 738 + is_json_output = output_format == "json" 1245 739 1246 - gen_result = generate_agent_output( 1247 - transcript, 1248 - prompt, 1249 - name=name, 1250 - json_output=is_json_output, 740 + context = key_to_context(name) 741 + gen_result = generate_with_result( 742 + contents=[transcript, prompt] if transcript else [prompt], 743 + context=context, 744 + temperature=0.3, 745 + max_output_tokens=max_output_tokens, 746 + thinking_budget=thinking_budget, 1251 747 system_instruction=system_instruction, 1252 - thinking_budget=meta_thinking_budget, 1253 - max_output_tokens=meta_max_output_tokens, 1254 - return_result=True, 748 + json_output=is_json_output, 1255 749 ) 750 + 1256 751 result = gen_result["text"] 1257 752 usage_data = gen_result.get("usage") 1258 753 1259 - # Run post-processing hooks using helper 1260 - result = _execute_post_hooks( 1261 - result, 1262 - meta, 1263 - transcript=transcript, 1264 - output_path=output_path, 1265 - day=day, 1266 - segment=segment, 1267 - span_mode=span_mode, 1268 - name=name, 1269 - ) 754 + # Run post-hooks 755 + result = _run_post_hooks(result, config) 1270 756 1271 - # Write output file (agents.py owns output writing) 1272 - if output_path and result: 1273 - _write_output(output_path, result, output_format or "md") 757 + # Write output 758 + if output_path and result: 759 + _write_output(output_path, result) 1274 760 1275 - # Emit finish event with result 1276 - finish_event: dict[str, Any] = { 1277 - "event": "finish", 1278 - "ts": now_ms(), 1279 - "result": result, 1280 - } 1281 - if usage_data: 1282 - finish_event["usage"] = usage_data 1283 - # Include handoff config for cortex to spawn follow-up agent 1284 - if config.get("handoff"): 1285 - finish_event["handoff"] = config["handoff"] 761 + # Emit finish event 762 + finish_event: dict[str, Any] = { 763 + "event": "finish", 764 + "ts": now_ms(), 765 + "result": result, 766 + } 767 + if usage_data: 768 + finish_event["usage"] = usage_data 769 + if config.get("handoff"): 770 + finish_event["handoff"] = config["handoff"] 771 + emit_event(finish_event) 1286 772 1287 - emit_event(finish_event) 773 + # Log completion 774 + if config.get("day"): 775 + day_log(config["day"], f"agent {name} ok") 1288 776 1289 - # Log completion (only for day-based requests) 1290 - if day: 1291 - msg = f"generate {name} ok" 1292 - if force: 1293 - msg += " --force" 1294 - day_log(day, msg) 777 + 778 + # ============================================================================= 779 + # Utility Functions 780 + # ============================================================================= 781 + 782 + 783 + def scan_day(day: str) -> dict[str, list[str]]: 784 + """Return lists of processed and pending daily generator output files. 785 + 786 + Only scans daily generators (schedule='daily'). Segment generators are 787 + stored within segment directories and are not included here. 788 + """ 789 + day_dir = day_path(day) 790 + daily_generators = get_muse_configs( 791 + has_tools=False, has_output=True, schedule="daily", include_disabled=True 792 + ) 793 + processed: list[str] = [] 794 + pending: list[str] = [] 795 + for key, meta in sorted(daily_generators.items()): 796 + output_format = meta.get("output") 797 + output_path = get_output_path(day_dir, key, output_format=output_format) 798 + if output_path.exists(): 799 + processed.append(os.path.join("agents", output_path.name)) 800 + else: 801 + pending.append(os.path.join("agents", output_path.name)) 802 + return {"processed": sorted(processed), "repairable": sorted(pending)} 1295 803 1296 804 1297 805 # ============================================================================= ··· 1300 808 1301 809 1302 810 async def main_async() -> None: 1303 - """NDJSON-based CLI for agents and generators. 1304 - 1305 - Routes based on config: 1306 - - 'output' field present (no 'tools') -> generator (transcript analysis) 1307 - - Everything else -> agent (with or without tools, via provider) 1308 - """ 811 + """NDJSON-based CLI for agents and generators.""" 1309 812 parser = argparse.ArgumentParser( 1310 813 description="solstone Agent CLI - Accepts NDJSON input via stdin" 1311 814 ) ··· 1319 822 dry_run = args.dry_run 1320 823 1321 824 app_logger = setup_logging(args.verbose) 1322 - 1323 - # Always write to stdout only 1324 825 event_writer = JSONEventWriter(None) 1325 826 1326 827 def emit_event(data: Event) -> None: ··· 1329 830 event_writer.emit(data) 1330 831 1331 832 try: 1332 - # NDJSON input mode from stdin only 1333 833 app_logger.info("Processing NDJSON input from stdin") 1334 834 for line in sys.stdin: 1335 835 line = line.strip() ··· 1337 837 continue 1338 838 1339 839 try: 1340 - # Parse NDJSON line - raw request from cortex 1341 840 request = json.loads(line) 1342 - 1343 - # Hydrate config: load agent definition, merge request, resolve provider 1344 841 config = hydrate_config(request) 1345 842 1346 - # Validate config 1347 843 error = validate_config(config) 1348 844 if error: 1349 845 emit_event({"event": "error", "error": error, "ts": now_ms()}) 1350 846 continue 1351 847 1352 - # Route based on config type: tools → run_tools, else → run_generate 1353 - has_tools = bool(config.get("tools")) 1354 - 1355 - if not has_tools: 1356 - # Generate path: single-shot generation with opt-in features 1357 - app_logger.debug(f"Processing generate: {config.get('name')}") 1358 - _run_generate(config, emit_event, dry_run=dry_run) 1359 - 1360 - else: 1361 - # Agent: with or without tools (conversational or tool-using) 1362 - # Extract provider to route to correct module 1363 - from .providers import PROVIDER_REGISTRY, get_provider_module 1364 - 1365 - provider = config.get("provider", "google") 1366 - name = config.get("name", "default") 1367 - model = config.get("model") 1368 - 1369 - app_logger.debug(f"Processing agent: provider={provider}") 1370 - 1371 - # Route to appropriate provider module 1372 - if provider in PROVIDER_REGISTRY: 1373 - provider_mod = get_provider_module(provider) 1374 - else: 1375 - # Explicit error for unknown providers 1376 - valid = ", ".join(sorted(PROVIDER_REGISTRY.keys())) 1377 - raise ValueError( 1378 - f"Unknown provider: {provider!r}. Valid providers: {valid}" 1379 - ) 1380 - 1381 - # Assemble inputs if day is specified (transcript loading) 1382 - inputs: InputContext | None = None 1383 - if config.get("day"): 1384 - inputs = assemble_inputs(config) 1385 - 1386 - # Get metadata for hooks (from inputs if available, else from config) 1387 - meta = inputs.meta if inputs else config 1388 - 1389 - # Emit unified start event (agents.py owns this) 1390 - _emit_start_event( 1391 - emit_event, 1392 - name, 1393 - model, 1394 - provider, 1395 - config.get("prompt", ""), 1396 - continue_from=config.get("continue_from"), 1397 - ) 1398 - 1399 - # Handle skip conditions using helper 1400 - if inputs and _handle_skip(inputs, name, "agent", emit_event): 1401 - continue 1402 - 1403 - # Pass transcript and system instruction to provider if inputs assembled 1404 - if inputs and not config.get("continue_from"): 1405 - # Warn if both day and continue_from are specified 1406 - if config.get("continue_from"): 1407 - logging.warning( 1408 - "Both 'day' and 'continue_from' specified; " 1409 - "continue_from takes precedence, transcript ignored" 1410 - ) 1411 - else: 1412 - config["transcript"] = inputs.transcript 1413 - if not config.get("system_instruction"): 1414 - config["system_instruction"] = inputs.system_instruction 1415 - 1416 - # Capture state before pre-hook for dry-run comparison 1417 - before_values = { 1418 - "prompt": config.get("prompt", ""), 1419 - "system_instruction": config.get("system_instruction", ""), 1420 - "user_instruction": config.get("user_instruction", ""), 1421 - "extra_context": config.get("extra_context", ""), 1422 - "transcript": config.get("transcript", ""), 1423 - } 1424 - 1425 - # Run pre-processing hooks using helper 1426 - # Note: before_values already contains transcript 1427 - modifications, hook_info = _execute_pre_hooks( 1428 - meta, 1429 - modifiable=before_values.copy(), 1430 - output_path=inputs.output_path if inputs else None, 1431 - day=inputs.day if inputs else None, 1432 - segment=inputs.segment if inputs else None, 1433 - span_mode=inputs.span_mode if inputs else False, 1434 - ) 1435 - 1436 - # Apply modifications to config 1437 - for key in ( 1438 - "prompt", 1439 - "system_instruction", 1440 - "user_instruction", 1441 - "extra_context", 1442 - "transcript", 1443 - ): 1444 - if key in modifications: 1445 - config[key] = modifications[key] 1446 - 1447 - # Current values after hook 1448 - current_values = { 1449 - "prompt": config.get("prompt", ""), 1450 - "system_instruction": config.get("system_instruction", ""), 1451 - "user_instruction": config.get("user_instruction", ""), 1452 - "extra_context": config.get("extra_context", ""), 1453 - "transcript": config.get("transcript", ""), 1454 - } 1455 - 1456 - # Dry-run mode: emit context and return without LLM call 1457 - if dry_run: 1458 - dry_run_event = _build_dry_run_event( 1459 - "agent", 1460 - name, 1461 - provider, 1462 - model, 1463 - config, 1464 - inputs, 1465 - hook_info, 1466 - before_values, 1467 - current_values, 1468 - ) 1469 - emit_event(dry_run_event) 1470 - continue 1471 - 1472 - handoff_config = config.get("handoff") 1473 - output_path = inputs.output_path if inputs else None 1474 - output_format = inputs.output_format if inputs else None 1475 - 1476 - # Create event handler that intercepts finish for post-hooks, 1477 - # output writing, and handoff 1478 - def agent_emit_event(data: Event) -> None: 1479 - if data.get("event") == "finish": 1480 - result = data.get("result", "") 1481 - 1482 - # Apply post-processing hooks using helper 1483 - result = _execute_post_hooks( 1484 - result, 1485 - meta, 1486 - transcript=config.get("transcript", ""), 1487 - output_path=output_path, 1488 - day=inputs.day if inputs else None, 1489 - segment=inputs.segment if inputs else None, 1490 - span_mode=inputs.span_mode if inputs else False, 1491 - name=name, 1492 - ) 1493 - 1494 - # Update data if result was transformed 1495 - if result != data.get("result", ""): 1496 - data = {**data, "result": result} 1497 - 1498 - # Write output file (agents.py owns output writing) 1499 - if output_path and result: 1500 - _write_output( 1501 - output_path, result, output_format or "md" 1502 - ) 1503 - 1504 - # Include handoff config for cortex 1505 - if handoff_config: 1506 - data = {**data, "handoff": handoff_config} 1507 - 1508 - # Filter out start events from providers (we already emitted ours) 1509 - if data.get("event") == "start": 1510 - return 1511 - 1512 - emit_event(data) 1513 - 1514 - # Pass complete config to provider 1515 - await provider_mod.run_tools( 1516 - config=config, 1517 - on_event=agent_emit_event, 1518 - ) 1519 - 1520 - # Log completion for day-based requests 1521 - if inputs and inputs.day: 1522 - day_log(inputs.day, f"agent {name} ok") 848 + enrich_config(config) 849 + await _run_agent(config, emit_event, dry_run=dry_run) 1523 850 1524 851 except json.JSONDecodeError as e: 1525 852 emit_event( ··· 1539 866 } 1540 867 ) 1541 868 1542 - except Exception as exc: # pragma: no cover - unexpected 869 + except Exception as exc: 1543 870 err = { 1544 871 "event": "error", 1545 872 "error": str(exc), ··· 1554 881 1555 882 def main() -> None: 1556 883 """Entry point wrapper.""" 884 + asyncio.run(main_async()) 1557 885 1558 - asyncio.run(main_async()) 886 + 887 + __all__ = [ 888 + "format_tool_summary", 889 + "parse_agent_events_to_turns", 890 + "hydrate_config", 891 + "expand_tools", 892 + "validate_config", 893 + "enrich_config", 894 + "scan_day", 895 + ]
+1 -1
think/muse.py
··· 705 705 return _load_hook_function(config, "pre", "pre_process") 706 706 707 707 708 - # Type aliases for hook context (actual TypedDicts defined in agents.py) 708 + # Type aliases for hook context - hooks receive the full config dict 709 709 HookContext = dict 710 710 PreHookContext = dict
+39 -26
think/providers/anthropic.py
··· 53 53 GenerateResult, 54 54 JSONEventCallback, 55 55 ThinkingEvent, 56 - extract_agent_config, 57 56 extract_tool_result, 58 57 ) 59 58 ··· 258 257 user_instruction, extra_context, model, etc. 259 258 on_event: Optional event callback 260 259 """ 261 - ac = extract_agent_config(config, default_max_tokens=_DEFAULT_MAX_TOKENS) 260 + # Extract config values directly 261 + prompt = config.get("prompt", "") 262 + model = config.get("model", _DEFAULT_MODEL) 263 + system_instruction = config.get("system_instruction") 264 + user_instruction = config.get("user_instruction") 265 + extra_context = config.get("extra_context") 266 + transcript = config.get("transcript") 267 + mcp_server_url = config.get("mcp_server_url") 268 + tools_filter = config.get("tools") 269 + max_output_tokens = config.get("max_output_tokens", _DEFAULT_MAX_TOKENS) 270 + thinking_budget_config = config.get("thinking_budget") 271 + continue_from = config.get("continue_from") 272 + agent_id = config.get("agent_id") 273 + name = config.get("name") 274 + 262 275 callback = JSONEventCallback(on_event) 263 276 264 277 try: ··· 271 284 # Note: Start event is emitted by agents.py (unified event ownership) 272 285 273 286 # Build initial messages - check for continuation first 274 - if ac.continue_from: 287 + if continue_from: 275 288 # Load previous conversation history using shared function 276 289 from ..agents import parse_agent_events_to_turns 277 290 278 - messages = parse_agent_events_to_turns(ac.continue_from) 291 + messages = parse_agent_events_to_turns(continue_from) 279 292 # Add new prompt as continuation 280 - messages.append({"role": "user", "content": ac.prompt}) 293 + messages.append({"role": "user", "content": prompt}) 281 294 else: 282 295 # Fresh conversation 283 296 messages: list[MessageParam] = [] 284 297 # Prepend transcript if provided (from day/segment input assembly) 285 - if ac.transcript: 286 - messages.append({"role": "user", "content": ac.transcript}) 287 - if ac.extra_context: 288 - messages.append({"role": "user", "content": ac.extra_context}) 289 - if ac.user_instruction: 290 - messages.append({"role": "user", "content": ac.user_instruction}) 291 - messages.append({"role": "user", "content": ac.prompt}) 298 + if transcript: 299 + messages.append({"role": "user", "content": transcript}) 300 + if extra_context: 301 + messages.append({"role": "user", "content": extra_context}) 302 + if user_instruction: 303 + messages.append({"role": "user", "content": user_instruction}) 304 + messages.append({"role": "user", "content": prompt}) 292 305 293 306 # Initialize tools and executor if MCP server URL provided 294 - if ac.mcp_server_url: 295 - async with create_mcp_client(str(ac.mcp_server_url)) as mcp: 296 - if ac.tools and isinstance(ac.tools, list): 297 - logger.info(f"Using tool filter with allowed tools: {ac.tools}") 307 + if mcp_server_url: 308 + async with create_mcp_client(str(mcp_server_url)) as mcp: 309 + if tools_filter and isinstance(tools_filter, list): 310 + logger.info(f"Using tool filter with allowed tools: {tools_filter}") 298 311 299 - tools = await _get_mcp_tools(mcp, ac.tools) 312 + tools = await _get_mcp_tools(mcp, tools_filter) 300 313 tool_executor = ToolExecutor( 301 - mcp, callback, agent_id=ac.agent_id, name=ac.name 314 + mcp, callback, agent_id=agent_id, name=name 302 315 ) 303 316 304 317 thinking_budget, effective_max_tokens = _resolve_agent_thinking_params( 305 - ac.max_output_tokens, ac.thinking_budget 318 + max_output_tokens, thinking_budget_config 306 319 ) 307 320 308 321 for _ in range(_MAX_TOOL_ITERATIONS): 309 322 # Build request params - thinking always enabled 310 323 create_params = { 311 - "model": ac.model, 324 + "model": model, 312 325 "max_tokens": effective_max_tokens, 313 - "system": ac.system_instruction, 326 + "system": system_instruction, 314 327 "messages": messages, 315 328 "thinking": { 316 329 "type": "enabled", ··· 330 343 elif getattr(block, "type", None) == "tool_use": 331 344 tool_uses.append(block) 332 345 elif isinstance(block, (ThinkingBlock, RedactedThinkingBlock)): 333 - _emit_thinking_event(block, ac.model, callback) 346 + _emit_thinking_event(block, model, callback) 334 347 335 348 messages.append({"role": "assistant", "content": response.content}) 336 349 ··· 383 396 else: 384 397 # No MCP tools - single response only 385 398 thinking_budget, effective_max_tokens = _resolve_agent_thinking_params( 386 - ac.max_output_tokens, ac.thinking_budget 399 + max_output_tokens, thinking_budget_config 387 400 ) 388 401 create_params = { 389 - "model": ac.model, 402 + "model": model, 390 403 "max_tokens": effective_max_tokens, 391 - "system": ac.system_instruction, 404 + "system": system_instruction, 392 405 "messages": messages, 393 406 "thinking": { 394 407 "type": "enabled", ··· 403 416 if getattr(block, "type", None) == "text": 404 417 final_text += block.text 405 418 elif isinstance(block, (ThinkingBlock, RedactedThinkingBlock)): 406 - _emit_thinking_event(block, ac.model, callback) 419 + _emit_thinking_event(block, model, callback) 407 420 408 421 finish_event = { 409 422 "event": "finish",
+37 -32
think/providers/google.py
··· 47 47 GenerateResult, 48 48 JSONEventCallback, 49 49 ThinkingEvent, 50 - extract_agent_config, 51 50 extract_tool_result, 52 51 ) 53 52 ··· 540 539 user_instruction, extra_context, model, etc. 541 540 on_event: Optional event callback 542 541 """ 543 - ac = extract_agent_config(config, default_max_tokens=_DEFAULT_MAX_TOKENS) 542 + # Extract config values directly 543 + prompt = config.get("prompt", "") 544 + model = config.get("model", _DEFAULT_MODEL) 545 + system_instruction = config.get("system_instruction") 546 + user_instruction = config.get("user_instruction") 547 + extra_context = config.get("extra_context") 548 + transcript = config.get("transcript") 549 + mcp_server_url = config.get("mcp_server_url") 550 + tools = config.get("tools") 551 + max_output_tokens = config.get("max_output_tokens", _DEFAULT_MAX_TOKENS) 552 + thinking_budget = config.get("thinking_budget") 553 + continue_from = config.get("continue_from") 554 + agent_id = config.get("agent_id") 555 + name = config.get("name") 556 + 544 557 callback = JSONEventCallback(on_event) 545 558 546 559 try: ··· 552 565 # Note: Start event is emitted by agents.py (unified event ownership) 553 566 554 567 # Build history - check for continuation first 555 - if ac.continue_from: 568 + if continue_from: 556 569 # Load previous conversation history using shared function 557 570 from ..agents import parse_agent_events_to_turns 558 571 559 - turns = parse_agent_events_to_turns(ac.continue_from) 572 + turns = parse_agent_events_to_turns(continue_from) 560 573 # Convert to Google's format 561 574 history = [] 562 575 for turn in turns: ··· 568 581 # Fresh conversation - convert generic turns to Google format 569 582 history = [] 570 583 # Prepend transcript if provided (from day/segment input assembly) 571 - if ac.transcript: 584 + if transcript: 572 585 history.append( 573 - types.Content(role="user", parts=[types.Part(text=ac.transcript)]) 586 + types.Content(role="user", parts=[types.Part(text=transcript)]) 574 587 ) 575 - if ac.extra_context: 588 + if extra_context: 576 589 history.append( 577 - types.Content( 578 - role="user", parts=[types.Part(text=ac.extra_context)] 579 - ) 590 + types.Content(role="user", parts=[types.Part(text=extra_context)]) 580 591 ) 581 - if ac.user_instruction: 592 + if user_instruction: 582 593 history.append( 583 594 types.Content( 584 - role="user", parts=[types.Part(text=ac.user_instruction)] 595 + role="user", parts=[types.Part(text=user_instruction)] 585 596 ) 586 597 ) 587 598 ··· 593 604 594 605 # Create fresh chat session 595 606 chat = client.aio.chats.create( 596 - model=ac.model, 597 - config=types.GenerateContentConfig( 598 - system_instruction=ac.system_instruction 599 - ), 607 + model=model, 608 + config=types.GenerateContentConfig(system_instruction=system_instruction), 600 609 history=history, 601 610 ) 602 611 ··· 604 613 tool_call_count = 0 605 614 606 615 # Configure tools if MCP server URL provided 607 - if ac.mcp_server_url: 616 + if mcp_server_url: 608 617 # Create MCP client and attach hooks 609 - async with create_mcp_client(str(ac.mcp_server_url)) as mcp: 618 + async with create_mcp_client(str(mcp_server_url)) as mcp: 610 619 # Attach tool logging hooks to the MCP session 611 - tool_hooks = ToolLoggingHooks( 612 - callback, agent_id=ac.agent_id, name=ac.name 613 - ) 620 + tool_hooks = ToolLoggingHooks(callback, agent_id=agent_id, name=name) 614 621 tool_hooks.attach(mcp.session) 615 622 616 623 # Configure function calling mode based on tool filtering 617 - if ac.tools and isinstance(ac.tools, list): 618 - logger.info(f"Filtering tools to: {ac.tools}") 624 + if tools and isinstance(tools, list): 625 + logger.info(f"Filtering tools to: {tools}") 619 626 function_calling_config = types.FunctionCallingConfig( 620 627 mode="ANY", # Restrict to only allowed functions 621 - allowed_function_names=ac.tools, 628 + allowed_function_names=tools, 622 629 ) 623 630 else: 624 631 function_calling_config = types.FunctionCallingConfig(mode="AUTO") 625 632 626 633 total_tokens, effective_thinking_budget = ( 627 - _compute_agent_thinking_params( 628 - ac.max_output_tokens, ac.thinking_budget 629 - ) 634 + _compute_agent_thinking_params(max_output_tokens, thinking_budget) 630 635 ) 631 636 632 637 cfg = types.GenerateContentConfig( ··· 642 647 ) 643 648 644 649 # Send the message - SDK handles automatic function calling 645 - response = await chat.send_message(ac.prompt, config=cfg) 646 - _emit_thinking_events(response, ac.model, callback) 650 + response = await chat.send_message(prompt, config=cfg) 651 + _emit_thinking_events(response, model, callback) 647 652 648 653 # Capture tool call count from hooks 649 654 tool_call_count = tool_hooks._counter 650 655 else: 651 656 # No MCP tools - just basic config 652 657 total_tokens, effective_thinking_budget = _compute_agent_thinking_params( 653 - ac.max_output_tokens, ac.thinking_budget 658 + max_output_tokens, thinking_budget 654 659 ) 655 660 656 661 cfg = types.GenerateContentConfig( ··· 661 666 ), 662 667 ) 663 668 664 - response = await chat.send_message(ac.prompt, config=cfg) 665 - _emit_thinking_events(response, ac.model, callback) 669 + response = await chat.send_message(prompt, config=cfg) 670 + _emit_thinking_events(response, model, callback) 666 671 667 672 # Extract finish reason for diagnostics and user-friendly messages 668 673 finish_reason = _extract_finish_reason(response)
+38 -27
think/providers/openai.py
··· 80 80 GenerateResult, 81 81 JSONEventCallback, 82 82 ThinkingEvent, 83 - extract_agent_config, 84 83 ) 85 84 86 85 ··· 223 222 user_instruction, extra_context, model, etc. 224 223 on_event: Optional event callback 225 224 """ 226 - ac = extract_agent_config(config, default_max_tokens=_DEFAULT_MAX_TOKENS) 225 + # Extract config values directly 226 + prompt = config.get("prompt", "") 227 + model = config.get("model", GPT_5) 228 + system_instruction = config.get("system_instruction") 229 + user_instruction = config.get("user_instruction") 230 + extra_context = config.get("extra_context") 231 + transcript = config.get("transcript") 232 + mcp_server_url = config.get("mcp_server_url") 233 + tools = config.get("tools") 234 + max_output_tokens = config.get("max_output_tokens", _DEFAULT_MAX_TOKENS) 235 + continue_from = config.get("continue_from") 236 + agent_id = config.get("agent_id") 237 + name = config.get("name") 227 238 max_turns = config.get("max_turns", _DEFAULT_MAX_TURNS) 228 239 229 - LOG.info("Running agent with model %s", ac.model) 240 + LOG.info("Running agent with model %s", model) 230 241 cb = JSONEventCallback(on_event) 231 242 232 243 # Note: Start event is emitted by agents.py (unified event ownership) 233 244 234 245 # Model settings: always enable reasoning with detailed summaries 235 246 model_settings = ModelSettings( 236 - max_tokens=ac.max_output_tokens, 247 + max_tokens=max_output_tokens, 237 248 reasoning=_DEFAULT_REASONING, 238 249 ) 239 250 240 251 # Initialize MCP server 241 252 mcp_server = None 242 - if ac.mcp_server_url: 243 - http_uri = _normalize_streamable_http_uri(str(ac.mcp_server_url)) 253 + if mcp_server_url: 254 + http_uri = _normalize_streamable_http_uri(str(mcp_server_url)) 244 255 245 256 # Configure tool filter if tools are specified 246 257 tool_filter = None 247 - if ac.tools and isinstance(ac.tools, list) and ToolFilterStatic: 258 + if tools and isinstance(tools, list) and ToolFilterStatic: 248 259 # Create a tool filter with allowed tools 249 - tool_filter = ToolFilterStatic(allowed_tool_names=ac.tools) 250 - LOG.info(f"Using tool filter with allowed tools: {ac.tools}") 251 - elif ac.tools: 260 + tool_filter = ToolFilterStatic(allowed_tool_names=tools) 261 + LOG.info(f"Using tool filter with allowed tools: {tools}") 262 + elif tools: 252 263 LOG.warning( 253 264 "Tool filtering requested but ToolFilterStatic not available in this version" 254 265 ) 255 266 256 267 mcp_params = {"url": http_uri} 257 268 headers: dict[str, str] = {} 258 - if ac.agent_id: 259 - headers["X-Agent-Id"] = str(ac.agent_id) 260 - if ac.name: 261 - headers["X-Agent-Name"] = ac.name 269 + if agent_id: 270 + headers["X-Agent-Id"] = str(agent_id) 271 + if name: 272 + headers["X-Agent-Name"] = name 262 273 if headers: 263 274 mcp_params["headers"] = headers 264 275 ··· 280 291 finish_reason_holder = [None] 281 292 282 293 # Create session and load history if continuing conversation 283 - session_id = ac.continue_from or ac.agent_id or f"session-{int(time.time())}" 294 + session_id = continue_from or agent_id or f"session-{int(time.time())}" 284 295 session = SQLiteSession(session_id=session_id, db_path=":memory:") 285 296 286 297 # Load conversation history if continuing 287 - if ac.continue_from: 298 + if continue_from: 288 299 from ..agents import parse_agent_events_to_turns 289 300 290 - turns = parse_agent_events_to_turns(ac.continue_from) 301 + turns = parse_agent_events_to_turns(continue_from) 291 302 if turns: 292 303 items = _convert_turns_to_items(turns) 293 304 await session.add_items(items) ··· 295 306 # Fresh conversation - add transcript, context and user instruction as initial messages 296 307 initial_turns = [] 297 308 # Prepend transcript if provided (from day/segment input assembly) 298 - if ac.transcript: 299 - initial_turns.append({"role": "user", "content": ac.transcript}) 300 - if ac.extra_context: 301 - initial_turns.append({"role": "user", "content": ac.extra_context}) 302 - if ac.user_instruction: 303 - initial_turns.append({"role": "user", "content": ac.user_instruction}) 309 + if transcript: 310 + initial_turns.append({"role": "user", "content": transcript}) 311 + if extra_context: 312 + initial_turns.append({"role": "user", "content": extra_context}) 313 + if user_instruction: 314 + initial_turns.append({"role": "user", "content": user_instruction}) 304 315 if initial_turns: 305 316 initial_items = _convert_turns_to_items(initial_turns) 306 317 await session.add_items(initial_items) ··· 324 335 mcp_servers_list = [mcp_server] if mcp_server else [] 325 336 agent = Agent( 326 337 name="solstoneCLI", 327 - instructions=ac.system_instruction, 328 - model=ac.model, 338 + instructions=system_instruction, 339 + model=model, 329 340 model_settings=model_settings, 330 341 mcp_servers=mcp_servers_list, 331 342 ) 332 343 333 344 result = Runner.run_streamed( 334 345 agent, 335 - input=ac.prompt, 346 + input=prompt, 336 347 session=session, 337 348 run_config=RunConfig(tracing_disabled=True), # per docs 338 349 max_turns=max_turns, ··· 438 449 thinking_event: ThinkingEvent = { 439 450 "event": "thinking", 440 451 "summary": summary_text, 441 - "model": ac.model, 452 + "model": model, 442 453 "ts": now_ms(), 443 454 } 444 455 cb.emit(thinking_event)
-79
think/providers/shared.py
··· 12 12 13 13 from __future__ import annotations 14 14 15 - from dataclasses import dataclass 16 15 from typing import Any, Callable, Literal, Optional, Union 17 16 18 17 from typing_extensions import Required, TypedDict ··· 152 151 153 152 154 153 # --------------------------------------------------------------------------- 155 - # Agent Config Extraction 156 - # --------------------------------------------------------------------------- 157 - 158 - 159 - @dataclass 160 - class AgentConfig: 161 - """Validated agent configuration extracted from config dict.""" 162 - 163 - prompt: str 164 - model: str 165 - name: str 166 - agent_id: Optional[str] 167 - max_output_tokens: int 168 - thinking_budget: Optional[int] 169 - mcp_server_url: Optional[str] 170 - continue_from: Optional[str] 171 - system_instruction: str 172 - extra_context: str 173 - user_instruction: str 174 - tools: Optional[list[str]] 175 - provider: str 176 - 177 - # Transcript from journal (if day/segment specified) 178 - transcript: str 179 - 180 - # Original config for provider-specific access 181 - raw_config: dict 182 - 183 - 184 - def extract_agent_config(config: dict, default_max_tokens: int = 8192) -> AgentConfig: 185 - """Extract and validate agent configuration. 186 - 187 - Parameters 188 - ---------- 189 - config 190 - Raw config dict from cortex. 191 - default_max_tokens 192 - Default max_output_tokens if not specified in config. 193 - 194 - Returns 195 - ------- 196 - AgentConfig 197 - Validated configuration dataclass. 198 - 199 - Raises 200 - ------ 201 - ValueError 202 - If required fields are missing. 203 - """ 204 - prompt = config.get("prompt", "") 205 - if not prompt: 206 - raise ValueError("Missing 'prompt' in config") 207 - 208 - model = config.get("model") 209 - if not model: 210 - raise ValueError("Missing 'model' in config - should be set by Cortex") 211 - 212 - return AgentConfig( 213 - prompt=prompt, 214 - model=model, 215 - name=config.get("name", "default"), 216 - agent_id=config.get("agent_id"), 217 - max_output_tokens=config.get("max_output_tokens", default_max_tokens), 218 - thinking_budget=config.get("thinking_budget"), 219 - mcp_server_url=config.get("mcp_server_url"), 220 - continue_from=config.get("continue_from"), 221 - system_instruction=config.get("system_instruction", ""), 222 - extra_context=config.get("extra_context", ""), 223 - user_instruction=config.get("user_instruction", ""), 224 - tools=config.get("tools"), 225 - provider=config.get("provider", "google"), 226 - transcript=config.get("transcript", ""), 227 - raw_config=config, 228 - ) 229 - 230 - 231 - # --------------------------------------------------------------------------- 232 154 # MCP Tool Result Extraction 233 155 # --------------------------------------------------------------------------- 234 156 ··· 275 197 "GenerateResult", 276 198 "JSONEventCallback", 277 199 "ThinkingEvent", 278 - "extract_agent_config", 279 200 "extract_tool_result", 280 201 ]