From bad05e29996fdfab4d32baa75434df03a3fe8cb6 Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Thu, 16 Apr 2026 23:57:55 -0600 Subject: [PATCH] feat(talents): add cwd frontmatter, default cogitate agents to journal Adds an optional `cwd` field on talent frontmatter with two legal values (`"journal"` default, `"repo"` escape hatch) for `type: cogitate` prompts. Threads the resolved absolute path through `prepare_config` and each provider's `CLIRunner`, and also through the cortex-side `Popen` for `sol agents`, so every cogitate subprocess lands in the correct working directory. Validation is centralized in `_validate_cwd` (`think/talent.py`) and runs at both `get_talent_configs` and `get_agent`. `talent/coder.md` opts into `cwd: "repo"`; all other cogitate talents default to `"journal"`. Generators reject `cwd` entirely. Fixes repo-relative path notes in `talent/heartbeat.md` so the agent runs correctly under journal cwd. Known gap: Google provider's `--sandbox=none` is unchanged and deferred to a follow-up lode. --- docs/APPS.md | 4 +- docs/THINK.md | 2 +- talent/coder.md | 1 + talent/heartbeat.md | 6 +-- tests/test_cli_provider.py | 31 ++++++++++++ tests/test_cortex.py | 95 +++++++++++++++++++++++++++++++++++- tests/test_openai.py | 31 ++++++++++++ tests/test_talent.py | 48 +++++++++++++++++- think/agents.py | 24 +++++++++ think/cortex.py | 27 +++++++++- think/providers/anthropic.py | 3 ++ think/providers/google.py | 3 ++ think/providers/ollama.py | 3 ++ think/providers/openai.py | 3 ++ think/talent.py | 41 ++++++++++++++++ 15 files changed, 312 insertions(+), 10 deletions(-) diff --git a/docs/APPS.md b/docs/APPS.md index 91bd07469..12d234bda 100644 --- a/docs/APPS.md +++ b/docs/APPS.md @@ -283,7 +283,7 @@ Define custom generator prompts that integrate with solstone's output generation - Keys are namespaced as `{app}:{agent}` (e.g., `my_app:weekly_summary`) - Outputs go to `JOURNAL/YYYYMMDD/agents/__.md` (or `.json` if `output: "json"`) -**Metadata format:** Same schema as system generators in `talent/*.md` - JSON frontmatter includes `title`, `description`, `color`, `schedule` (required), `priority` (required for scheduled prompts), `hook`, `output`, `max_output_tokens`, and `thinking_budget` fields. The `schedule` field must be `"segment"` or `"daily"`. The `priority` field is required for all scheduled prompts - prompts without explicit priority will fail validation. Set `output: "json"` for structured JSON output instead of markdown. Optional `max_output_tokens` sets the maximum response length; `thinking_budget` sets the model's thinking token budget (provider-specific defaults apply if omitted). +**Metadata format:** Same schema as system generators in `talent/*.md` - JSON frontmatter includes `title`, `description`, `color`, `schedule` (required), `priority` (required for scheduled prompts), `hook`, `output`, `max_output_tokens`, and `thinking_budget` fields. The `schedule` field must be `"segment"` or `"daily"`. The `priority` field is required for all scheduled prompts - prompts without explicit priority will fail validation. Set `output: "json"` for structured JSON output instead of markdown. Optional `max_output_tokens` sets the maximum response length; `thinking_budget` sets the model's thinking token budget (provider-specific defaults apply if omitted). Generators reject a `cwd` field entirely; working-directory control is only available for `type: "cogitate"` prompts. **Priority bands:** Prompts run in priority order (lowest first). Recommended bands: - 10-30: Generators (content-producing prompts) @@ -365,7 +365,7 @@ Define custom agents and generator templates that integrate with solstone's Cort - Keys are namespaced as `{app}:{name}` (e.g., `my_app:helper`) - Agents inherit all system agent capabilities (tools, scheduling, multi-facet) -**Metadata format:** Same schema as system agents in `talent/*.md` - JSON frontmatter includes `title`, `provider`, `model`, `tools`, `schedule`, `priority`, `multi_facet`, `max_output_tokens`, and `thinking_budget` fields. The `priority` field is **required** for all scheduled prompts - prompts without explicit priority will fail validation. See the priority bands documentation in [THINK.md](THINK.md#unified-priority-execution). Optional `max_output_tokens` sets the maximum response length; `thinking_budget` sets the model's thinking token budget (provider-specific defaults apply if omitted; OpenAI uses fixed reasoning and ignores this field). See [CORTEX.md](CORTEX.md) for agent configuration details. +**Metadata format:** Same schema as system agents in `talent/*.md` - JSON frontmatter includes `title`, `provider`, `model`, `tools`, `schedule`, `priority`, `multi_facet`, `max_output_tokens`, and `thinking_budget` fields. The `priority` field is **required** for all scheduled prompts - prompts without explicit priority will fail validation. See the priority bands documentation in [THINK.md](THINK.md#unified-priority-execution). Optional `max_output_tokens` sets the maximum response length; `thinking_budget` sets the model's thinking token budget (provider-specific defaults apply if omitted; OpenAI uses fixed reasoning and ignores this field). Cogitate agents may also declare `cwd: "journal"` or `cwd: "repo"`; when omitted they default to `journal`, and repo-oriented prompts like `coder` should opt into `repo`. See [CORTEX.md](CORTEX.md) for agent configuration details. **Template variables:** Agent prompts can use template variables like `$name`, `$preferred`, and pronoun variables. See [PROMPT_TEMPLATES.md](PROMPT_TEMPLATES.md) for the complete template system documentation. diff --git a/docs/THINK.md b/docs/THINK.md index ae5effbb4..4d4c749d3 100644 --- a/docs/THINK.md +++ b/docs/THINK.md @@ -254,7 +254,7 @@ Providers implement `run_generate()`, `run_agenerate()`, and `run_cogitate()` fu System prompts in `talent/*.md` (markdown with JSON frontmatter). Apps can add custom agents in `apps/{app}/talent/`. -JSON metadata supports `title`, `provider`, `model`, `tools`, `schedule`, `priority`, `multi_facet`, and `load` keys. +JSON metadata supports `title`, `provider`, `model`, `tools`, `schedule`, `priority`, `multi_facet`, and `load` keys. Cogitate prompts may also set `cwd: "journal"` or `cwd: "repo"`; when omitted they default to `journal`, while repo-root agents such as `coder` should set `repo`. Generators reject `cwd`. **Important:** The `priority` field is **required** for all prompts with a `schedule`. Prompts without explicit priority will fail validation. See the [Unified Priority Execution](#unified-priority-execution) section for priority bands. diff --git a/talent/coder.md b/talent/coder.md index 802ee8b81..917f3696a 100644 --- a/talent/coder.md +++ b/talent/coder.md @@ -1,6 +1,7 @@ { "type": "cogitate", "write": true, + "cwd": "repo", "title": "Coder", "description": "Developer agent with full repo read/write access" } diff --git a/talent/heartbeat.md b/talent/heartbeat.md index e7fb7a33b..b1afe760c 100644 --- a/talent/heartbeat.md +++ b/talent/heartbeat.md @@ -20,9 +20,9 @@ check, maintain, close. ## Path notes -- `sol call identity agency --write` writes to `journal/sol/agency.md`. -- The git-tracked copy is `sol/agency.md` in the project root. -- After writing via `sol call`, copy `journal/sol/agency.md` to `sol/agency.md` before committing. +- `sol call identity agency --write` writes to `sol/agency.md` in the journal root. +- The git-tracked copy is `../sol/agency.md` (in the project root). +- After writing via `sol call`, copy `sol/agency.md` to `../sol/agency.md` before committing. ## Step 1: Check system health diff --git a/tests/test_cli_provider.py b/tests/test_cli_provider.py index 1ce1b751e..b8e8029b3 100644 --- a/tests/test_cli_provider.py +++ b/tests/test_cli_provider.py @@ -365,6 +365,37 @@ class TestCLIRunnerExitCode: assert captured_env is provided_env assert sentinel_key not in captured_env + def test_cwd_passed_to_create_subprocess_exec(self): + events = [] + callback = JSONEventCallback(events.append) + aggregator = ThinkingAggregator(callback, model="test-model") + captured_cwd = None + + async def create_subprocess_exec(*args, **kwargs): + nonlocal captured_cwd + captured_cwd = kwargs["cwd"] + return _make_process([], [], 0) + + runner = CLIRunner( + cmd=["fakecli", "--json"], + prompt_text="test", + translate=lambda _e, _a, _c: None, + callback=callback, + aggregator=aggregator, + cwd=Path("/tmp"), + ) + + with ( + patch( + "think.providers.cli.asyncio.create_subprocess_exec", + AsyncMock(side_effect=create_subprocess_exec), + ), + patch("think.providers.cli.shutil.which", return_value="/usr/bin/fakecli"), + ): + asyncio.run(runner.run()) + + assert captured_cwd == "/tmp" + class TestCLIRunnerFirstEventTimeout: def test_first_event_timeout_includes_stderr(self): diff --git a/tests/test_cortex.py b/tests/test_cortex.py index 94b143292..7d3abb61a 100644 --- a/tests/test_cortex.py +++ b/tests/test_cortex.py @@ -186,7 +186,7 @@ def test_spawn_generator_via_subprocess( config = { "event": "request", "ts": 987654321, - "name": "activity", + "name": "decisions", "day": "20240101", "output": "md", } @@ -213,7 +213,7 @@ def test_spawn_generator_via_subprocess( written_data = mock_process.stdin.write.call_args[0][0] ndjson = json.loads(written_data.strip()) assert ndjson["event"] == "request" - assert ndjson["name"] == "activity" + assert ndjson["name"] == "decisions" assert ndjson["day"] == "20240101" assert ndjson["output"] == "md" @@ -234,6 +234,97 @@ def test_spawn_generator_via_subprocess( mock_timer_instance.start.assert_called_once() +@patch("think.talent.get_agent") +@patch("think.cortex.subprocess.Popen") +@patch("think.cortex.threading.Thread") +@patch("think.cortex.threading.Timer") +def test_spawn_subprocess_uses_cwd_from_talent( + mock_timer, + mock_thread, + mock_popen, + mock_get_agent, + cortex_service, + mock_journal, +): + mock_process = MagicMock() + mock_process.pid = 24680 + mock_process.poll.return_value = None + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_popen.return_value = mock_process + mock_get_agent.return_value = {"type": "cogitate", "cwd": "journal"} + + mock_timer_instance = MagicMock() + mock_timer.return_value = mock_timer_instance + + agent_id = "24680" + file_path = mock_journal / "agents" / f"{agent_id}_active.jsonl" + request = { + "event": "request", + "ts": 24680, + "prompt": "Test prompt", + "provider": "openai", + "name": "unified", + "model": GPT_5, + } + + cortex_service._spawn_subprocess( + agent_id, + file_path, + request, + ["sol", "agents"], + "agent", + ) + + assert mock_popen.call_args.kwargs["cwd"] == str(mock_journal) + + +@patch("think.talent.get_agent") +@patch("think.cortex.subprocess.Popen") +@patch("think.cortex.threading.Thread") +@patch("think.cortex.threading.Timer") +def test_spawn_subprocess_skips_cwd_for_generate( + mock_timer, + mock_thread, + mock_popen, + mock_get_agent, + cortex_service, + mock_journal, +): + mock_process = MagicMock() + mock_process.pid = 13579 + mock_process.poll.return_value = None + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_popen.return_value = mock_process + mock_get_agent.return_value = {"type": "generate"} + + mock_timer_instance = MagicMock() + mock_timer.return_value = mock_timer_instance + + agent_id = "13579" + file_path = mock_journal / "agents" / f"{agent_id}_active.jsonl" + request = { + "event": "request", + "ts": 13579, + "name": "decisions", + "day": "20240101", + "output": "md", + } + + cortex_service._spawn_subprocess( + agent_id, + file_path, + request, + ["sol", "agents"], + "agent", + ) + + assert mock_popen.call_args.kwargs["cwd"] is None + + def test_monitor_stdout_json_events(cortex_service, mock_journal): """Test monitoring stdout with JSON events.""" from io import StringIO diff --git a/tests/test_openai.py b/tests/test_openai.py index e12e63a49..274474860 100644 --- a/tests/test_openai.py +++ b/tests/test_openai.py @@ -4,6 +4,7 @@ import asyncio import functools import importlib +from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch from think.models import GPT_5 @@ -347,6 +348,36 @@ class TestRunCogitate: assert "resume" in MockCLIRunner.last_instance.cmd assert "thread-abc" in MockCLIRunner.last_instance.cmd + def test_run_cogitate_passes_cwd_to_cli_runner(self): + provider = _openai_provider() + events = [] + + class MockCLIRunner: + last_instance = None + + def __init__(self, **kwargs): + self.kwargs = kwargs + self.cmd = kwargs["cmd"] + self.prompt_text = kwargs["prompt_text"] + self.cli_session_id = "test-session-id" + self.run = AsyncMock(return_value="test result") + MockCLIRunner.last_instance = self + + with patch("think.providers.openai.CLIRunner", MockCLIRunner): + asyncio.run( + provider.run_cogitate( + { + "prompt": "hello", + "model": GPT_5, + "cwd": "/fake/journal", + }, + events.append, + ) + ) + + assert MockCLIRunner.last_instance is not None + assert MockCLIRunner.last_instance.kwargs["cwd"] == Path("/fake/journal") + def test_system_instruction_prepended(self): provider = _openai_provider() events = [] diff --git a/tests/test_talent.py b/tests/test_talent.py index a8dce1c07..5f6458c10 100644 --- a/tests/test_talent.py +++ b/tests/test_talent.py @@ -3,7 +3,15 @@ """Tests for think.talent module.""" -from think.talent import get_agent_filter, source_is_enabled, source_is_required +import pytest + +from think.talent import ( + _validate_cwd, + get_agent, + get_agent_filter, + source_is_enabled, + source_is_required, +) def test_source_is_enabled_bool(): @@ -59,3 +67,41 @@ def test_get_agent_filter_dict(): filter_dict = {"entities": True, "meetings": "required", "flow": False} assert get_agent_filter(filter_dict) == filter_dict assert get_agent_filter({}) == {} + + +def test_validate_cwd_defaults_cogitate_to_journal(): + assert _validate_cwd(None, "cogitate", "test-agent") == "journal" + + +def test_validate_cwd_accepts_repo(): + assert _validate_cwd("repo", "cogitate", "test-agent") == "repo" + + +def test_validate_cwd_accepts_journal(): + assert _validate_cwd("journal", "cogitate", "test-agent") == "journal" + + +def test_validate_cwd_rejects_generate_with_cwd(): + with pytest.raises( + ValueError, + match="Prompt 'test-agent' sets 'cwd' but cwd is only valid for type: cogitate", + ): + _validate_cwd("journal", "generate", "test-agent") + + +def test_validate_cwd_rejects_invalid_value(): + with pytest.raises( + ValueError, + match="Prompt 'test-agent' has invalid 'cwd' value 'home'", + ): + _validate_cwd("home", "cogitate", "test-agent") + + +def test_get_agent_normalizes_cwd_for_cogitate(): + config = get_agent("chat") + assert config["cwd"] == "journal" + + +def test_get_agent_preserves_repo_cwd_for_coder(): + config = get_agent("coder") + assert config["cwd"] == "repo" diff --git a/think/agents.py b/think/agents.py index 62034a5fa..1e579b907 100644 --- a/think/agents.py +++ b/think/agents.py @@ -45,6 +45,7 @@ from think.utils import ( format_day, format_segment_times, get_journal, + get_project_root, now_ms, require_solstone, segment_parse, @@ -460,9 +461,32 @@ def prepare_config(request: dict) -> dict: # Convert path string to Path object for convenience agent_path = Path(config["path"]) if config.get("path") else None sources = config.get("sources", {}) + talent_cwd = config.get("cwd") # Merge request values (request overrides agent defaults) config.update({k: v for k, v in request.items() if v is not None}) + request_cwd = request.get("cwd") + if request_cwd is not None and request_cwd != talent_cwd: + raise ValueError( + f"Request overrides 'cwd' for talent '{name}' are not allowed " + f"({talent_cwd!r} != {request_cwd!r})" + ) + + cwd_value = config.get("cwd") + if cwd_value == "journal": + try: + journal_path = Path(get_journal()) + except Exception as exc: + raise RuntimeError( + f"Cannot resolve cwd for talent '{name}' — journal path unavailable" + ) from exc + if not journal_path.exists(): + raise RuntimeError( + f"Cannot resolve cwd for talent '{name}' — journal path unavailable" + ) + config["cwd"] = str(journal_path) + elif cwd_value == "repo": + config["cwd"] = get_project_root() # Populate stream from env if not already in config (dream passes it as # SOL_STREAM env var but not as a top-level request key — hooks need it) diff --git a/think/cortex.py b/think/cortex.py index 02827ebae..06dfec8f0 100644 --- a/think/cortex.py +++ b/think/cortex.py @@ -29,7 +29,7 @@ from typing import Any, Dict, Optional from think.callosum import CallosumConnection from think.runner import _atomic_symlink -from think.utils import get_journal, get_rev, now_ms +from think.utils import get_journal, get_project_root, get_rev, now_ms class AgentProcess: @@ -193,6 +193,8 @@ class CortexService: - Event relay to Callosum All config loading, validation, and hydration is done by agents.py. + Cortex only resolves talent cwd early so the child process starts in + the correct working directory. """ agent_id = request.get("agent_id") if not agent_id: @@ -280,6 +282,28 @@ class CortexService: # Spawn the subprocess self.logger.info(f"Spawning {process_type} {agent_id}: {cmd}") self.logger.debug(f"NDJSON input: {ndjson_input}") + subprocess_cwd = None + if process_type == "agent": + from think.talent import get_agent + + talent_key = str(config.get("name", "unified")) + talent_config = get_agent(talent_key) + if talent_config.get("type") == "cogitate": + # Resolve here because prepare_config() runs inside sol agents. + cwd_value = talent_config.get("cwd") + if cwd_value == "journal": + try: + subprocess_cwd = str(Path(get_journal())) + except Exception as exc: + raise RuntimeError( + f"Cannot resolve cwd for talent '{talent_key}'" + ) from exc + elif cwd_value == "repo": + subprocess_cwd = get_project_root() + else: + raise RuntimeError( + f"Cannot resolve cwd for talent '{talent_key}'" + ) process = subprocess.Popen( cmd, @@ -289,6 +313,7 @@ class CortexService: text=True, env=env, bufsize=1, + cwd=subprocess_cwd, ) # Send input and close stdin diff --git a/think/providers/anthropic.py b/think/providers/anthropic.py index 59f8291b8..0ae37fcb5 100644 --- a/think/providers/anthropic.py +++ b/think/providers/anthropic.py @@ -34,6 +34,7 @@ from __future__ import annotations import logging import os import traceback +from pathlib import Path from typing import Any, Callable from anthropic import AsyncAnthropic @@ -273,12 +274,14 @@ async def run_cogitate( ) -> str | None: return _translate_claude(event, agg, cb, pending_tools, result_meta) + cwd_value = config.get("cwd") runner = CLIRunner( cmd=cmd, prompt_text=prompt_body, translate=translate, callback=callback, aggregator=aggregator, + cwd=Path(cwd_value) if cwd_value else None, env=build_cogitate_env("ANTHROPIC_API_KEY"), ) diff --git a/think/providers/google.py b/think/providers/google.py index 442f3f744..fa43766f7 100644 --- a/think/providers/google.py +++ b/think/providers/google.py @@ -34,6 +34,7 @@ from __future__ import annotations import logging import os import traceback +from pathlib import Path from typing import Any, Callable from google import genai @@ -726,12 +727,14 @@ async def run_cogitate( return _translate_gemini(event, agg, cb, usage, pending_tools) aggregator = ThinkingAggregator(callback, model=model) + cwd_value = config.get("cwd") runner = CLIRunner( cmd=cmd, prompt_text=prompt_body, translate=translate, callback=callback, aggregator=aggregator, + cwd=Path(cwd_value) if cwd_value else None, env=build_cogitate_env("GOOGLE_API_KEY"), ) diff --git a/think/providers/ollama.py b/think/providers/ollama.py index a28076914..63a201361 100644 --- a/think/providers/ollama.py +++ b/think/providers/ollama.py @@ -50,6 +50,7 @@ from __future__ import annotations import logging import os import traceback +from pathlib import Path from typing import Any, Callable import httpx @@ -523,12 +524,14 @@ async def run_cogitate( return _translate_opencode(event, agg, cb, usage) aggregator = ThinkingAggregator(callback, model=model) + cwd_value = config.get("cwd") runner = CLIRunner( cmd=cmd, prompt_text=prompt_body, translate=translate, callback=callback, aggregator=aggregator, + cwd=Path(cwd_value) if cwd_value else None, env=_build_opencode_env(), # Local models are slower than cloud APIs; allow more time for # the first event (model loading + initial inference). diff --git a/think/providers/openai.py b/think/providers/openai.py index 13843b275..725db492e 100644 --- a/think/providers/openai.py +++ b/think/providers/openai.py @@ -36,6 +36,7 @@ import functools import logging import os import traceback +from pathlib import Path from typing import Any, Callable from think.models import GPT_5, OPENAI_EFFORT_SUFFIXES @@ -205,12 +206,14 @@ async def run_cogitate( usage_holder: list[dict[str, Any]] = [{}] aggregator = ThinkingAggregator(cb, model) translate = functools.partial(_translate_codex, usage_holder=usage_holder) + cwd_value = config.get("cwd") runner = CLIRunner( cmd=cmd, prompt_text=prompt_text, translate=translate, callback=cb, aggregator=aggregator, + cwd=Path(cwd_value) if cwd_value else None, env=build_cogitate_env("OPENAI_API_KEY"), ) diff --git a/think/talent.py b/think/talent.py index 37220eed1..897d83365 100644 --- a/think/talent.py +++ b/think/talent.py @@ -40,6 +40,34 @@ APPS_DIR = Path(__file__).parent.parent / "apps" # --------------------------------------------------------------------------- +def _validate_cwd(raw_cwd: Any, talent_type: Any, key: str) -> str | None: + """Validate and normalize the optional talent cwd setting.""" + if talent_type == "cogitate": + if raw_cwd is None: + return "journal" + if raw_cwd in {"journal", "repo"}: + return raw_cwd + raise ValueError( + f"Prompt '{key}' has invalid 'cwd' value '{raw_cwd}' " + "(must be 'journal' or 'repo')" + ) + + if talent_type == "generate": + if raw_cwd is not None: + raise ValueError( + f"Prompt '{key}' sets 'cwd' but cwd is only valid for type: cogitate" + ) + return None + + if raw_cwd is None: + return None + + raise ValueError( + f"Prompt '{key}' has invalid 'cwd' value '{raw_cwd}' " + "(must be 'journal' or 'repo')" + ) + + def key_to_context(key: str) -> str: """Convert talent config key to context pattern. @@ -288,6 +316,14 @@ def get_talent_configs( f'(activity types to match, or ["*"] for all types).' ) + # Validate: cwd is only valid for cogitate prompts and defaults there + for key, info in configs.items(): + normalized_cwd = _validate_cwd(info.get("cwd"), info.get("type"), key) + if normalized_cwd is None: + info.pop("cwd", None) + else: + info["cwd"] = normalized_cwd + return {key: info for key, info in configs.items() if matches_filter(info)} @@ -456,6 +492,11 @@ def get_agent( # Load config from frontmatter - preserve all fields post = frontmatter.load(md_path) config = dict(post.metadata) if post.metadata else {} + normalized_cwd = _validate_cwd(config.get("cwd"), config.get("type"), name) + if normalized_cwd is None: + config.pop("cwd", None) + else: + config["cwd"] = normalized_cwd # Store path for later use config["path"] = str(md_path) -- 2.51.2