personal memory agent
0
fork

Configure Feed

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

talents: wire schema frontmatter field into dispatcher

Lazy-load a schema frontmatter field in get_talent(): require a
relative path, reject traversal and symlink escapes, parse the JSON,
validate Draft 2020-12 well-formedness, and attach the result as
config[\"json_schema\"]. Thread that through both
_execute_generate() call sites and propagate advisory
schema_validation onto finish events only when present.

Only _execute_generate is wired here. _execute_batch_generate does
not exist, and think/batch.py remains a separate, non-talent-aware
path that is explicitly out of scope for this lode.

Schema loading and validation happens lazily in get_talent(), not in
get_talent_configs(). This keeps discovery, status, and settings
paths tolerant of one broken unused talent instead of failing
broadly at metadata load time.

No real talent migrates here; Lode 3 will migrate talent/sense.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+436
+211
tests/test_generate_full.py
··· 14 14 import json 15 15 import os 16 16 from pathlib import Path 17 + from unittest.mock import MagicMock 17 18 18 19 from tests.conftest import copytree_tracked 19 20 from think.utils import day_path ··· 63 64 return events 64 65 65 66 67 + def _write_generator_file( 68 + tmp_path: Path, 69 + name: str, 70 + metadata: dict, 71 + body: str = "Test prompt", 72 + ) -> None: 73 + (tmp_path / f"{name}.md").write_text( 74 + f"{json.dumps(metadata, indent=2)}\n\n{body}\n", 75 + encoding="utf-8", 76 + ) 77 + 78 + 79 + def _write_schema_file(tmp_path: Path, name: str, schema: dict) -> None: 80 + (tmp_path / name).write_text(json.dumps(schema, indent=2), encoding="utf-8") 81 + 82 + 66 83 def test_generate_output_ndjson(tmp_path, monkeypatch): 67 84 """Test basic output generation via NDJSON protocol.""" 68 85 mod = importlib.import_module("think.talents") ··· 107 124 finish_events = [e for e in events if e["event"] == "finish"] 108 125 assert len(finish_events) == 1 109 126 assert finish_events[0]["result"] == MOCK_RESULT["text"] 127 + 128 + 129 + def test_dispatcher_passes_json_schema(tmp_path, monkeypatch): 130 + """Test that generator execution forwards json_schema to the model layer.""" 131 + mod = importlib.import_module("think.talents") 132 + copy_day(tmp_path) 133 + 134 + import think.models 135 + import think.talent 136 + 137 + monkeypatch.setattr(think.talent, "TALENT_DIR", tmp_path) 138 + schema = {"type": "object", "properties": {"summary": {"type": "string"}}} 139 + _write_schema_file(tmp_path, "schema.json", schema) 140 + _write_generator_file( 141 + tmp_path, 142 + "schema_gen", 143 + { 144 + "type": "generate", 145 + "schedule": "daily", 146 + "priority": 10, 147 + "output": "json", 148 + "schema": "schema.json", 149 + "load": {"transcripts": True, "percepts": True}, 150 + }, 151 + ) 152 + 153 + mock_generate = MagicMock( 154 + return_value={ 155 + "text": '{"summary":"ok"}', 156 + "usage": {"input_tokens": 10, "output_tokens": 5}, 157 + } 158 + ) 159 + monkeypatch.setattr(think.models, "generate_with_result", mock_generate) 160 + monkeypatch.setenv("GOOGLE_API_KEY", "x") 161 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 162 + 163 + events = run_generator_with_config( 164 + mod, 165 + { 166 + "name": "schema_gen", 167 + "day": "20240101", 168 + "output": "json", 169 + "provider": "google", 170 + "model": "gemini-2.0-flash", 171 + }, 172 + monkeypatch, 173 + ) 174 + 175 + assert mock_generate.call_args.kwargs["json_schema"] == schema 176 + finish_events = [e for e in events if e["event"] == "finish"] 177 + assert len(finish_events) == 1 178 + 179 + 180 + def test_dispatcher_omits_json_schema_when_absent(tmp_path, monkeypatch): 181 + """Test that generator execution passes json_schema=None when absent.""" 182 + mod = importlib.import_module("think.talents") 183 + copy_day(tmp_path) 184 + 185 + import think.models 186 + import think.talent 187 + 188 + monkeypatch.setattr(think.talent, "TALENT_DIR", tmp_path) 189 + _write_generator_file( 190 + tmp_path, 191 + "plain_gen", 192 + { 193 + "type": "generate", 194 + "schedule": "daily", 195 + "priority": 10, 196 + "output": "md", 197 + "load": {"transcripts": True, "percepts": True}, 198 + }, 199 + ) 200 + 201 + mock_generate = MagicMock(return_value=MOCK_RESULT) 202 + monkeypatch.setattr(think.models, "generate_with_result", mock_generate) 203 + monkeypatch.setenv("GOOGLE_API_KEY", "x") 204 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 205 + 206 + run_generator_with_config( 207 + mod, 208 + { 209 + "name": "plain_gen", 210 + "day": "20240101", 211 + "output": "md", 212 + "provider": "google", 213 + "model": "gemini-2.0-flash", 214 + }, 215 + monkeypatch, 216 + ) 217 + 218 + assert mock_generate.call_args.kwargs["json_schema"] is None 219 + 220 + 221 + def test_finish_event_includes_schema_validation(tmp_path, monkeypatch): 222 + """Test that finish events surface schema_validation when returned.""" 223 + mod = importlib.import_module("think.talents") 224 + copy_day(tmp_path) 225 + 226 + import think.models 227 + import think.talent 228 + 229 + monkeypatch.setattr(think.talent, "TALENT_DIR", tmp_path) 230 + schema = {"type": "object", "properties": {"summary": {"type": "string"}}} 231 + validation = {"valid": True, "errors": []} 232 + _write_schema_file(tmp_path, "schema.json", schema) 233 + _write_generator_file( 234 + tmp_path, 235 + "schema_validation_gen", 236 + { 237 + "type": "generate", 238 + "schedule": "daily", 239 + "priority": 10, 240 + "output": "json", 241 + "schema": "schema.json", 242 + "load": {"transcripts": True, "percepts": True}, 243 + }, 244 + ) 245 + 246 + monkeypatch.setattr( 247 + think.models, 248 + "generate_with_result", 249 + MagicMock( 250 + return_value={ 251 + "text": '{"summary":"ok"}', 252 + "usage": {"input_tokens": 10, "output_tokens": 5}, 253 + "schema_validation": validation, 254 + } 255 + ), 256 + ) 257 + monkeypatch.setenv("GOOGLE_API_KEY", "x") 258 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 259 + 260 + events = run_generator_with_config( 261 + mod, 262 + { 263 + "name": "schema_validation_gen", 264 + "day": "20240101", 265 + "output": "json", 266 + "provider": "google", 267 + "model": "gemini-2.0-flash", 268 + }, 269 + monkeypatch, 270 + ) 271 + 272 + finish_events = [e for e in events if e["event"] == "finish"] 273 + assert len(finish_events) == 1 274 + assert finish_events[0]["schema_validation"] == validation 275 + 276 + 277 + def test_finish_event_omits_schema_validation_when_absent(tmp_path, monkeypatch): 278 + """Test that finish events omit schema_validation when not returned.""" 279 + mod = importlib.import_module("think.talents") 280 + copy_day(tmp_path) 281 + 282 + import think.models 283 + import think.talent 284 + 285 + monkeypatch.setattr(think.talent, "TALENT_DIR", tmp_path) 286 + _write_generator_file( 287 + tmp_path, 288 + "no_schema_validation_gen", 289 + { 290 + "type": "generate", 291 + "schedule": "daily", 292 + "priority": 10, 293 + "output": "md", 294 + "load": {"transcripts": True, "percepts": True}, 295 + }, 296 + ) 297 + 298 + monkeypatch.setattr( 299 + think.models, 300 + "generate_with_result", 301 + MagicMock(return_value=MOCK_RESULT), 302 + ) 303 + monkeypatch.setenv("GOOGLE_API_KEY", "x") 304 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 305 + 306 + events = run_generator_with_config( 307 + mod, 308 + { 309 + "name": "no_schema_validation_gen", 310 + "day": "20240101", 311 + "output": "md", 312 + "provider": "google", 313 + "model": "gemini-2.0-flash", 314 + }, 315 + monkeypatch, 316 + ) 317 + 318 + finish_events = [e for e in events if e["event"] == "finish"] 319 + assert len(finish_events) == 1 320 + assert "schema_validation" not in finish_events[0] 110 321 111 322 112 323 def test_generate_hook_invoked_with_context(tmp_path, monkeypatch):
+163
tests/test_talent.py
··· 3 3 4 4 """Tests for think.talent module.""" 5 5 6 + import json 7 + from pathlib import Path 8 + 6 9 import pytest 7 10 11 + from think import talent as talent_module 8 12 from think.talent import ( 9 13 _validate_cwd, 10 14 get_talent, ··· 105 109 def test_get_agent_preserves_repo_cwd_for_coder(): 106 110 config = get_talent("coder") 107 111 assert config["cwd"] == "repo" 112 + 113 + 114 + def _write_talent_file(tmp_path: Path, name: str, metadata: dict) -> Path: 115 + md_path = tmp_path / f"{name}.md" 116 + md_path.write_text( 117 + f"{json.dumps(metadata, indent=2)}\n\nTest prompt\n", 118 + encoding="utf-8", 119 + ) 120 + return md_path 121 + 122 + 123 + def _write_schema_file(path: Path, schema: dict) -> None: 124 + path.write_text(json.dumps(schema, indent=2), encoding="utf-8") 125 + 126 + 127 + def test_schema_absent_no_json_schema_key(tmp_path, monkeypatch): 128 + monkeypatch.setattr(talent_module, "TALENT_DIR", tmp_path) 129 + _write_talent_file( 130 + tmp_path, "schema_absent", {"type": "generate", "output": "json"} 131 + ) 132 + 133 + config = get_talent("schema_absent") 134 + 135 + assert "json_schema" not in config 136 + assert "schema" not in config 137 + 138 + 139 + def test_schema_loads_valid_file(tmp_path, monkeypatch): 140 + monkeypatch.setattr(talent_module, "TALENT_DIR", tmp_path) 141 + schema = {"type": "object", "properties": {"value": {"type": "string"}}} 142 + _write_schema_file(tmp_path / "schema.json", schema) 143 + _write_talent_file( 144 + tmp_path, 145 + "schema_valid", 146 + {"type": "generate", "output": "json", "schema": "schema.json"}, 147 + ) 148 + 149 + config = get_talent("schema_valid") 150 + 151 + assert config["json_schema"] == schema 152 + assert "schema" not in config 153 + 154 + 155 + def test_schema_absolute_path_rejected(tmp_path, monkeypatch): 156 + monkeypatch.setattr(talent_module, "TALENT_DIR", tmp_path) 157 + _write_talent_file( 158 + tmp_path, 159 + "schema_absolute", 160 + {"type": "generate", "output": "json", "schema": "/etc/passwd"}, 161 + ) 162 + 163 + with pytest.raises( 164 + ValueError, 165 + match=r"talent schema_absolute: schema path must be relative: /etc/passwd", 166 + ): 167 + get_talent("schema_absolute") 168 + 169 + 170 + def test_schema_parent_traversal_rejected(tmp_path, monkeypatch): 171 + monkeypatch.setattr(talent_module, "TALENT_DIR", tmp_path) 172 + _write_talent_file( 173 + tmp_path, 174 + "schema_parent", 175 + {"type": "generate", "output": "json", "schema": "../escape.json"}, 176 + ) 177 + 178 + with pytest.raises( 179 + ValueError, 180 + match=r"talent schema_parent: schema path must not contain '\.\.': \.\./escape\.json", 181 + ): 182 + get_talent("schema_parent") 183 + 184 + 185 + def test_schema_symlink_escape_rejected(tmp_path, monkeypatch): 186 + monkeypatch.setattr(talent_module, "TALENT_DIR", tmp_path) 187 + outside_schema = tmp_path.parent / "outside_schema.json" 188 + _write_schema_file(outside_schema, {"type": "object"}) 189 + try: 190 + (tmp_path / "schema.json").symlink_to(outside_schema) 191 + except (NotImplementedError, OSError) as exc: 192 + pytest.skip(f"symlinks unavailable on this filesystem: {exc}") 193 + 194 + _write_talent_file( 195 + tmp_path, 196 + "schema_symlink", 197 + {"type": "generate", "output": "json", "schema": "schema.json"}, 198 + ) 199 + 200 + with pytest.raises( 201 + ValueError, 202 + match=r"talent schema_symlink: schema path escapes talent directory:", 203 + ): 204 + get_talent("schema_symlink") 205 + 206 + 207 + def test_schema_missing_file(tmp_path, monkeypatch): 208 + monkeypatch.setattr(talent_module, "TALENT_DIR", tmp_path) 209 + _write_talent_file( 210 + tmp_path, 211 + "schema_missing", 212 + {"type": "generate", "output": "json", "schema": "missing.json"}, 213 + ) 214 + 215 + with pytest.raises( 216 + FileNotFoundError, 217 + match=r"talent schema_missing: schema file not found: .*missing\.json", 218 + ): 219 + get_talent("schema_missing") 220 + 221 + 222 + def test_schema_malformed_json(tmp_path, monkeypatch): 223 + monkeypatch.setattr(talent_module, "TALENT_DIR", tmp_path) 224 + schema_path = tmp_path / "broken.json" 225 + schema_path.write_text("{\n", encoding="utf-8") 226 + _write_talent_file( 227 + tmp_path, 228 + "schema_malformed", 229 + {"type": "generate", "output": "json", "schema": "broken.json"}, 230 + ) 231 + 232 + with pytest.raises( 233 + ValueError, 234 + match=r"talent schema_malformed: schema file is not valid JSON: .*broken\.json", 235 + ): 236 + get_talent("schema_malformed") 237 + 238 + 239 + def test_schema_invalid_schema_draft(tmp_path, monkeypatch): 240 + monkeypatch.setattr(talent_module, "TALENT_DIR", tmp_path) 241 + _write_schema_file(tmp_path / "invalid_schema.json", {"type": 3}) 242 + _write_talent_file( 243 + tmp_path, 244 + "schema_invalid", 245 + {"type": "generate", "output": "json", "schema": "invalid_schema.json"}, 246 + ) 247 + 248 + with pytest.raises( 249 + ValueError, 250 + match=( 251 + r"talent schema_invalid: schema file is not a valid JSON Schema: " 252 + r".*invalid_schema\.json" 253 + ), 254 + ): 255 + get_talent("schema_invalid") 256 + 257 + 258 + def test_schema_not_string(tmp_path, monkeypatch): 259 + monkeypatch.setattr(talent_module, "TALENT_DIR", tmp_path) 260 + _write_talent_file( 261 + tmp_path, 262 + "schema_not_string", 263 + {"type": "generate", "output": "json", "schema": 42}, 264 + ) 265 + 266 + with pytest.raises( 267 + ValueError, 268 + match=r"talent schema_not_string: schema must be a string, got int: 42", 269 + ): 270 + get_talent("schema_not_string")
+58
think/talent.py
··· 18 18 from __future__ import annotations 19 19 20 20 import importlib.util 21 + import json 21 22 import os 22 23 from pathlib import Path 23 24 from typing import Any, Callable 24 25 25 26 import frontmatter 27 + from jsonschema import Draft202012Validator, SchemaError 26 28 27 29 # Import core prompt utilities from think.prompts 28 30 from think.prompts import _load_prompt_metadata, load_prompt ··· 447 449 # --------------------------------------------------------------------------- 448 450 449 451 452 + def _load_talent_schema( 453 + *, 454 + name: str, 455 + md_path: Path, 456 + raw_schema: Any, 457 + ) -> dict[str, Any]: 458 + """Load and validate a talent JSON Schema from a relative file path.""" 459 + if not isinstance(raw_schema, str): 460 + raise ValueError( 461 + f"talent {name}: schema must be a string, got {type(raw_schema).__name__}: " 462 + f"{raw_schema!r}" 463 + ) 464 + 465 + raw_path = Path(raw_schema) 466 + if raw_path.is_absolute(): 467 + raise ValueError(f"talent {name}: schema path must be relative: {raw_schema}") 468 + if ".." in raw_path.parts: 469 + raise ValueError( 470 + f"talent {name}: schema path must not contain '..': {raw_schema}" 471 + ) 472 + 473 + talent_dir = md_path.parent.resolve() 474 + schema_path = (md_path.parent / raw_schema).resolve() 475 + if not schema_path.is_relative_to(talent_dir): 476 + raise ValueError( 477 + f"talent {name}: schema path escapes talent directory: {schema_path}" 478 + ) 479 + if not schema_path.exists(): 480 + raise FileNotFoundError(f"talent {name}: schema file not found: {schema_path}") 481 + 482 + try: 483 + with open(schema_path, encoding="utf-8") as f: 484 + parsed = json.load(f) 485 + except json.JSONDecodeError as exc: 486 + raise ValueError( 487 + f"talent {name}: schema file is not valid JSON: {schema_path}" 488 + ) from exc 489 + 490 + try: 491 + Draft202012Validator.check_schema(parsed) 492 + except SchemaError as exc: 493 + raise ValueError( 494 + f"talent {name}: schema file is not a valid JSON Schema: {schema_path}" 495 + ) from exc 496 + 497 + return parsed 498 + 499 + 450 500 def get_talent( 451 501 name: str = "unified", 452 502 facet: str | None = None, ··· 500 550 501 551 # Store path for later use 502 552 config["path"] = str(md_path) 553 + 554 + if "schema" in config: 555 + config["json_schema"] = _load_talent_schema( 556 + name=name, 557 + md_path=md_path, 558 + raw_schema=config["schema"], 559 + ) 560 + del config["schema"] 503 561 504 562 # Extract source config from 'load' key (replaces instructions.sources) 505 563 config["sources"] = config.pop("load", _DEFAULT_LOAD.copy())
+4
think/talents.py
··· 970 970 thinking_budget=thinking_budget, 971 971 system_instruction=system_instruction, 972 972 json_output=is_json_output, 973 + json_schema=config.get("json_schema"), 973 974 timeout_s=timeout_s, 974 975 ) 975 976 except Exception as exc: ··· 1015 1016 thinking_budget=thinking_budget, 1016 1017 system_instruction=system_instruction, 1017 1018 json_output=is_json_output, 1019 + json_schema=config.get("json_schema"), 1018 1020 timeout_s=timeout_s, 1019 1021 provider=backup, 1020 1022 model=backup_model, ··· 1046 1048 } 1047 1049 if usage_data: 1048 1050 finish_event["usage"] = usage_data 1051 + if "schema_validation" in gen_result: 1052 + finish_event["schema_validation"] = gen_result["schema_validation"] 1049 1053 emit_event(finish_event) 1050 1054 1051 1055