personal memory agent

feat: add sol engage subcommand for agent delegation with --wait support

+265 -19
+2
sol.py
··· 65 65 "cortex": "think.cortex", 66 66 "muse": "think.muse_cli", 67 67 "call": "think.call", 68 + "engage": "think.engage", 68 69 "help": "think.help_cli", 69 70 "chat": "think.chat_cli", 70 71 "heartbeat": "think.heartbeat", ··· 121 122 "cortex", 122 123 "muse", 123 124 "call", 125 + "engage", 124 126 ], 125 127 "Convey (web UI)": [ 126 128 "convey",
+3 -1
tests/test_cogitate_coder.py
··· 200 200 201 201 assert result.exit_code == 0 202 202 assert "1710864123456" in result.output 203 - mock_cortex.assert_called_once_with(prompt="Fix the bug", name="coder") 203 + mock_cortex.assert_called_once_with( 204 + prompt="Fix the bug", name="coder", config=None 205 + ) 204 206 205 207 def test_handoff_empty_stdin(self): 206 208 """Empty stdin produces error and exit code 1."""
+155
tests/test_engage.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Tests for the engage CLI command.""" 5 + 6 + import importlib 7 + from unittest.mock import patch 8 + 9 + from typer.testing import CliRunner 10 + 11 + runner = CliRunner() 12 + 13 + 14 + def _engage_app(): 15 + mod = importlib.reload(importlib.import_module("think.engage")) 16 + return mod.engage_app 17 + 18 + 19 + def _invoke_engage(*args, input_text=""): 20 + return runner.invoke(_engage_app(), [*args], input=input_text) 21 + 22 + 23 + def _call_app(): 24 + call_mod = importlib.reload(importlib.import_module("think.call")) 25 + return call_mod.call_app 26 + 27 + 28 + class TestEngage: 29 + def test_fire_and_forget(self): 30 + with patch( 31 + "think.cortex_client.cortex_request", return_value="agent-123" 32 + ) as mock_cr: 33 + result = _invoke_engage("coder", input_text="fix the bug\n") 34 + 35 + assert result.exit_code == 0 36 + assert "agent-123" in result.output 37 + mock_cr.assert_called_once_with( 38 + prompt="fix the bug", name="coder", config=None 39 + ) 40 + 41 + def test_empty_stdin(self): 42 + result = _invoke_engage("coder", input_text="") 43 + 44 + assert result.exit_code == 1 45 + assert ( 46 + "no prompt" in result.output.lower() 47 + or "no prompt" in (result.stderr or "").lower() 48 + ) 49 + 50 + def test_cortex_failure(self): 51 + with patch("think.cortex_client.cortex_request", return_value=None): 52 + result = _invoke_engage("coder", input_text="fix the bug\n") 53 + 54 + assert result.exit_code == 1 55 + 56 + def test_wait_success(self): 57 + with patch( 58 + "think.cortex_client.cortex_request", return_value="agent-123" 59 + ), patch( 60 + "think.cortex_client.wait_for_agents", 61 + return_value=({"agent-123": "finish"}, []), 62 + ), patch( 63 + "think.cortex_client.read_agent_events", 64 + return_value=[{"event": "finish", "result": "All fixed!"}], 65 + ): 66 + result = _invoke_engage("coder", "--wait", input_text="fix the bug\n") 67 + 68 + assert result.exit_code == 0 69 + assert "All fixed!" in result.output 70 + 71 + def test_wait_error(self): 72 + with patch( 73 + "think.cortex_client.cortex_request", return_value="agent-123" 74 + ), patch( 75 + "think.cortex_client.wait_for_agents", 76 + return_value=({"agent-123": "error"}, []), 77 + ): 78 + result = _invoke_engage("coder", "--wait", input_text="fix the bug\n") 79 + 80 + assert result.exit_code == 1 81 + 82 + def test_wait_timeout(self): 83 + with patch( 84 + "think.cortex_client.cortex_request", return_value="agent-123" 85 + ), patch( 86 + "think.cortex_client.wait_for_agents", 87 + return_value=({}, ["agent-123"]), 88 + ): 89 + result = _invoke_engage("coder", "--wait", input_text="fix the bug\n") 90 + 91 + assert result.exit_code == 1 92 + combined_output = result.output 93 + if result.stderr: 94 + combined_output += result.stderr 95 + assert "timed out" in combined_output.lower() 96 + 97 + def test_facet_and_day(self): 98 + with patch( 99 + "think.cortex_client.cortex_request", return_value="agent-123" 100 + ) as mock_cr: 101 + result = _invoke_engage( 102 + "coder", 103 + "--facet", 104 + "work", 105 + "--day", 106 + "20260404", 107 + input_text="do stuff\n", 108 + ) 109 + 110 + assert result.exit_code == 0 111 + mock_cr.assert_called_once_with( 112 + prompt="do stuff", 113 + name="coder", 114 + config={"facet": "work", "day": "20260404"}, 115 + ) 116 + 117 + def test_facet_only(self): 118 + with patch( 119 + "think.cortex_client.cortex_request", return_value="agent-123" 120 + ) as mock_cr: 121 + result = _invoke_engage( 122 + "coder", "--facet", "work", input_text="do stuff\n" 123 + ) 124 + 125 + assert result.exit_code == 0 126 + mock_cr.assert_called_once_with( 127 + prompt="do stuff", name="coder", config={"facet": "work"} 128 + ) 129 + 130 + def test_day_only(self): 131 + with patch( 132 + "think.cortex_client.cortex_request", return_value="agent-123" 133 + ) as mock_cr: 134 + result = _invoke_engage( 135 + "coder", "--day", "20260404", input_text="do stuff\n" 136 + ) 137 + 138 + assert result.exit_code == 0 139 + mock_cr.assert_called_once_with( 140 + prompt="do stuff", name="coder", config={"day": "20260404"} 141 + ) 142 + 143 + 144 + class TestHandoffDeprecated: 145 + def test_handoff_still_works(self): 146 + with patch("think.cortex_client.cortex_request", return_value="agent-123"): 147 + result = runner.invoke(_call_app(), ["handoff", "coder"], input="fix the bug\n") 148 + 149 + assert result.exit_code == 0 150 + assert "agent-123" in result.output 151 + 152 + def test_handoff_hidden(self): 153 + result = runner.invoke(_call_app(), ["--help"]) 154 + 155 + assert "handoff" not in result.output
+1 -1
tests/test_handoff.py
··· 27 27 result = _invoke_handoff("coder", input_text="fix the bug\n") 28 28 assert result.exit_code == 0 29 29 assert "agent-123" in result.output 30 - mock_cr.assert_called_once_with(prompt="fix the bug", name="coder") 30 + mock_cr.assert_called_once_with(prompt="fix the bug", name="coder", config=None) 31 31 32 32 33 33 def _assert_handoff_empty_stdin():
+4 -17
think/call.py
··· 111 111 typer.echo(f"Navigate: {' '.join(parts)}") 112 112 113 113 114 - @call_app.command("handoff") 114 + @call_app.command("handoff", hidden=True) 115 115 def handoff( 116 116 agent: str = typer.Argument(help="Agent name to hand off to (e.g. coder)."), 117 117 ) -> None: 118 - """Spawn a cogitate agent with a request from stdin (fire-and-forget). 119 - 120 - Reads a prompt from stdin, sends it to cortex as an agent request, 121 - prints the agent_id to stdout, and exits immediately. 122 - 123 - Example:: 124 - 125 - echo 'Fix the matching bug' | sol call handoff coder 126 - """ 118 + """Spawn a cogitate agent with a request from stdin (fire-and-forget).""" 127 119 prompt = sys.stdin.read() 128 120 if not prompt.strip(): 129 121 typer.echo("Error: no prompt provided on stdin.", err=True) 130 122 raise typer.Exit(1) 131 123 132 - from think.cortex_client import cortex_request 133 - 134 - agent_id = cortex_request(prompt=prompt.strip(), name=agent) 135 - if agent_id is None: 136 - typer.echo("Error: failed to send cortex request.", err=True) 137 - raise typer.Exit(1) 124 + from think.engage import _engage 138 125 139 - typer.echo(agent_id) 126 + _engage(agent, prompt.strip()) 140 127 141 128 142 129 def main() -> None:
+100
think/engage.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """CLI for delegating work to cogitate agents. 5 + 6 + Provides ``sol engage <name>`` as the primary agent delegation command, 7 + replacing ``sol call handoff`` with richer options. 8 + """ 9 + 10 + import sys 11 + 12 + import typer 13 + 14 + engage_app = typer.Typer(name="engage") 15 + 16 + 17 + def _engage( 18 + name: str, 19 + prompt: str, 20 + *, 21 + wait: bool = False, 22 + facet: str | None = None, 23 + day: str | None = None, 24 + ) -> None: 25 + config_data = {} 26 + if facet is not None: 27 + config_data["facet"] = facet 28 + if day is not None: 29 + config_data["day"] = day 30 + config = config_data or None 31 + 32 + from think.cortex_client import cortex_request 33 + 34 + agent_id = cortex_request(prompt=prompt, name=name, config=config) 35 + if agent_id is None: 36 + typer.echo("Error: failed to send cortex request.", err=True) 37 + raise typer.Exit(1) 38 + 39 + if not wait: 40 + typer.echo(agent_id) 41 + return 42 + 43 + from think.cortex_client import read_agent_events, wait_for_agents 44 + 45 + completed, timed_out = wait_for_agents([agent_id]) 46 + if agent_id in timed_out: 47 + typer.echo("Error: agent timed out.", err=True) 48 + raise typer.Exit(1) 49 + 50 + end_state = completed.get(agent_id, "error") 51 + if end_state != "finish": 52 + typer.echo(f"Error: agent ended with state: {end_state}", err=True) 53 + raise typer.Exit(1) 54 + 55 + events = read_agent_events(agent_id) 56 + result = "" 57 + for event in reversed(events): 58 + if event.get("event") == "finish": 59 + result = event.get("result", "") 60 + break 61 + 62 + typer.echo(result) 63 + 64 + 65 + @engage_app.command() 66 + def engage( 67 + name: str = typer.Argument(help="Agent name to delegate to (e.g. coder)."), 68 + wait: bool = typer.Option( 69 + False, 70 + "--wait", 71 + help="Block until the agent completes and print its result.", 72 + ), 73 + facet: str | None = typer.Option( 74 + None, "--facet", help="Facet context for the agent." 75 + ), 76 + day: str | None = typer.Option( 77 + None, "--day", help="Day context for the agent (e.g. 20260404)." 78 + ), 79 + ) -> None: 80 + """Delegate work to a cogitate agent. 81 + 82 + Reads a prompt from stdin, sends it to cortex as an agent request. 83 + By default, prints the agent_id and exits immediately (fire-and-forget). 84 + 85 + Example:: 86 + 87 + echo 'Fix the matching bug' | sol engage coder 88 + echo 'Fix the matching bug' | sol engage coder --wait 89 + """ 90 + prompt = sys.stdin.read() 91 + if not prompt.strip(): 92 + typer.echo("Error: no prompt provided on stdin.", err=True) 93 + raise typer.Exit(1) 94 + 95 + _engage(name, prompt.strip(), wait=wait, facet=facet, day=day) 96 + 97 + 98 + def main() -> None: 99 + """Entry point for ``sol engage``.""" 100 + engage_app()