Compare commits
5 Commits
ed6577ccf9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ca5fee2c0 | ||
|
|
e77417b6cd | ||
|
|
ef5734101e | ||
|
|
8c28797bca | ||
|
|
42dbdcde9e |
@@ -13,7 +13,7 @@ def _load():
|
||||
ssm = boto3.client('ssm', region_name='us-east-1')
|
||||
names = list(_DEFAULTS.keys())
|
||||
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']}
|
||||
except Exception:
|
||||
found = {}
|
||||
|
||||
@@ -4,6 +4,7 @@ agent-claw Runtime 1 — main assistant agent.
|
||||
Entrypoint for AgentCore CodeZip deployment.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
from strands import Agent, tool
|
||||
from strands.models import BedrockModel
|
||||
from bedrock_agentcore.runtime import BedrockAgentCoreApp
|
||||
@@ -90,6 +91,7 @@ except Exception as _e:
|
||||
|
||||
# ── Subagent loading ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
TOOL_PRESETS = {
|
||||
"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],
|
||||
@@ -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"
|
||||
|
||||
|
||||
# ── 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 ────────────────────────────────────────────────────────────
|
||||
|
||||
# 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 += '\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
|
||||
# NOTE: extended thinking disabled — causes retry/duplicate issues with streaming
|
||||
@@ -563,16 +690,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
|
||||
|
||||
@@ -45,12 +45,40 @@ def _get_oauth_token(conn: dict, actor_id: str) -> str:
|
||||
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:
|
||||
"""Resolve auth headers for a connection."""
|
||||
auth_type = conn.get('auth_type', 'none')
|
||||
if auth_type == 'oauth_client_credentials':
|
||||
token = _get_oauth_token(conn, actor_id)
|
||||
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':
|
||||
token = _get_ssm_value(conn['token_ssm'])
|
||||
return {'Authorization': f'Bearer {token}'}
|
||||
|
||||
@@ -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)
|
||||
|
||||
35
scripts/seed_factcloud.py
Normal file
35
scripts/seed_factcloud.py
Normal 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}')
|
||||
Reference in New Issue
Block a user