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
This commit is contained in:
@@ -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"
|
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 <objective>` or `/goal set <objective> | <stopping condition>`'
|
||||||
|
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 <objective>` to set one.'
|
||||||
|
return content
|
||||||
|
|
||||||
|
elif cmd == 'checkpoint':
|
||||||
|
if not rest:
|
||||||
|
return '❌ Usage: `/goal checkpoint <note>`'
|
||||||
|
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 <objective>` to set one.'
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
# ── Entrypoint ────────────────────────────────────────────────────────────
|
# ── Entrypoint ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Module-level actor_id for tool closures (set per-invocation)
|
# Module-level actor_id for tool closures (set per-invocation)
|
||||||
@@ -600,16 +725,37 @@ async def main(payload: dict, context):
|
|||||||
tools=all_tools,
|
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.
|
# 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.
|
# Agent-runner suppresses replies that start with HEARTBEAT_OK, so only real alerts reach Telegram.
|
||||||
prompt = payload.get('prompt', '')
|
|
||||||
if prompt.strip() == '[HEARTBEAT]':
|
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 = (
|
prompt = (
|
||||||
'HEARTBEAT CHECK: Silently check for anything urgent Daniel should know about '
|
'HEARTBEAT CHECK: Silently check for anything urgent Daniel should know about '
|
||||||
'(calendar events starting within 2 hours, unread urgent emails, overdue reminders). '
|
'(calendar events starting within 2 hours, unread urgent emails, overdue reminders). '
|
||||||
'Do NOT narrate your checking process. '
|
'Do NOT narrate your checking process. '
|
||||||
'If nothing is urgent: reply with the single word HEARTBEAT_OK and nothing else. '
|
'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.'
|
'If something IS urgent: reply with 2-3 lines max summarising only the urgent items.'
|
||||||
|
+ goal_heartbeat
|
||||||
)
|
)
|
||||||
|
|
||||||
final_message = None
|
final_message = None
|
||||||
|
|||||||
@@ -46,6 +46,16 @@ def _get_base_prompt(actor_id: str = '') -> str:
|
|||||||
s3 = boto3.client('s3')
|
s3 = boto3.client('s3')
|
||||||
parts = []
|
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']:
|
for fname in ['SOUL.md', 'STATUS.md']:
|
||||||
try:
|
try:
|
||||||
obj = s3.get_object(Bucket=bucket, Key=fname)
|
obj = s3.get_object(Bucket=bucket, Key=fname)
|
||||||
|
|||||||
Reference in New Issue
Block a user