From 8c28797bca9f2f0adb69be97470f6fa79f5f7cb4 Mon Sep 17 00:00:00 2001 From: daniel Date: Sat, 16 May 2026 07:07:46 -0500 Subject: [PATCH] feat: add /goal command for durable multi-turn objectives - /goal set|status|checkpoint|pause|resume|clear intercept in main.py - GOAL.md injected into system prompt when active (prompt_builder.py) - Goal context added to heartbeat for autonomous progress --- agentclaw/app/agent_claw_main/main.py | 148 +++++++++++++++++- .../app/agent_claw_main/prompt_builder.py | 10 ++ 2 files changed, 157 insertions(+), 1 deletion(-) diff --git a/agentclaw/app/agent_claw_main/main.py b/agentclaw/app/agent_claw_main/main.py index b7764cf..f81dac8 100644 --- a/agentclaw/app/agent_claw_main/main.py +++ b/agentclaw/app/agent_claw_main/main.py @@ -462,6 +462,131 @@ def aws_describe_service(service: str, region: str = "us-east-1") -> str: return f"Service {service} not yet implemented. Try: lambda, s3, cloudformation, dynamodb, sqs" +# ── Goal helpers ────────────────────────────────────────────────────────── + +from datetime import datetime as _dt +from zoneinfo import ZoneInfo as _ZoneInfo + +def _now_iso() -> str: + return _dt.now(_ZoneInfo('America/Chicago')).strftime('%Y-%m-%dT%H:%M:%S%z') + + +def _read_goal() -> str | None: + """Read GOAL.md from S3, return content or None.""" + try: + return ws_tools.read_file('GOAL.md') + except Exception: + return None + + +def _write_goal(content: str): + ws_tools.write_file('GOAL.md', content) + invalidate_prompt() + + +def _delete_goal(): + try: + _s3 = boto3.client('s3') + _s3.delete_object(Bucket=ws_tools.get_bucket(), Key='GOAL.md') + ws_tools._cache.pop('GOAL.md', None) + invalidate_prompt() + except Exception: + pass + + +def _parse_goal_status(content: str) -> str: + """Extract Status field from GOAL.md content.""" + for line in content.splitlines(): + if line.startswith('**Status:**'): + return line.split('**Status:**')[1].strip() + return 'unknown' + + +def _get_active_goal_context() -> dict | None: + """Return goal context dict if active, else None.""" + content = _read_goal() + if not content or _parse_goal_status(content) != 'active': + return None + objective = stopping = last_cp = '' + for line in content.splitlines(): + if line.startswith('**Objective:**'): + objective = line.split('**Objective:**')[1].strip() + elif line.startswith('**Stopping condition:**'): + stopping = line.split('**Stopping condition:**')[1].strip() + elif line.startswith('- ['): + last_cp = line # last checkpoint line wins + return {'objective': objective, 'stopping_condition': stopping, 'last_checkpoint': last_cp} + + +def _handle_goal_command(prompt: str) -> str | None: + """Handle /goal commands. Returns reply string or None if not a goal command.""" + parts = prompt.split(None, 2) # ['/goal', subcommand?, rest?] + cmd = parts[1] if len(parts) > 1 else 'status' + rest = parts[2] if len(parts) > 2 else '' + + if cmd == 'set': + if not rest: + return '❌ Usage: `/goal set ` or `/goal set | `' + if '|' in rest: + objective, stopping = [s.strip() for s in rest.split('|', 1)] + else: + objective, stopping = rest.strip(), 'not specified' + content = ( + f'# Goal\n\n' + f'**Objective:** {objective}\n' + f'**Stopping condition:** {stopping}\n' + f'**Status:** active\n' + f'**Set at:** {_now_iso()}\n\n' + f'## Checkpoint log\n' + ) + _write_goal(content) + return f'✅ Goal set: {objective}\nStopping condition: {stopping}' + + elif cmd in ('status', '/goal'): + content = _read_goal() + if not content: + return '📋 No active goal. Use `/goal set ` to set one.' + return content + + elif cmd == 'checkpoint': + if not rest: + return '❌ Usage: `/goal checkpoint `' + content = _read_goal() + if not content: + return '❌ No active goal to checkpoint.' + entry = f'- [{_now_iso()}] {rest}\n' + content = content.rstrip() + '\n' + entry + _write_goal(content) + return f'✅ Checkpoint added: {rest}' + + elif cmd == 'pause': + content = _read_goal() + if not content: + return '❌ No active goal to pause.' + content = content.replace('**Status:** active', '**Status:** paused') + _write_goal(content) + return '⏸️ Goal paused.' + + elif cmd == 'resume': + content = _read_goal() + if not content: + return '❌ No goal to resume.' + content = content.replace('**Status:** paused', '**Status:** active') + _write_goal(content) + return '▶️ Goal resumed.' + + elif cmd == 'clear': + _delete_goal() + return '🗑️ Goal cleared.' + + else: + # Not a recognized subcommand — treat the whole thing as status check + content = _read_goal() + if not content: + return '📋 No active goal. Use `/goal set ` to set one.' + return content + + # ── Entrypoint ──────────────────────────────────────────────────────────── # Module-level actor_id for tool closures (set per-invocation) @@ -600,16 +725,37 @@ async def main(payload: dict, context): tools=all_tools, ) + # Intercept /goal commands — handle directly without LLM + prompt = payload.get('prompt', '') + if prompt.strip().startswith('/goal'): + goal_reply = _handle_goal_command(prompt.strip()) + if goal_reply is not None: + yield {'data': goal_reply} + _typing_active = False + session_manager.close() + mcp_loader.close_mcp_clients(_mcp_to_close) + return + # Intercept heartbeat: replace bare [HEARTBEAT] with a strict-format instruction. # Agent-runner suppresses replies that start with HEARTBEAT_OK, so only real alerts reach Telegram. - prompt = payload.get('prompt', '') if prompt.strip() == '[HEARTBEAT]': + # Inject goal context into heartbeat if active + goal_ctx = _get_active_goal_context() + goal_heartbeat = '' + if goal_ctx: + goal_heartbeat = ( + f' You have an active goal: "{goal_ctx["objective"]}". ' + f'Stopping condition: "{goal_ctx["stopping_condition"]}". ' + f'Last checkpoint: "{goal_ctx["last_checkpoint"]}". ' + f'Make progress toward this goal or report blockers.' + ) prompt = ( 'HEARTBEAT CHECK: Silently check for anything urgent Daniel should know about ' '(calendar events starting within 2 hours, unread urgent emails, overdue reminders). ' 'Do NOT narrate your checking process. ' 'If nothing is urgent: reply with the single word HEARTBEAT_OK and nothing else. ' 'If something IS urgent: reply with 2-3 lines max summarising only the urgent items.' + + goal_heartbeat ) final_message = None diff --git a/agentclaw/app/agent_claw_main/prompt_builder.py b/agentclaw/app/agent_claw_main/prompt_builder.py index 68df95b..1b7ed8b 100644 --- a/agentclaw/app/agent_claw_main/prompt_builder.py +++ b/agentclaw/app/agent_claw_main/prompt_builder.py @@ -46,6 +46,16 @@ def _get_base_prompt(actor_id: str = '') -> str: s3 = boto3.client('s3') parts = [] + # Inject active goal at the top of context + try: + obj = s3.get_object(Bucket=bucket, Key='GOAL.md') + goal_content = obj['Body'].read().decode('utf-8') + if '**Status:** active' in goal_content: + parts.append(f'## Active Goal\n{goal_content}') + print(f'[prompt_builder] Injected GOAL.md ({len(goal_content)} bytes)') + except Exception: + pass + for fname in ['SOUL.md', 'STATUS.md']: try: obj = s3.get_object(Bucket=bucket, Key=fname)