Compare commits

...

5 Commits

Author SHA1 Message Date
daniel
4ca5fee2c0 refactor: move factcloud from hardcoded SSM to per-user DynamoDB oauth2_m2m connection
- Add oauth2_m2m auth type to mcp_loader.py (client_secret in record, not SSM)
- Remove _get_factcloud_token(), FACTCLOUD_* config, factcloud_clients from main.py
- Seed Daniel's factcloud connection into enrolled_services.mcp_connections
- factcloud now loaded dynamically via mcp_loader at session start
2026-05-16 09:49:28 -05:00
daniel
e77417b6cd feat: wire factcloud as direct MCP connection, drop knowledge_agent subagent
- Rename FACTBASE_CLOUD_* -> FACTCLOUD_* in config.py + SSM paths
- factcloud MCPClient added directly to main agent tool set
- knowledge_agent subagent removed (SSM + TOOL_PRESETS)
- System prompt updated: factcloud tools are direct, not via subagent
2026-05-16 09:25:55 -05:00
daniel
ef5734101e fix: add knowledge_agent to system prompt subagent list 2026-05-16 07:11:39 -05:00
daniel
8c28797bca 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
2026-05-16 07:07:46 -05:00
daniel
42dbdcde9e feat: factbase-cloud integration — knowledge_agent subagent with M2M auth 2026-05-15 23:32:23 -05:00
5 changed files with 224 additions and 3 deletions

View File

@@ -13,7 +13,7 @@ def _load():
ssm = boto3.client('ssm', region_name='us-east-1') ssm = boto3.client('ssm', region_name='us-east-1')
names = list(_DEFAULTS.keys()) names = list(_DEFAULTS.keys())
try: try:
resp = ssm.get_parameters(Names=names) resp = ssm.get_parameters(Names=names, WithDecryption=True)
found = {p['Name']: p['Value'] for p in resp['Parameters']} found = {p['Name']: p['Value'] for p in resp['Parameters']}
except Exception: except Exception:
found = {} found = {}

View File

@@ -4,6 +4,7 @@ agent-claw Runtime 1 — main assistant agent.
Entrypoint for AgentCore CodeZip deployment. Entrypoint for AgentCore CodeZip deployment.
""" """
import os import os
import time
from strands import Agent, tool from strands import Agent, tool
from strands.models import BedrockModel from strands.models import BedrockModel
from bedrock_agentcore.runtime import BedrockAgentCoreApp from bedrock_agentcore.runtime import BedrockAgentCoreApp
@@ -90,6 +91,7 @@ except Exception as _e:
# ── Subagent loading ────────────────────────────────────────────────────── # ── Subagent loading ──────────────────────────────────────────────────────
TOOL_PRESETS = { TOOL_PRESETS = {
"aws": lambda: [MCPClient(lambda: aws_iam_streamablehttp_client(config.AWS_MCP_URL, aws_service="aws-mcp"))], "aws": lambda: [MCPClient(lambda: aws_iam_streamablehttp_client(config.AWS_MCP_URL, aws_service="aws-mcp"))],
"coding": lambda: [MCPClient(lambda: aws_iam_streamablehttp_client(config.AWS_MCP_URL, aws_service="aws-mcp")), run_code], "coding": lambda: [MCPClient(lambda: aws_iam_streamablehttp_client(config.AWS_MCP_URL, aws_service="aws-mcp")), run_code],
@@ -425,6 +427,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)
@@ -528,7 +655,7 @@ async def main(payload: dict, context):
system_prompt = system_prompt + '\n\n---\n\n' + ltm_block system_prompt = system_prompt + '\n\n---\n\n' + ltm_block
system_prompt += '\nAWS tools available: call_aws (any AWS API via AWS MCP Server), aws_list_lambda_functions, aws_get_cost_and_usage, aws_describe_service. Use call_aws directly for AWS API calls — do NOT say you lack AWS access.' system_prompt += '\nAWS tools available: call_aws (any AWS API via AWS MCP Server), aws_list_lambda_functions, aws_get_cost_and_usage, aws_describe_service. Use call_aws directly for AWS API calls — do NOT say you lack AWS access.'
system_prompt += '\n\nSubagents available — use them aggressively to save cost and improve quality:\n- aws_agent: all AWS infrastructure, cost, resource, IAM, CloudWatch queries\n- coding_agent: code writing, builds, deployments, CodeBuild/AppRunner/ECR\n- document_agent: summarize URLs, extract data from documents, process long text\nDefault to delegating; only answer directly for simple conversational responses or tasks that don\'t fit a subagent.' system_prompt += '\n\nSubagents available — use them aggressively to save cost and improve quality:\n- aws_agent: all AWS infrastructure, cost, resource, IAM, CloudWatch queries\n- coding_agent: code writing, builds, deployments, CodeBuild/AppRunner/ECR\n- document_agent: summarize URLs, extract data from documents, process long text\nYou also have direct access to factcloud MCP tools (your personal knowledge graph) loaded from your MCP connections — use them directly for any factbase, factcloud, or knowledge base queries. Do NOT say you lack access to factcloud.\nDefault to delegating to subagents; only answer directly for simple conversational responses or tasks that don\'t fit a subagent.'
# Model: claude-sonnet-4-6 via cross-region inference # Model: claude-sonnet-4-6 via cross-region inference
# NOTE: extended thinking disabled — causes retry/duplicate issues with streaming # NOTE: extended thinking disabled — causes retry/duplicate issues with streaming
@@ -563,16 +690,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

View File

@@ -45,12 +45,40 @@ def _get_oauth_token(conn: dict, actor_id: str) -> str:
return token return token
def _get_m2m_token(conn: dict, actor_id: str) -> str:
"""Fetch OAuth token for oauth2_m2m (secret stored directly in record)."""
cache_key = f"{actor_id}:{conn['name']}"
cached = _token_cache.get(cache_key)
if cached and cached['expires_at'] > time.time() + 60:
return cached['token']
data = urllib.parse.urlencode({
'grant_type': 'client_credentials',
'client_id': conn['client_id'],
'client_secret': conn['client_secret'],
'scope': conn.get('scopes', conn.get('scope', '')),
}).encode()
req = urllib.request.Request(conn['token_url'], data=data,
headers={'Content-Type': 'application/x-www-form-urlencoded'})
with urllib.request.urlopen(req, timeout=10) as resp:
body = json.loads(resp.read())
token = body['access_token']
expires_in = body.get('expires_in', 3600)
_token_cache[cache_key] = {'token': token, 'expires_at': time.time() + expires_in}
return token
def _resolve_auth_headers(conn: dict, actor_id: str) -> dict: def _resolve_auth_headers(conn: dict, actor_id: str) -> dict:
"""Resolve auth headers for a connection.""" """Resolve auth headers for a connection."""
auth_type = conn.get('auth_type', 'none') auth_type = conn.get('auth_type', 'none')
if auth_type == 'oauth_client_credentials': if auth_type == 'oauth_client_credentials':
token = _get_oauth_token(conn, actor_id) token = _get_oauth_token(conn, actor_id)
return {'Authorization': f'Bearer {token}'} return {'Authorization': f'Bearer {token}'}
elif auth_type == 'oauth2_m2m':
token = _get_m2m_token(conn, actor_id)
return {'Authorization': f'Bearer {token}'}
elif auth_type == 'bearer': elif auth_type == 'bearer':
token = _get_ssm_value(conn['token_ssm']) token = _get_ssm_value(conn['token_ssm'])
return {'Authorization': f'Bearer {token}'} return {'Authorization': f'Bearer {token}'}

View File

@@ -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)

35
scripts/seed_factcloud.py Normal file
View File

@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""Seed Daniel's factcloud MCP connection into DynamoDB."""
import boto3
ACTOR_ID = 'telegram:8537376738'
TABLE_NAME = 'agent-claw-users'
conn = {
'name': 'factcloud',
'url': 'https://factbase-cloud-gateway-2czetaoh3u.gateway.bedrock-agentcore.us-east-1.amazonaws.com/mcp',
'auth_type': 'oauth2_m2m',
'client_id': '5fo2q4fb452j3aekd55g3190i4',
'client_secret': '1e0bqs8r4jk90sbeivh96mn893mgmv96h2olvcq7m3o5gjpjc56p',
'token_url': 'https://factbase-cloud.auth.us-east-1.amazoncognito.com/oauth2/token',
'scopes': 'factbase-cloud/read factbase-cloud/write',
'enabled': True,
}
session = boto3.Session(profile_name='ai1', region_name='us-east-1')
ddb = session.resource('dynamodb')
table = ddb.Table(TABLE_NAME)
# Get existing connections, upsert factcloud
resp = table.get_item(Key={'actor_id': ACTOR_ID})
services = resp.get('Item', {}).get('enrolled_services', {})
connections = services.get('mcp_connections', [])
connections = [c for c in connections if c['name'] != 'factcloud']
connections.append(conn)
table.update_item(
Key={'actor_id': ACTOR_ID},
UpdateExpression='SET enrolled_services.mcp_connections = :conns',
ExpressionAttributeValues={':conns': connections},
)
print(f'Seeded factcloud connection for {ACTOR_ID}')