personal memory agent
0
fork

Configure Feed

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

Unify agent config preparation with single get_agent() call

- Enhance get_agent() with include_datetime parameter for day vs interactive context
- Simplify prepare_config() to use single get_agent() call instead of redundant loading
- Remove separate _load_muse_meta() - frontmatter fields now directly in config
- Rename expand_tools to _expand_tools (private), update __all__
- Update hooks to use config directly instead of config["meta"]
- Update tests to check has_hook instead of has_meta

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

+416 -349
+1 -1
docs/CORTEX.md
··· 263 263 264 264 When spawning an agent: 265 265 1. Cortex passes the raw request to `sol agents` via stdin (NDJSON format) 266 - 2. The agent process (`think/agents.py`) handles all config loading via `hydrate_config()`: 266 + 2. The agent process (`think/agents.py`) handles all config loading via `prepare_config()`: 267 267 - Loads agent configuration using `get_agent()` from `think/muse.py` 268 268 - Merges request parameters with agent defaults 269 269 - Resolves provider and model based on context
+6 -4
tests/test_agents_ndjson.py
··· 54 54 return f"Response to: {prompt}" 55 55 56 56 57 - def mock_hydrate_config(request: dict) -> dict: 58 - """Mock hydrate_config that passes through request with minimal additions.""" 57 + def mock_prepare_config(request: dict) -> dict: 58 + """Mock prepare_config that passes through request with minimal additions.""" 59 59 config = dict(request) 60 60 # Add required fields if not present 61 61 if "name" not in config: ··· 64 64 config["provider"] = "google" 65 65 if "model" not in config: 66 66 config["model"] = "gpt-5-mini" 67 + # Add empty meta for hooks 68 + config["meta"] = {} 67 69 return config 68 70 69 71 ··· 82 84 83 85 monkeypatch.setitem(sys.modules, "agents", MagicMock()) 84 86 85 - # Mock hydrate_config to avoid needing real agent configs 86 - monkeypatch.setattr("think.agents.hydrate_config", mock_hydrate_config) 87 + # Mock prepare_config to avoid needing real agent configs 88 + monkeypatch.setattr("think.agents.prepare_config", mock_prepare_config) 87 89 88 90 89 91 def test_ndjson_single_request(mock_journal, monkeypatch, capsys):
+2 -2
tests/test_generate_full.py
··· 138 138 "span": context.get("span_mode"), 139 139 "name": context.get("name"), 140 140 "has_transcript": bool(context.get("transcript")), 141 - "has_meta": bool(context.get("meta")), 141 + "has_hook": bool(context.get("hook")), # Frontmatter fields now directly in config 142 142 } 143 143 with open(out_path, "w") as f: 144 144 json.dump(ctx_copy, f) ··· 187 187 assert captured["span"] is False 188 188 assert captured["name"] == "hooked_gen" 189 189 assert captured["has_transcript"] is True 190 - assert captured["has_meta"] is True 190 + assert captured["has_hook"] is True # Frontmatter fields now directly in config 191 191 192 192 finally: 193 193 # Clean up test files
+6 -4
tests/test_openai.py
··· 12 12 from think.models import GPT_5 13 13 14 14 15 - def _mock_hydrate_config(request: dict) -> dict: 16 - """Mock hydrate_config that passes through request with minimal additions.""" 15 + def _mock_prepare_config(request: dict) -> dict: 16 + """Mock prepare_config that passes through request with minimal additions.""" 17 17 config = dict(request) 18 18 if "name" not in config: 19 19 config["name"] = "default" ··· 21 21 config["provider"] = "openai" 22 22 if "model" not in config: 23 23 config["model"] = GPT_5 24 + # Add empty meta for hooks 25 + config["meta"] = {} 24 26 return config 25 27 26 28 ··· 30 32 import io 31 33 32 34 sys.stdin = io.StringIO(stdin_data) 33 - # Mock hydrate_config to avoid needing real agent configs 34 - with patch.object(mod, "hydrate_config", _mock_hydrate_config): 35 + # Mock prepare_config to avoid needing real agent configs 36 + with patch.object(mod, "prepare_config", _mock_prepare_config): 35 37 await mod.main_async() 36 38 37 39
+375 -328
think/agents.py
··· 1 1 # SPDX-License-Identifier: AGPL-3.0-only 2 2 # Copyright (c) 2026 sol pbc 3 3 4 - """Unified agent and generator CLI for solstone. 4 + """Unified agent CLI for solstone. 5 5 6 - Spawned by cortex for both: 7 - - Agents (with or without tools, conversational or tool-using) 8 - - Transcript generators (configs with 'output' field, no 'tools') 6 + Spawned by cortex for all agent types: 7 + - Tool-using agents (with MCP tools) 8 + - Generators (transcript analysis, no tools) 9 9 10 + Both paths share unified config preparation and execution flow. 10 11 Reads NDJSON config from stdin, emits JSONL events to stdout. 11 12 """ 12 13 ··· 25 26 26 27 from think.cluster import cluster, cluster_period, cluster_span 27 28 from think.muse import ( 28 - compose_instructions, 29 29 get_agent_filter, 30 30 get_muse_configs, 31 31 get_output_path, ··· 180 180 181 181 182 182 # ============================================================================= 183 - # Config Hydration, Enrichment, and Validation 183 + # Unified Config Preparation 184 184 # ============================================================================= 185 185 186 186 187 - def hydrate_config(request: dict) -> dict: 188 - """Load agent config and merge with request. 189 - 190 - Takes the raw request from cortex and returns a fully hydrated config with: 191 - - Base agent config loaded from muse/*.md 192 - - Request values merged (request overrides defaults) 193 - - Provider and model resolved from context 194 - - Tools expanded from pack names to tool list 195 - 196 - Args: 197 - request: Raw request dict from cortex with at least 'name' field 198 - 199 - Returns: 200 - Fully hydrated config dict ready for routing 201 - """ 202 - from think.models import resolve_model_for_provider, resolve_provider 203 - from think.muse import get_agent, key_to_context 204 - 205 - name = request.get("name", "default") 206 - facet = request.get("facet") 207 - 208 - # Load base config from agent definition 209 - config = get_agent(name, facet=facet) 210 - 211 - # Merge request into config (request values override agent defaults) 212 - # Only override with non-None values to preserve agent defaults 213 - config.update({k: v for k, v in request.items() if v is not None}) 214 - 215 - # Resolve provider and model from context 216 - context = key_to_context(name) 217 - default_provider, default_model = resolve_provider(context) 218 - 219 - # Provider can be overridden by request or agent config 220 - provider = config.get("provider") or default_provider 221 - 222 - # Model: use explicit model from request/config, or resolve from provider 223 - model = config.get("model") 224 - if not model: 225 - if provider != default_provider: 226 - model = resolve_model_for_provider(context, provider) 227 - else: 228 - model = default_model 229 - 230 - config["provider"] = provider 231 - config["model"] = model 232 - 233 - # Expand tools if it's a string (tool pack name) 234 - tools_config = config.get("tools") 235 - if isinstance(tools_config, str): 236 - config["tools"] = expand_tools(tools_config) 237 - 238 - return config 239 - 240 - 241 - def expand_tools(tools_config: str) -> list[str]: 187 + def _expand_tools(tools_config: str) -> list[str]: 242 188 """Expand tool pack names to a list of tool names. 243 189 244 190 Args: ··· 268 214 return expanded 269 215 270 216 271 - def validate_config(config: dict) -> str | None: 272 - """Validate agent config. 217 + def _build_prompt_context( 218 + day: str | None, segment: str | None, span: list[str] | None 219 + ) -> dict[str, str]: 220 + """Build context dict for prompt template substitution. 273 221 274 222 Args: 275 - config: Hydrated config dict 223 + day: Day in YYYYMMDD format 224 + segment: Segment key (HHMMSS_LEN) 225 + span: List of segment keys 276 226 277 227 Returns: 278 - Error message string if invalid, None if valid 228 + Dict with day, date, segment_start, segment_end as available 279 229 """ 280 - has_tools = bool(config.get("tools")) 281 - has_prompt = bool(config.get("prompt")) 282 - has_day = bool(config.get("day")) 230 + context: dict[str, str] = {} 231 + if not day: 232 + return context 283 233 284 - # Tools path requires prompt 285 - if has_tools and not has_prompt: 286 - return "Missing 'prompt' field for tool agent" 234 + context["day"] = day 235 + context["date"] = format_day(day) 287 236 288 - # Generate path requires at least prompt or day 289 - if not has_tools and not has_prompt and not has_day: 290 - return "Invalid config: must have 'tools', 'prompt', or 'day' field" 237 + if segment: 238 + start_str, end_str = format_segment_times(segment) 239 + if start_str and end_str: 240 + context["segment"] = segment 241 + context["segment_start"] = start_str 242 + context["segment_end"] = end_str 243 + elif span: 244 + all_times = [] 245 + for seg in span: 246 + start_time, end_time = segment_parse(seg) 247 + if start_time and end_time: 248 + all_times.append((start_time, end_time)) 291 249 292 - # Segment/span requires day 293 - if (config.get("segment") or config.get("span")) and not has_day: 294 - return "Invalid config: 'segment' or 'span' requires 'day' field" 250 + if all_times: 251 + earliest_start = min(t[0] for t in all_times) 252 + latest_end = max(t[1] for t in all_times) 253 + context["segment_start"] = ( 254 + datetime.combine(datetime.today(), earliest_start) 255 + .strftime("%I:%M %p") 256 + .lstrip("0") 257 + ) 258 + context["segment_end"] = ( 259 + datetime.combine(datetime.today(), latest_end) 260 + .strftime("%I:%M %p") 261 + .lstrip("0") 262 + ) 295 263 296 - # Validate continue_from if present 297 - continue_from = config.get("continue_from") 298 - if continue_from: 299 - from think.cortex_client import get_agent_log_status 264 + return context 300 265 301 - status = get_agent_log_status(continue_from) 302 - if status == "running": 303 - return f"Cannot continue from {continue_from}: agent is still running" 304 - if status == "not_found": 305 - return f"Cannot continue from {continue_from}: agent not found" 306 266 307 - return None 267 + def _load_transcript( 268 + day: str, 269 + segment: str | None, 270 + span: list[str] | None, 271 + sources: dict, 272 + ) -> tuple[str, dict[str, int]]: 273 + """Load and cluster transcript for day/segment/span. 308 274 275 + Args: 276 + day: Day in YYYYMMDD format 277 + segment: Optional segment key 278 + span: Optional list of segment keys 279 + sources: Source config dict from instructions 309 280 310 - def enrich_config(config: dict) -> None: 311 - """Enrich config with transcript, system instruction, output path, etc. 281 + Returns: 282 + Tuple of (transcript text, source_counts dict) 283 + """ 284 + # Set segment key for token usage logging 285 + if segment: 286 + os.environ["SEGMENT_KEY"] = segment 287 + elif span: 288 + os.environ["SEGMENT_KEY"] = span[0] 312 289 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 290 + # Convert sources config for clustering 291 + cluster_sources: dict = {} 292 + for k, v in sources.items(): 293 + if k == "agents": 294 + agent_filter = get_agent_filter(v) 295 + if agent_filter is None: 296 + cluster_sources[k] = source_is_enabled(v) 297 + elif not agent_filter: 298 + cluster_sources[k] = False 299 + else: 300 + cluster_sources[k] = agent_filter 301 + else: 302 + cluster_sources[k] = source_is_enabled(v) 303 + 304 + # Build transcript via clustering 305 + if span: 306 + return cluster_span(day, span, sources=cluster_sources) 307 + elif segment: 308 + return cluster_period(day, segment, sources=cluster_sources) 309 + else: 310 + return cluster(day, sources=cluster_sources) 311 + 312 + 313 + def prepare_config(request: dict) -> dict: 314 + """Prepare complete agent config from request. 315 + 316 + Single unified preparation path for all agent types. Takes raw request 317 + from cortex and returns fully prepared config ready for execution. 318 + 319 + Config fields produced: 320 + - name: Agent name 321 + - provider, model: Resolved from context/request 322 + - tools: Expanded tool list (if tool agent) 323 + - system_instruction: System prompt 324 + - user_instruction: Agent instruction from .md file 325 + - extra_context: Facets, datetime context 326 + - prompt: User's runtime query/request 327 + - transcript: Clustered transcript (if day provided) 328 + - output_path: Where to write output (if output format set) 329 + - skip_reason: Why to skip (if applicable) 330 + 331 + Day vs Non-Day Processing: 332 + - Non-day (interactive): Includes current datetime in context, no transcript 333 + - Day-based (historical): Excludes datetime, loads clustered transcript 321 334 322 335 Args: 323 - config: Hydrated config dict to enrich 336 + request: Raw request dict from cortex 337 + 338 + Returns: 339 + Fully prepared config dict 324 340 """ 325 - name = config.get("name", "default") 326 - day = config.get("day") 327 - segment = config.get("segment") 328 - span = config.get("span") # List of sequential segment keys 329 - facet = config.get("facet") 330 - output_format = config.get("output") 331 - output_path_override = config.get("output_path") 332 - user_prompt = config.get("prompt", "") 341 + from think.models import resolve_model_for_provider, resolve_provider 342 + from think.muse import get_agent, key_to_context 333 343 334 - # Load config metadata (for hooks, system prompt, etc.) 335 - all_configs = get_muse_configs(has_tools=False) 336 - if name not in all_configs: 337 - all_configs = get_muse_configs() # Include tool configs 338 - if name in all_configs: 339 - meta = all_configs[name] 340 - agent_path = Path(meta["path"]) 341 - else: 342 - meta = {} 343 - agent_path = None 344 + name = request.get("name", "default") 345 + facet = request.get("facet") 346 + day = request.get("day") 347 + segment = request.get("segment") 348 + span = request.get("span") 349 + output_format = request.get("output") 350 + output_path_override = request.get("output_path") 351 + user_prompt = request.get("prompt", "") 344 352 345 - config["meta"] = meta 346 - config["agent_path"] = agent_path 353 + # Load complete agent config with appropriate datetime context: 354 + # - Interactive (no day): include_datetime=True for current context 355 + # - Day-based analysis: include_datetime=False for historical data 356 + config = get_agent(name, facet=facet, include_datetime=not day) 357 + 358 + # Config now contains all frontmatter fields plus: 359 + # - path: Path to the .md file 360 + # - system_instruction, user_instruction, extra_context 361 + # - sources: Source config for transcript loading 362 + # - All frontmatter: tools, hook, disabled, thinking_budget, max_output_tokens, etc. 363 + 364 + # Convert path string to Path object for convenience 365 + agent_path = Path(config["path"]) if config.get("path") else None 366 + sources = config.get("sources", {}) 367 + 368 + # Merge request values (request overrides agent defaults) 369 + config.update({k: v for k, v in request.items() if v is not None}) 370 + 371 + # Track additional state 347 372 config["span_mode"] = bool(span) 348 373 config["source_counts"] = {} 349 374 350 - # Check if config is disabled 351 - if meta.get("disabled"): 352 - config["skip_reason"] = "disabled" 353 - return 375 + # Resolve provider and model from context 376 + context = key_to_context(name) 377 + default_provider, default_model = resolve_provider(context) 354 378 355 - # Extract instructions config for source filtering and system prompt 356 - instructions_config = meta.get("instructions") 357 - instructions = compose_instructions( 358 - facet=facet, 359 - include_datetime=not day, 360 - config_overrides=instructions_config, 361 - ) 362 - sources = instructions.get("sources", {}) 363 - config["system_prompt_name"] = instructions.get("system_prompt_name", "journal") 379 + provider = config.get("provider") or default_provider 380 + model = config.get("model") 381 + if not model: 382 + if provider != default_provider: 383 + model = resolve_model_for_provider(context, provider) 384 + else: 385 + model = default_model 364 386 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 387 + config["provider"] = provider 388 + config["model"] = model 373 389 374 - # Transcript loading (only if day is provided) 375 - if day: 376 - # Set segment key for token usage logging 377 - if segment: 378 - os.environ["SEGMENT_KEY"] = segment 379 - elif span: 380 - os.environ["SEGMENT_KEY"] = span[0] 390 + # Expand tools if string (pack name) 391 + tools_config = config.get("tools") 392 + if isinstance(tools_config, str): 393 + config["tools"] = _expand_tools(tools_config) 381 394 382 - # Convert sources for clustering 383 - cluster_sources: dict = {} 384 - for k, v in sources.items(): 385 - if k == "agents": 386 - agent_filter = get_agent_filter(v) 387 - if agent_filter is None: 388 - cluster_sources[k] = source_is_enabled(v) 389 - elif not agent_filter: 390 - cluster_sources[k] = False 391 - else: 392 - cluster_sources[k] = agent_filter 393 - else: 394 - cluster_sources[k] = source_is_enabled(v) 395 + # Check if disabled 396 + if config.get("disabled"): 397 + config["skip_reason"] = "disabled" 398 + return config 395 399 396 - # Build transcript via clustering 397 - if span: 398 - transcript, source_counts = cluster_span(day, span, sources=cluster_sources) 399 - elif segment: 400 - transcript, source_counts = cluster_period( 401 - day, segment, sources=cluster_sources 402 - ) 403 - else: 404 - transcript, source_counts = cluster(day, sources=cluster_sources) 400 + # Day-based processing: load transcript and apply template substitution 401 + if day: 402 + # Merge extra_context into system_instruction for day-based analysis 403 + if "system_instruction" not in request: 404 + system_instruction = config.get("system_instruction", "") 405 + extra_ctx = config.get("extra_context") 406 + if extra_ctx: 407 + system_instruction = f"{system_instruction}\n\n{extra_ctx}" 408 + config["system_instruction"] = system_instruction 405 409 410 + # Load transcript 411 + transcript, source_counts = _load_transcript(day, segment, span, sources) 406 412 config["transcript"] = transcript 407 413 config["source_counts"] = source_counts 408 414 total_count = sum(source_counts.values()) 409 415 410 - # Check required sources have content 416 + # Check required sources 411 417 for source_type, mode in sources.items(): 412 418 if source_is_required(mode) and source_counts.get(source_type, 0) == 0: 413 419 config["skip_reason"] = f"missing_required_{source_type}" 414 - return 420 + return config 415 421 416 - # Skip when there's nothing to analyze 422 + # Skip if no content 417 423 if total_count == 0 or len(transcript.strip()) < MIN_INPUT_CHARS: 418 424 config["skip_reason"] = "no_input" 419 - return 425 + return config 420 426 421 - # Prepend input context note for limited recordings 427 + # Note for limited recordings 422 428 if total_count < 3: 423 - input_note = ( 429 + config["transcript"] = ( 424 430 "**Input Note:** Limited recordings for this day. " 425 - "Scale analysis to available input.\n\n" 431 + "Scale analysis to available input.\n\n" + transcript 426 432 ) 427 - config["transcript"] = input_note + transcript 428 - 429 - # Build context for template substitution 430 - prompt_context: dict[str, str] = {} 431 - if day: 432 - prompt_context["day"] = day 433 - prompt_context["date"] = format_day(day) 434 433 435 - if segment: 436 - start_str, end_str = format_segment_times(segment) 437 - if start_str and end_str: 438 - prompt_context["segment"] = segment 439 - prompt_context["segment_start"] = start_str 440 - prompt_context["segment_end"] = end_str 441 - elif span: 442 - all_times = [] 443 - for seg in span: 444 - start_time, end_time = segment_parse(seg) 445 - if start_time and end_time: 446 - all_times.append((start_time, end_time)) 447 - 448 - if all_times: 449 - earliest_start = min(t[0] for t in all_times) 450 - latest_end = max(t[1] for t in all_times) 451 - start_str = ( 452 - datetime.combine(datetime.today(), earliest_start) 453 - .strftime("%I:%M %p") 454 - .lstrip("0") 455 - ) 456 - end_str = ( 457 - datetime.combine(datetime.today(), latest_end) 458 - .strftime("%I:%M %p") 459 - .lstrip("0") 460 - ) 461 - prompt_context["segment_start"] = start_str 462 - prompt_context["segment_end"] = end_str 434 + # Reload agent instruction with template substitution for day/segment context 435 + if agent_path and agent_path.exists(): 436 + prompt_context = _build_prompt_context(day, segment, span) 437 + agent_prompt_obj = load_prompt( 438 + agent_path.stem, base_dir=agent_path.parent, context=prompt_context 439 + ) 440 + config["user_instruction"] = agent_prompt_obj.text 463 441 464 - # Load prompt from agent file if available, otherwise use user prompt 465 - if agent_path and agent_path.exists(): 466 - agent_prompt_obj = load_prompt( 467 - agent_path.stem, base_dir=agent_path.parent, context=prompt_context 468 - ) 469 - prompt = agent_prompt_obj.text 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 442 + # Set prompt (user's runtime query) 443 + # For tool agents: prompt is the user's question 444 + # For generators: prompt is typically empty (instruction is in user_instruction) 445 + config["prompt"] = user_prompt 474 446 475 447 # Determine output path 476 448 if output_format: ··· 482 454 day_dir, name, segment=segment, output_format=output_format, facet=facet 483 455 ) 484 456 457 + return config 458 + 459 + 460 + def validate_config(config: dict) -> str | None: 461 + """Validate prepared config. 462 + 463 + Args: 464 + config: Prepared config dict 465 + 466 + Returns: 467 + Error message string if invalid, None if valid 468 + """ 469 + has_tools = bool(config.get("tools")) 470 + has_prompt = bool(config.get("prompt")) 471 + has_user_instruction = bool(config.get("user_instruction")) 472 + has_day = bool(config.get("day")) 473 + 474 + # Tool agents need a prompt (user's question) 475 + if has_tools and not has_prompt: 476 + return "Missing 'prompt' field for tool agent" 477 + 478 + # Generators need either day (transcript) or user_instruction 479 + if not has_tools and not has_day and not has_user_instruction and not has_prompt: 480 + return "Invalid config: must have 'tools', 'day', or 'prompt'" 481 + 482 + # Segment/span requires day 483 + if (config.get("segment") or config.get("span")) and not has_day: 484 + return "Invalid config: 'segment' or 'span' requires 'day'" 485 + 486 + # Validate continue_from if present 487 + continue_from = config.get("continue_from") 488 + if continue_from: 489 + from think.cortex_client import get_agent_log_status 490 + 491 + status = get_agent_log_status(continue_from) 492 + if status == "running": 493 + return f"Cannot continue from {continue_from}: agent is still running" 494 + if status == "not_found": 495 + return f"Cannot continue from {continue_from}: agent not found" 496 + 497 + return None 498 + 485 499 486 500 # ============================================================================= 487 501 # Hook Execution ··· 497 511 Returns: 498 512 Dict of field modifications to apply to config 499 513 """ 500 - meta = config.get("meta", {}) 501 - pre_hook = load_pre_hook(meta) 514 + pre_hook = load_pre_hook(config) 502 515 if not pre_hook: 503 516 return {} 504 517 ··· 523 536 Returns: 524 537 Transformed result (or original if no hook) 525 538 """ 526 - meta = config.get("meta", {}) 527 - post_hook = load_post_hook(meta) 539 + post_hook = load_post_hook(config) 528 540 if not post_hook: 529 541 return result 530 542 ··· 555 567 def _build_dry_run_event(config: dict, before_values: dict) -> dict: 556 568 """Build a dry-run event with all context.""" 557 569 has_tools = bool(config.get("tools")) 558 - run_type = "agent" if has_tools else "generate" 559 570 560 571 event: dict[str, Any] = { 561 572 "event": "dry_run", 562 573 "ts": now_ms(), 563 - "type": run_type, 574 + "type": "agent" if has_tools else "generate", 564 575 "name": config.get("name", "default"), 565 576 "provider": config.get("provider", ""), 566 577 "model": config.get("model") or "unknown", 567 578 "system_instruction": config.get("system_instruction", ""), 579 + "user_instruction": config.get("user_instruction", ""), 568 580 "prompt": config.get("prompt", ""), 569 581 } 570 582 571 583 if has_tools: 572 - event["user_instruction"] = config.get("user_instruction", "") 573 584 event["extra_context"] = config.get("extra_context", "") 574 585 event["tools"] = config.get("tools", []) 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" 579 586 580 587 # Day-based fields 581 588 if config.get("day"): ··· 602 609 return event 603 610 604 611 612 + async def _execute_with_tools( 613 + config: dict, 614 + emit_event: Callable[[dict], None], 615 + ) -> None: 616 + """Execute tool-using agent via provider's run_tools. 617 + 618 + Args: 619 + config: Prepared config dict 620 + emit_event: Event emission callback 621 + """ 622 + from .providers import PROVIDER_REGISTRY, get_provider_module 623 + 624 + provider = config.get("provider", "google") 625 + output_path = config.get("output_path") 626 + 627 + if provider not in PROVIDER_REGISTRY: 628 + valid = ", ".join(sorted(PROVIDER_REGISTRY.keys())) 629 + raise ValueError(f"Unknown provider: {provider!r}. Valid providers: {valid}") 630 + 631 + provider_mod = get_provider_module(provider) 632 + 633 + # Wrapper to intercept finish event for post-processing 634 + def agent_emit_event(data: Event) -> None: 635 + if data.get("event") == "finish": 636 + result = data.get("result", "") 637 + result = _run_post_hooks(result, config) 638 + if result != data.get("result", ""): 639 + data = {**data, "result": result} 640 + if output_path and result: 641 + _write_output(output_path, result) 642 + if config.get("handoff"): 643 + data = {**data, "handoff": config["handoff"]} 644 + 645 + # Filter out start events from providers (we already emitted ours) 646 + if data.get("event") == "start": 647 + return 648 + 649 + emit_event(data) 650 + 651 + await provider_mod.run_tools(config=config, on_event=agent_emit_event) 652 + 653 + 654 + async def _execute_generate( 655 + config: dict, 656 + emit_event: Callable[[dict], None], 657 + ) -> None: 658 + """Execute single-shot generation (no tools). 659 + 660 + Args: 661 + config: Prepared config dict 662 + emit_event: Event emission callback 663 + """ 664 + from think.models import generate_with_result 665 + from think.muse import key_to_context 666 + 667 + name = config.get("name", "default") 668 + transcript = config.get("transcript", "") 669 + user_instruction = config.get("user_instruction", "") 670 + prompt = config.get("prompt", "") 671 + system_instruction = config.get("system_instruction", "") 672 + output_path = config.get("output_path") 673 + output_format = config.get("output") 674 + 675 + # Get generation parameters from config (set in frontmatter) 676 + thinking_budget = config.get("thinking_budget") or 8192 * 3 677 + max_output_tokens = config.get("max_output_tokens") or 8192 * 6 678 + is_json_output = output_format == "json" 679 + 680 + # Build contents: transcript + instruction + prompt 681 + contents = [] 682 + if transcript: 683 + contents.append(transcript) 684 + if user_instruction: 685 + contents.append(user_instruction) 686 + if prompt: 687 + contents.append(prompt) 688 + 689 + # Fallback if no contents 690 + if not contents: 691 + contents = ["No input provided."] 692 + 693 + context = key_to_context(name) 694 + gen_result = generate_with_result( 695 + contents=contents, 696 + context=context, 697 + temperature=0.3, 698 + max_output_tokens=max_output_tokens, 699 + thinking_budget=thinking_budget, 700 + system_instruction=system_instruction, 701 + json_output=is_json_output, 702 + ) 703 + 704 + result = gen_result["text"] 705 + usage_data = gen_result.get("usage") 706 + 707 + # Run post-hooks 708 + result = _run_post_hooks(result, config) 709 + 710 + # Write output 711 + if output_path and result: 712 + _write_output(output_path, result) 713 + 714 + # Emit finish event 715 + finish_event: dict[str, Any] = { 716 + "event": "finish", 717 + "ts": now_ms(), 718 + "result": result, 719 + } 720 + if usage_data: 721 + finish_event["usage"] = usage_data 722 + if config.get("handoff"): 723 + finish_event["handoff"] = config["handoff"] 724 + emit_event(finish_event) 725 + 726 + 605 727 async def _run_agent( 606 728 config: dict, 607 729 emit_event: Callable[[dict], None], 608 730 dry_run: bool = False, 609 731 ) -> None: 610 - """Execute agent or generator based on config. 732 + """Execute agent based on config. 611 733 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. 734 + Unified execution path for all agent types. Handles: 735 + - Skip conditions (disabled, no input, etc.) 736 + - Output existence checking (skip if exists unless force) 737 + - Pre/post hooks 738 + - Dry-run mode 739 + - Routing to tool or generate execution 614 740 615 741 Args: 616 - config: Fully hydrated and enriched config dict 742 + config: Fully prepared config dict 617 743 emit_event: Callback to emit JSONL events 618 744 dry_run: If True, emit dry_run event instead of calling LLM 619 745 """ ··· 622 748 model = config.get("model") 623 749 has_tools = bool(config.get("tools")) 624 750 force = config.get("force", False) 751 + output_path = config.get("output_path") 625 752 626 753 # Emit start event 627 754 start_event: dict[str, Any] = { ··· 652 779 day_log(config["day"], f"agent {name} skipped ({skip_reason})") 653 780 return 654 781 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: 782 + # Check if output already exists (applies to both tool agents and generators) 783 + if output_path and not force and not dry_run: 659 784 if output_path.exists() and output_path.stat().st_size > 0: 660 785 LOG.info("Output exists, loading: %s", output_path) 661 786 with open(output_path, "r") as f: ··· 673 798 before_values = { 674 799 "prompt": config.get("prompt", ""), 675 800 "system_instruction": config.get("system_instruction", ""), 801 + "user_instruction": config.get("user_instruction", ""), 676 802 "transcript": config.get("transcript", ""), 677 803 } 678 804 if has_tools: 679 - before_values["user_instruction"] = config.get("user_instruction", "") 680 805 before_values["extra_context"] = config.get("extra_context", "") 681 806 682 807 # Run pre-hooks ··· 689 814 emit_event(_build_dry_run_event(config, before_values)) 690 815 return 691 816 692 - # Execute LLM call - this is the only real branch 817 + # Execute based on agent type 693 818 if has_tools: 694 - # Tool-using agent path 695 - from .providers import PROVIDER_REGISTRY, get_provider_module 696 - 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 - ) 702 - 703 - provider_mod = get_provider_module(provider) 704 - 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"]} 716 - 717 - # Filter out start events from providers (we already emitted ours) 718 - if data.get("event") == "start": 719 - return 720 - 721 - emit_event(data) 722 - 723 - await provider_mod.run_tools(config=config, on_event=agent_emit_event) 724 - 819 + await _execute_with_tools(config, emit_event) 725 820 else: 726 - # Generator path - single-shot generation 727 - from think.models import generate_with_result 728 - from think.muse import key_to_context 729 - 730 - transcript = config.get("transcript", "") 731 - prompt = config.get("prompt", "") 732 - system_instruction = config.get("system_instruction", "") 733 - meta = config.get("meta", {}) 734 - 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" 739 - 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, 747 - system_instruction=system_instruction, 748 - json_output=is_json_output, 749 - ) 750 - 751 - result = gen_result["text"] 752 - usage_data = gen_result.get("usage") 753 - 754 - # Run post-hooks 755 - result = _run_post_hooks(result, config) 756 - 757 - # Write output 758 - if output_path and result: 759 - _write_output(output_path, result) 760 - 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) 821 + await _execute_generate(config, emit_event) 772 822 773 823 # Log completion 774 824 if config.get("day"): ··· 794 844 pending: list[str] = [] 795 845 for key, meta in sorted(daily_generators.items()): 796 846 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)) 847 + output_file = get_output_path(day_dir, key, output_format=output_format) 848 + if output_file.exists(): 849 + processed.append(os.path.join("agents", output_file.name)) 800 850 else: 801 - pending.append(os.path.join("agents", output_path.name)) 851 + pending.append(os.path.join("agents", output_file.name)) 802 852 return {"processed": sorted(processed), "repairable": sorted(pending)} 803 853 804 854 ··· 808 858 809 859 810 860 async def main_async() -> None: 811 - """NDJSON-based CLI for agents and generators.""" 861 + """NDJSON-based CLI for agents.""" 812 862 parser = argparse.ArgumentParser( 813 863 description="solstone Agent CLI - Accepts NDJSON input via stdin" 814 864 ) ··· 838 888 839 889 try: 840 890 request = json.loads(line) 841 - config = hydrate_config(request) 891 + config = prepare_config(request) 842 892 843 893 error = validate_config(config) 844 894 if error: 845 895 emit_event({"event": "error", "error": error, "ts": now_ms()}) 846 896 continue 847 897 848 - enrich_config(config) 849 898 await _run_agent(config, emit_event, dry_run=dry_run) 850 899 851 900 except json.JSONDecodeError as e: ··· 887 936 __all__ = [ 888 937 "format_tool_summary", 889 938 "parse_agent_events_to_turns", 890 - "hydrate_config", 891 - "expand_tools", 939 + "prepare_config", 892 940 "validate_config", 893 - "enrich_config", 894 941 "scan_day", 895 942 ]
+26 -10
think/muse.py
··· 552 552 # --------------------------------------------------------------------------- 553 553 554 554 555 - def get_agent(name: str = "default", facet: str | None = None) -> dict: 555 + def get_agent( 556 + name: str = "default", 557 + facet: str | None = None, 558 + include_datetime: bool = True, 559 + ) -> dict: 556 560 """Return complete agent configuration by name. 557 561 558 562 Loads configuration from .md file with JSON frontmatter and instruction text, ··· 567 571 Optional facet name to focus on. When provided, includes detailed 568 572 information for just this facet (with full entity details) instead 569 573 of summaries of all facets. 574 + include_datetime: 575 + Whether to include current datetime in extra_context. Default True 576 + for interactive agents, set False for historical analysis (day-based). 570 577 571 578 Returns 572 579 ------- 573 580 dict 574 - Complete agent configuration including system_instruction, user_instruction, 575 - extra_context, model, backend, etc. 581 + Complete agent configuration including: 582 + - name: Agent name 583 + - path: Path to the .md file 584 + - system_instruction, user_instruction, extra_context: Composed prompts 585 + - sources: Source config from instructions (for transcript loading) 586 + - All frontmatter fields (tools, hook, disabled, thinking_budget, etc.) 576 587 """ 577 588 # Resolve agent path based on namespace 578 589 agent_dir, agent_name = _resolve_agent_path(name) ··· 582 593 if not md_path.exists(): 583 594 raise FileNotFoundError(f"Agent not found: {name}") 584 595 585 - # Load config from frontmatter 586 - post = frontmatter.load( 587 - md_path, 588 - ) 596 + # Load config from frontmatter - preserve all fields 597 + post = frontmatter.load(md_path) 589 598 config = dict(post.metadata) if post.metadata else {} 590 599 591 - # Extract instructions config if present 592 - instructions_config = config.pop("instructions", None) 600 + # Store path for later use (e.g., load_prompt with template context) 601 + config["path"] = str(md_path) 602 + 603 + # Extract instructions config (but keep a copy for sources) 604 + instructions_config = config.get("instructions") 593 605 594 606 # Use compose_instructions for consistent prompt composition 595 607 instructions = compose_instructions( 596 608 user_prompt=agent_name, 597 609 user_prompt_dir=agent_dir, 598 610 facet=facet, 599 - include_datetime=True, 611 + include_datetime=include_datetime, 600 612 config_overrides=instructions_config, 601 613 ) 602 614 603 615 # Merge instruction results into config 604 616 config["system_instruction"] = instructions["system_instruction"] 605 617 config["user_instruction"] = instructions["user_instruction"] 618 + config["system_prompt_name"] = instructions.get("system_prompt_name", "journal") 606 619 if instructions["extra_context"]: 607 620 config["extra_context"] = instructions["extra_context"] 621 + 622 + # Preserve sources config for transcript loading 623 + config["sources"] = instructions.get("sources", {}) 608 624 609 625 # Set agent name 610 626 config["name"] = name