personal memory agent
0
fork

Configure Feed

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

feat: add template_vars to pre-hook contract

Pre-hooks can now return {"template_vars": {"key": "value"}} to inject
named variables into agent text fields (user_instruction, transcript,
prompt) via string.Template.safe_substitute(). Variables auto-expand
with capitalize convention matching load_prompt ( and ).
Template vars are applied after direct field modifications and before
dry-run event emission.

+235
+212
tests/test_output_hooks.py
··· 16 16 import shutil 17 17 from pathlib import Path 18 18 19 + from think.agents import _apply_template_vars 19 20 from think.talent import load_post_hook, load_pre_hook 20 21 from think.utils import day_path 21 22 ··· 432 433 assert prompt_found, f"Expected [pre-processed] in contents: {contents}" 433 434 434 435 # Verify generator still completed successfully 436 + finish_events = [e for e in events if e["event"] == "finish"] 437 + assert len(finish_events) == 1 438 + 439 + 440 + # ---- Pre-hook Template Vars Tests ---- 441 + 442 + 443 + def test_template_vars_basic_substitution(): 444 + """Test basic template var substitution in user_instruction.""" 445 + config = {"user_instruction": "Hello $name"} 446 + 447 + _apply_template_vars(config, {"name": "world"}) 448 + 449 + assert config["user_instruction"] == "Hello world" 450 + 451 + 452 + def test_template_vars_auto_capitalize(): 453 + """Test auto-capitalized template key/value expansion.""" 454 + config = {"prompt": "$greeting and $Greeting"} 455 + 456 + _apply_template_vars(config, {"greeting": "hello"}) 457 + 458 + assert config["prompt"] == "hello and Hello" 459 + 460 + 461 + def test_template_vars_all_fields(): 462 + """Test substitution applies to all supported text fields.""" 463 + config = { 464 + "user_instruction": "$x", 465 + "transcript": "$x", 466 + "prompt": "$x", 467 + } 468 + 469 + _apply_template_vars(config, {"x": "replaced"}) 470 + 471 + assert config["user_instruction"] == "replaced" 472 + assert config["transcript"] == "replaced" 473 + assert config["prompt"] == "replaced" 474 + 475 + 476 + def test_template_vars_no_system_instruction(): 477 + """Test substitution does not touch system_instruction.""" 478 + config = { 479 + "system_instruction": "$x", 480 + "user_instruction": "$x", 481 + } 482 + 483 + _apply_template_vars(config, {"x": "val"}) 484 + 485 + assert config["system_instruction"] == "$x" 486 + assert config["user_instruction"] == "val" 487 + 488 + 489 + def test_template_vars_empty_string_value(): 490 + """Test empty string values substitute without errors.""" 491 + config = {"prompt": "before $tag after"} 492 + 493 + _apply_template_vars(config, {"tag": ""}) 494 + 495 + assert config["prompt"] == "before after" 496 + 497 + 498 + def test_template_vars_dollar_in_value_not_reprocessed(): 499 + """Test substituted dollar signs in values are not reprocessed.""" 500 + config = {"prompt": "$var"} 501 + 502 + _apply_template_vars(config, {"var": "costs $100"}) 503 + 504 + assert config["prompt"] == "costs $100" 505 + 506 + 507 + def test_template_vars_missing_placeholder_left_alone(): 508 + """Test missing placeholders remain unchanged.""" 509 + config = {"prompt": "$defined and $undefined"} 510 + 511 + _apply_template_vars(config, {"defined": "yes"}) 512 + 513 + assert config["prompt"] == "yes and $undefined" 514 + 515 + 516 + def test_template_vars_empty_field_skipped(): 517 + """Test empty or missing supported fields are skipped safely.""" 518 + config = {"prompt": ""} 519 + 520 + _apply_template_vars(config, {"x": "val"}) 521 + assert config["prompt"] == "" 522 + 523 + config = {} 524 + _apply_template_vars(config, {"x": "val"}) 525 + assert "prompt" not in config 526 + 527 + 528 + def test_template_vars_popped_from_modifications(): 529 + """Test template_vars are applied and not copied into config.""" 530 + config = {} 531 + modifications = { 532 + "user_instruction": "Hello $name", 533 + "template_vars": {"name": "world"}, 534 + } 535 + 536 + template_vars = modifications.pop("template_vars", None) 537 + for key, value in modifications.items(): 538 + config[key] = value 539 + if template_vars: 540 + _apply_template_vars(config, template_vars) 541 + 542 + assert config["user_instruction"] == "Hello world" 543 + assert "template_vars" not in config 544 + 545 + 546 + def test_pre_hook_template_vars_integration(tmp_path, monkeypatch): 547 + """Test pre-hook template_vars reach the model as substituted text.""" 548 + mod = importlib.import_module("think.agents") 549 + copy_day(tmp_path) 550 + 551 + import think.talent 552 + 553 + monkeypatch.setattr(think.talent, "TALENT_DIR", tmp_path) 554 + 555 + prompt_file = tmp_path / "prehook_template_vars.md" 556 + prompt_file.write_text( 557 + '{\n "type": "generate",\n "title": "Prehook Template Vars",\n "schedule": "daily",\n "priority": 10,\n "output": "md",\n "hook": {"pre": "prehook_template_vars"},\n "load": {"transcripts": true, "percepts": true}\n}\n\nTalk about $topic' 558 + ) 559 + 560 + hook_file = tmp_path / "prehook_template_vars.py" 561 + hook_file.write_text(""" 562 + def pre_process(context): 563 + return {"template_vars": {"topic": "weather"}} 564 + """) 565 + 566 + received_kwargs = {} 567 + 568 + def mock_generate(*args, **kwargs): 569 + received_kwargs.update(kwargs) 570 + received_kwargs["contents"] = args[0] if args else kwargs.get("contents") 571 + return MOCK_RESULT 572 + 573 + import think.models 574 + 575 + monkeypatch.setattr(think.models, "generate_with_result", mock_generate) 576 + monkeypatch.setenv("GOOGLE_API_KEY", "x") 577 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 578 + 579 + config = { 580 + "name": "prehook_template_vars", 581 + "day": "20240101", 582 + "output": "md", 583 + "provider": "google", 584 + "model": "gemini-2.0-flash", 585 + } 586 + 587 + events = run_generator_with_config(mod, config, monkeypatch) 588 + 589 + contents = received_kwargs.get("contents", []) 590 + prompt_found = any("weather" in str(c) for c in contents) 591 + assert prompt_found, f"Expected weather in contents: {contents}" 592 + 593 + finish_events = [e for e in events if e["event"] == "finish"] 594 + assert len(finish_events) == 1 595 + 596 + 597 + def test_pre_hook_template_vars_with_field_mods(tmp_path, monkeypatch): 598 + """Test pre-hook can return field mods and template_vars together.""" 599 + mod = importlib.import_module("think.agents") 600 + copy_day(tmp_path) 601 + 602 + import think.talent 603 + 604 + monkeypatch.setattr(think.talent, "TALENT_DIR", tmp_path) 605 + 606 + prompt_file = tmp_path / "prehook_template_with_mods.md" 607 + prompt_file.write_text( 608 + '{\n "type": "generate",\n "title": "Prehook Template With Mods",\n "schedule": "daily",\n "priority": 10,\n "output": "md",\n "hook": {"pre": "prehook_template_with_mods"},\n "load": {"transcripts": true, "percepts": true}\n}\n\nOriginal prompt' 609 + ) 610 + 611 + hook_file = tmp_path / "prehook_template_with_mods.py" 612 + hook_file.write_text(""" 613 + def pre_process(context): 614 + return { 615 + "user_instruction": "Talk about $topic", 616 + "template_vars": {"topic": "music"}, 617 + } 618 + """) 619 + 620 + received_kwargs = {} 621 + 622 + def mock_generate(*args, **kwargs): 623 + received_kwargs.update(kwargs) 624 + received_kwargs["contents"] = args[0] if args else kwargs.get("contents") 625 + return MOCK_RESULT 626 + 627 + import think.models 628 + 629 + monkeypatch.setattr(think.models, "generate_with_result", mock_generate) 630 + monkeypatch.setenv("GOOGLE_API_KEY", "x") 631 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 632 + 633 + config = { 634 + "name": "prehook_template_with_mods", 635 + "day": "20240101", 636 + "output": "md", 637 + "provider": "google", 638 + "model": "gemini-2.0-flash", 639 + } 640 + 641 + events = run_generator_with_config(mod, config, monkeypatch) 642 + 643 + contents = received_kwargs.get("contents", []) 644 + prompt_found = any("Talk about music" in str(c) for c in contents) 645 + assert prompt_found, f"Expected Talk about music in contents: {contents}" 646 + 435 647 finish_events = [e for e in events if e["event"] == "finish"] 436 648 assert len(finish_events) == 1 437 649
+23
think/agents.py
··· 23 23 import traceback 24 24 from datetime import datetime, timezone 25 25 from pathlib import Path 26 + from string import Template 26 27 from typing import Any, Callable, Optional 27 28 28 29 from think.cluster import cluster, cluster_period, cluster_span ··· 645 646 return {} 646 647 647 648 649 + def _apply_template_vars(config: dict, template_vars: dict) -> None: 650 + """Substitute template_vars into text fields of config in-place. 651 + 652 + Expands each key with auto-capitalize convention (matching load_prompt): 653 + {"foo": "bar"} -> $foo="bar", $Foo="Bar" 654 + """ 655 + expanded = {} 656 + for key, value in template_vars.items(): 657 + str_value = str(value) 658 + expanded[key] = str_value 659 + expanded[key.capitalize()] = str_value.capitalize() 660 + 661 + for field in ("user_instruction", "transcript", "prompt"): 662 + text = config.get(field) 663 + if text: 664 + config[field] = Template(text).safe_substitute(expanded) 665 + 666 + 648 667 def _run_post_hooks(result: str, config: dict) -> str: 649 668 """Run post-processing hooks, return transformed result. 650 669 ··· 1080 1099 1081 1100 # Run pre-hooks 1082 1101 modifications = _run_pre_hooks(config) 1102 + template_vars = modifications.pop("template_vars", None) 1083 1103 for key, value in modifications.items(): 1084 1104 config[key] = value 1105 + if template_vars: 1106 + LOG.info("Pre-hook template_vars: %s", list(template_vars.keys())) 1107 + _apply_template_vars(config, template_vars) 1085 1108 1086 1109 # Handle skip conditions set by pre-hooks 1087 1110 skip_reason = config.get("skip_reason")