Compare commits
207 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b08ee17f2 | |||
| 8668d103a8 | |||
| 133f393f8b | |||
| fd3ef36a15 | |||
| aa281aad34 | |||
| a3d0c7e0cb | |||
| de3042ba3f | |||
| 326d7f201c | |||
| db30ef3094 | |||
| e3d1cb6739 | |||
| 846f3f2470 | |||
| 913437ea0b | |||
| 520bd635e2 | |||
| b7d850ddd0 | |||
| 0a251278f1 | |||
| 857af8e6a3 | |||
| 273d4ec66e | |||
| eeb46a2b3e | |||
| b5e05fefae | |||
| bdfbb7698a | |||
| 35b1eadb7f | |||
| 38036eb7bd | |||
| 70d90fda19 | |||
| 1e3dcbbbc2 | |||
| 53b095cdcb | |||
| d04862053f | |||
| df0e0ea082 | |||
| b1724ee360 | |||
| a59493835d | |||
| 334af2b74e | |||
| 81c72949ce | |||
| 97fd45d36a | |||
| caebbea1aa | |||
| 574a3a284e | |||
| 8ea3fb8cfe | |||
| 69d16a8f6c | |||
| f16cb0ea1f | |||
| e0f1e9d494 | |||
| 7fb0da26fc | |||
| f5f72c1c9c | |||
| 06d0a16201 | |||
| 0964758b12 | |||
| c25abdfd84 | |||
| af720bb569 | |||
| b763226a64 | |||
| 9b7580d22b | |||
| c23c274ac7 | |||
| 1335a15341 | |||
| 2a1cbaa582 | |||
| 74cba57cce | |||
| 7616de2417 | |||
| d96875932a | |||
| 238d90871a | |||
| e38e1563ba | |||
| e3d8b89b69 | |||
| ec64c14d37 | |||
| fb5b7ed9de | |||
| da0aa65c31 | |||
| cbf7cc0a37 | |||
| 802f64f4a7 | |||
| 9ad95fde59 | |||
| b812f6a03a | |||
| 0299a87d0c | |||
| 4aa2358211 | |||
| bc8a97079e | |||
| 6eaa609f63 | |||
| 8f0101b273 | |||
| 5ee98ac7cf | |||
| c058029ac0 | |||
| 6a79728d99 | |||
| 200c202465 | |||
| 791da46f59 | |||
| 6377c5b094 | |||
| 8f4e901c3c | |||
| 4be61ebfc7 | |||
| ac46ce7bfb | |||
| 110d7e0075 | |||
| 749185e760 | |||
| 5cb75d1822 | |||
| 3febef106d | |||
| db18186825 | |||
| 87918b5263 | |||
| 01f258c4c4 | |||
| 3d992bbda3 | |||
| df43f36385 | |||
| bdd099bb78 | |||
| acca008772 | |||
| 0bf4d8b9fa | |||
| 7a2752eb42 | |||
| c65b43c21b | |||
| 90f376136e | |||
| d5ea28f8f3 | |||
| 1ccfc7aefa | |||
| 64830a6720 | |||
| 514d2828fa | |||
| 5705647364 | |||
| 8a3e1e68a9 | |||
| 4c900e9ab2 | |||
| fa0518b249 | |||
| 6a5bc0d484 | |||
| d288c865d0 | |||
| 81051a11fc | |||
| c4a8c73b24 | |||
| 2b8ed0eb05 | |||
| 40c530603b | |||
| dee3980dbe | |||
| d19cb2843e | |||
| ea31b037b8 | |||
| 5fe924318d | |||
| 8e6a812ce6 | |||
| 1565fd52e1 | |||
| 53f5f93deb | |||
| 21afac2b59 | |||
| c03f1caa58 | |||
| a5e928ac95 | |||
| 648e3cd52a | |||
| b216df76a0 | |||
| ddee82eaef | |||
| 6e88bb0205 | |||
| 0aa19721c3 | |||
| cf1e26b012 | |||
| 47e02c0821 | |||
| 7e1ebf1c26 | |||
| ecbf543e4c | |||
| 7daca39bb2 | |||
| d8712ceb72 | |||
| 5a90a4ba42 | |||
| e69c381331 | |||
| 8f608048f9 | |||
| df29c49bd0 | |||
| b3759db83b | |||
| 8308207be8 | |||
| 6b86c602c7 | |||
| d9644eaa39 | |||
| 3976ea6934 | |||
| cc00ae8999 | |||
| 70bf337c03 | |||
| 6ecdbf47b0 | |||
| e0e1abbb64 | |||
| cb8c26ee18 | |||
| 3d6beca577 | |||
| bed9670395 | |||
| 61bb0b6594 | |||
| e7506fcd25 | |||
| 7cc92eb8c3 | |||
| 3a70243b82 | |||
| b701605a62 | |||
| a5b17a293b | |||
| 7ad25d986b | |||
| db572b9be6 | |||
| 0ee653a164 | |||
| c92662bdb1 | |||
| 19469ff404 | |||
| 7fcb51985d | |||
| 3c9911c25b | |||
| 3dbd20040a | |||
| c9d62139af | |||
| 172b180477 | |||
| 6637bc8d96 | |||
| 93dc35dcbb | |||
| 30ad3edfbf | |||
| d10912be15 | |||
| b9c5191059 | |||
| d9037172d8 | |||
| df41732e95 | |||
| cd9a625041 | |||
| 420d703138 | |||
| 66866e524d | |||
| 33e6c018a3 | |||
| 1ac50ab532 | |||
| 4df924d3d7 | |||
| 8f2d87cc5d | |||
| 4b795584f6 | |||
| 6024ae4241 | |||
| aaa5d661c3 | |||
| 2e5670ace6 | |||
| 634658e829 | |||
| dc64cc68a1 | |||
| e8d56c815d | |||
| cc21780e99 | |||
| 714de59d2a | |||
| ed8d417bef | |||
| 294df7f066 | |||
| b46fe69712 | |||
| 6e6efb97bd | |||
| dc4be4f906 | |||
| 9cb20986d2 | |||
| aab38222db | |||
| c46082780f | |||
| ff8123acb9 | |||
| 358b4e1bf2 | |||
| 934c424510 | |||
| f12db37d75 | |||
| 2b263f6e10 | |||
| 4513f5dcd7 | |||
| 45cfae5217 | |||
| 4d877469d5 | |||
| 6022f6c911 | |||
| dacda3337f | |||
| 267f797abc | |||
| 42fd1ec8d1 | |||
| 81774d5d0e | |||
| d1cbfd1e54 | |||
| fd71501215 | |||
| 406bfb23b9 | |||
| 1e2e6e03dd | |||
| 8b99bb8590 |
@@ -1,4 +1,58 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(grep -n \"_is_context_too_large_error\" core/framework/agent_loop/agent_loop.py core/framework/agent_loop/internals/*.py)",
|
||||
"Read(//^class/ {cls=$3} /def test_/**)",
|
||||
"Read(//^ @pytest.mark.asyncio/{getline n; print NR\": \"n} /^ def test_/**)",
|
||||
"Bash(python3)",
|
||||
"Bash(grep -nE 'Tool\\\\\\(\\\\s*$|name=\"[a-z_]+\",' core/framework/tools/queen_lifecycle_tools.py)",
|
||||
"Bash(awk -F'\"' '{print $2}')",
|
||||
"Bash(grep -n \"create_colony\\\\|colony-spawn\\\\|colony_spawn\" /home/timothy/aden/hive/core/framework/agents/queen/nodes/__init__.py /home/timothy/aden/hive/core/framework/tools/*.py)",
|
||||
"Bash(git stash:*)",
|
||||
"Bash(python3 -c \"import sys,json; d=json.loads\\(sys.stdin.read\\(\\)\\); print\\('keys:', list\\(d.keys\\(\\)\\)[:10]\\)\")",
|
||||
"Bash(python3 -c ':*)",
|
||||
"Bash(uv run:*)",
|
||||
"Read(//tmp/**)",
|
||||
"Bash(grep -n \"useColony\\\\|const { queens, queenProfiles\" /home/timothy/aden/hive/core/frontend/src/pages/queen-dm.tsx)",
|
||||
"Bash(awk 'NR==385,/\\\\}, \\\\[/' /home/timothy/aden/hive/core/frontend/src/pages/queen-dm.tsx)",
|
||||
"Bash(xargs -I{} sh -c 'if ! grep -q \"^import base64\\\\|^from base64\" \"{}\"; then echo \"MISSING: {}\"; fi')",
|
||||
"Bash(find /home/timothy/aden/hive/core/framework -name \"*.py\" -type f -exec grep -l \"FileConversationStore\\\\|class.*ConversationStore\" {} \\\\;)",
|
||||
"Bash(find /home/timothy/aden/hive/core/framework -name \"*.py\" -exec grep -l \"run_parallel_workers\\\\|create_colony\" {} \\\\;)",
|
||||
"Bash(awk '/^ async def execute\\\\\\(self, ctx: AgentContext\\\\\\)/,/^ async def [a-z_]+/ {print NR\": \"$0}' /home/timothy/aden/hive/core/framework/agent_loop/agent_loop.py)",
|
||||
"Bash(grep -r \"max_concurrent_workers\\\\|max_depth\\\\|recursion\\\\|spawn.*bomb\" /home/timothy/aden/hive/core/framework/host/*.py)",
|
||||
"Bash(wc -l /home/timothy/aden/hive/tools/src/gcu/browser/*.py /home/timothy/aden/hive/tools/src/gcu/browser/tools/*.py)",
|
||||
"Bash(file /tmp/gcu_verify/*.png)",
|
||||
"Bash(ps -eo pid,cmd)",
|
||||
"Bash(ps -o pid,lstart,cmd -p 746640)",
|
||||
"Bash(kill 746636)",
|
||||
"Bash(ps -eo pid,lstart,cmd)",
|
||||
"Bash(grep -E \"^d|\\\\.py$\")",
|
||||
"Bash(grep -E \"\\\\.\\(ts|tsx\\)$\")",
|
||||
"Bash(xargs cat:*)",
|
||||
"Bash(find /home/timothy/aden/hive -path \"*/.venv\" -prune -o -name \"*.py\" -type f -exec grep -l \"frontend\\\\|UI\\\\|terminal\\\\|interactive\\\\|TUI\" {} \\\\;)",
|
||||
"Bash(wc -l /home/timothy/.hive/backup/*/SKILL.md)",
|
||||
"Bash(awk -F'::' '{print $1}')",
|
||||
"Bash(wait)",
|
||||
"Bash(pkill -f \"pytest.*test_event_loop_node\")",
|
||||
"Bash(pkill -f \"pytest.*TestToolConcurrency\")",
|
||||
"Bash(grep -n \"def.*discover\\\\|/api/agents\\\\|agents_discover\" /home/timothy/aden/hive/core/framework/server/*.py)",
|
||||
"Bash(bun run:*)",
|
||||
"Bash(npx eslint:*)",
|
||||
"Bash(npm run:*)",
|
||||
"Bash(npm test:*)",
|
||||
"Bash(grep -E \"\\\\.tsx$|^d\")",
|
||||
"Bash(grep -E \"test_.*\\\\.py$\")",
|
||||
"Bash(grep \"\\\\.py$\")",
|
||||
"Bash(grep -l \"save_agent_draft\\\\|confirm_and_build\\\\|replan_agent\\\\|load_built_agent\\\\|planning\\\\|building\\\\|staging\" /home/timothy/aden/hive/core/framework/agents/queen/reference/*.md)",
|
||||
"Bash(grep -E \"\\\\.tsx$|\\\\.ts$\")",
|
||||
"Bash(find /home/timothy/aden/hive/core/framework/tools -name \"*.py\" -exec grep -l \"switch_to_\" {} \\\\;)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"/home/timothy/.hive/skills/writing-hive-skills",
|
||||
"/tmp",
|
||||
"/home/timothy/.hive/skills"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
|
||||
@@ -9,7 +9,6 @@ Fix: Add wait_for_selector between scroll calls
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "tools" / "src"))
|
||||
@@ -36,7 +35,7 @@ async def test_twitter_lazy_scroll():
|
||||
if bridge.is_connected:
|
||||
print("✓ Extension connected!")
|
||||
break
|
||||
print(f"Waiting for extension... ({i+1}/10)")
|
||||
print(f"Waiting for extension... ({i + 1}/10)")
|
||||
else:
|
||||
print("✗ Extension not connected")
|
||||
return
|
||||
@@ -58,7 +57,8 @@ async def test_twitter_lazy_scroll():
|
||||
# Count initial tweets
|
||||
initial_count = await bridge.evaluate(
|
||||
tab_id,
|
||||
'(function() { return document.querySelectorAll(\'[data-testid="tweet"]\').length; })()'
|
||||
"(function() { return document.querySelectorAll("
|
||||
"'[data-testid=\"tweet\"]').length; })()",
|
||||
)
|
||||
print(f"Initial tweet count: {initial_count.get('result', 0)}")
|
||||
|
||||
@@ -70,7 +70,7 @@ async def test_twitter_lazy_scroll():
|
||||
print("\n--- Scrolling with waits ---")
|
||||
for i in range(3):
|
||||
result = await bridge.scroll(tab_id, "down", 500)
|
||||
print(f" Scroll {i+1}: {result.get('method', 'unknown')} method")
|
||||
print(f" Scroll {i + 1}: {result.get('method', 'unknown')} method")
|
||||
|
||||
# Wait for new content to load
|
||||
await asyncio.sleep(2)
|
||||
@@ -78,20 +78,22 @@ async def test_twitter_lazy_scroll():
|
||||
# Count tweets after scroll
|
||||
count_result = await bridge.evaluate(
|
||||
tab_id,
|
||||
'(function() { return document.querySelectorAll(\'[data-testid="tweet"]\').length; })()'
|
||||
"(function() { return document.querySelectorAll("
|
||||
"'[data-testid=\"tweet\"]').length; })()",
|
||||
)
|
||||
count = count_result.get('result', 0)
|
||||
count = count_result.get("result", 0)
|
||||
print(f" Tweet count after scroll: {count}")
|
||||
|
||||
# Final count
|
||||
final_count = await bridge.evaluate(
|
||||
tab_id,
|
||||
'(function() { return document.querySelectorAll(\'[data-testid="tweet"]\').length; })()'
|
||||
"(function() { return document.querySelectorAll("
|
||||
"'[data-testid=\"tweet\"]').length; })()",
|
||||
)
|
||||
final = final_count.get('result', 0)
|
||||
initial = initial_count.get('result', 0)
|
||||
final = final_count.get("result", 0)
|
||||
initial = initial_count.get("result", 0)
|
||||
|
||||
print(f"\n--- Results ---")
|
||||
print("\n--- Results ---")
|
||||
print(f"Initial tweets: {initial}")
|
||||
print(f"Final tweets: {final}")
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ Fix: Find visible modal container (highest z-index scrollable), scroll that
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "tools" / "src"))
|
||||
@@ -60,7 +59,7 @@ async def test_modal_scroll():
|
||||
# Click button to open modal
|
||||
print("\n--- Opening modal ---")
|
||||
# Find and click the "Open Modal" button
|
||||
result = await bridge.click(tab_id, '.ws-btn', timeout_ms=5000)
|
||||
result = await bridge.click(tab_id, ".ws-btn", timeout_ms=5000)
|
||||
print(f"Click result: {result}")
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
@@ -52,7 +52,9 @@ async def test_overlay_click():
|
||||
<head><title>Overlay Test</title></head>
|
||||
<body>
|
||||
<button id="target-btn" onclick="alert('Clicked!')">Click Me</button>
|
||||
<div id="overlay" style="position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.3);z-index:1000;"></div>
|
||||
<div id="overlay" style="position:fixed;top:0;left:0;
|
||||
width:100%;height:100%;
|
||||
background:rgba(0,0,0,0.3);z-index:1000;"></div>
|
||||
<script>
|
||||
window.clickCount = 0;
|
||||
document.getElementById('target-btn').addEventListener('click', () => {
|
||||
@@ -65,6 +67,7 @@ async def test_overlay_click():
|
||||
|
||||
# Navigate to data URL
|
||||
import base64
|
||||
|
||||
data_url = f"data:text/html;base64,{base64.b64encode(test_html.encode()).decode()}"
|
||||
await bridge.navigate(tab_id, data_url, wait_until="load")
|
||||
|
||||
@@ -91,7 +94,7 @@ async def test_overlay_click():
|
||||
targetElement: btn.tagName
|
||||
};
|
||||
})();
|
||||
"""
|
||||
""",
|
||||
)
|
||||
print(f"Coverage check: {coverage_check.get('result', {})}")
|
||||
|
||||
@@ -100,10 +103,7 @@ async def test_overlay_click():
|
||||
print(f"Click result: {click_result}")
|
||||
|
||||
# Check if click registered
|
||||
count_result = await bridge.evaluate(
|
||||
tab_id,
|
||||
"(function() { return window.clickCount; })()"
|
||||
)
|
||||
count_result = await bridge.evaluate(tab_id, "(function() { return window.clickCount; })()")
|
||||
count = count_result.get("result", 0)
|
||||
print(f"Click count after CDP click: {count}")
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ Fix: Use piercing selector (host >>> target) or traverse shadow roots
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "tools" / "src"))
|
||||
@@ -100,7 +99,7 @@ async def test_shadow_dom():
|
||||
});
|
||||
return { count: hosts.length, hosts };
|
||||
})();
|
||||
"""
|
||||
""",
|
||||
)
|
||||
print(f"Shadow DOM detection: {detection.get('result', {})}")
|
||||
|
||||
@@ -126,14 +125,13 @@ async def test_shadow_dom():
|
||||
}
|
||||
return { success: false, error: 'Button not found' };
|
||||
})();
|
||||
"""
|
||||
""",
|
||||
)
|
||||
print(f"JS click result: {click_result.get('result', {})}")
|
||||
|
||||
# Verify click was registered
|
||||
count_result = await bridge.evaluate(
|
||||
tab_id,
|
||||
"(function() { return window.shadowClickCount || 0; })()"
|
||||
tab_id, "(function() { return window.shadowClickCount || 0; })()"
|
||||
)
|
||||
count = count_result.get("result") or 0
|
||||
print(f"Shadow click count: {count}")
|
||||
|
||||
@@ -10,7 +10,6 @@ Fix: Focus via JavaScript, use execCommand('insertText') or Input.dispatchKeyEve
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "tools" / "src"))
|
||||
@@ -54,10 +53,14 @@ async def test_contenteditable():
|
||||
<h2>ContentEditable Test</h2>
|
||||
|
||||
<h3>1. Simple contenteditable div</h3>
|
||||
<div id="editor1" contenteditable="true" style="border:1px solid #ccc;padding:10px;min-height:50px;">Start text</div>
|
||||
<div id="editor1" contenteditable="true"
|
||||
style="border:1px solid #ccc;padding:10px;
|
||||
min-height:50px;">Start text</div>
|
||||
|
||||
<h3>2. Rich text editor (like Notion)</h3>
|
||||
<div id="editor2" contenteditable="true" style="border:1px solid #ccc;padding:10px;min-height:50px;">
|
||||
<div id="editor2" contenteditable="true"
|
||||
style="border:1px solid #ccc;padding:10px;
|
||||
min-height:50px;">
|
||||
<p>Type here...</p>
|
||||
</div>
|
||||
|
||||
@@ -106,7 +109,7 @@ async def test_contenteditable():
|
||||
ids: Array.from(editables).map(el => el.id)
|
||||
};
|
||||
})();
|
||||
"""
|
||||
""",
|
||||
)
|
||||
print(f"Contenteditable detection: {detection.get('result', {})}")
|
||||
|
||||
@@ -115,8 +118,7 @@ async def test_contenteditable():
|
||||
await bridge.click(tab_id, "#input1")
|
||||
await bridge.type_text(tab_id, "#input1", "Hello input")
|
||||
input_result = await bridge.evaluate(
|
||||
tab_id,
|
||||
"(function() { return document.getElementById('input1').value; })()"
|
||||
tab_id, "(function() { return document.getElementById('input1').value; })()"
|
||||
)
|
||||
print(f"Input value: {input_result.get('result', '')}")
|
||||
|
||||
@@ -126,7 +128,7 @@ async def test_contenteditable():
|
||||
await bridge.type_text(tab_id, "#editor1", "Hello contenteditable", clear_first=True)
|
||||
editor_result = await bridge.evaluate(
|
||||
tab_id,
|
||||
"(function() { return document.getElementById('editor1').innerText; })()"
|
||||
"(function() { return document.getElementById('editor1').innerText; })()",
|
||||
)
|
||||
print(f"Editor1 innerText: {editor_result.get('result', '')}")
|
||||
|
||||
@@ -142,7 +144,7 @@ async def test_contenteditable():
|
||||
document.execCommand('insertText', false, 'Hello from execCommand');
|
||||
return editor.innerText;
|
||||
})();
|
||||
"""
|
||||
""",
|
||||
)
|
||||
print(f"Editor2 after execCommand: {insert_result.get('result', '')}")
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ Fix: Add delay_ms between keystrokes
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "tools" / "src"))
|
||||
@@ -87,7 +86,26 @@ async def test_autocomplete():
|
||||
<div id="log" style="margin-top:20px;font-family:monospace;"></div>
|
||||
|
||||
<script>
|
||||
const countries = ["Afghanistan","Albania","Algeria","Andorra","Angola","Argentina","Armenia","Australia","Austria","Azerbaijan","Bahamas","Bahrain","Bangladesh","Belarus","Belgium","Belize","Benin","Bhutan","Bolivia","Brazil","Canada","China","Colombia","Denmark","Egypt","France","Germany","India","Indonesia","Italy","Japan","Mexico","Netherlands","Nigeria","Norway","Pakistan","Peru","Philippines","Poland","Portugal","Russia","Spain","Sweden","Switzerland","Thailand","Turkey","Ukraine","United Kingdom","United States","Vietnam"];
|
||||
const countries = [
|
||||
"Afghanistan","Albania","Algeria",
|
||||
"Andorra","Angola","Argentina",
|
||||
"Armenia","Australia","Austria",
|
||||
"Azerbaijan","Bahamas","Bahrain",
|
||||
"Bangladesh","Belarus","Belgium",
|
||||
"Belize","Benin","Bhutan",
|
||||
"Bolivia","Brazil","Canada",
|
||||
"China","Colombia","Denmark",
|
||||
"Egypt","France","Germany",
|
||||
"India","Indonesia","Italy",
|
||||
"Japan","Mexico","Netherlands",
|
||||
"Nigeria","Norway","Pakistan",
|
||||
"Peru","Philippines","Poland",
|
||||
"Portugal","Russia","Spain",
|
||||
"Sweden","Switzerland","Thailand",
|
||||
"Turkey","Ukraine",
|
||||
"United Kingdom","United States",
|
||||
"Vietnam"
|
||||
];
|
||||
|
||||
const input = document.getElementById('search');
|
||||
const log = document.getElementById('log');
|
||||
@@ -126,11 +144,15 @@ async def test_autocomplete():
|
||||
div.setAttribute('class', 'autocomplete-items');
|
||||
this.parentNode.appendChild(div);
|
||||
|
||||
countries.filter(c => c.substr(0, val.length).toUpperCase() === val.toUpperCase())
|
||||
.slice(0, 5)
|
||||
.forEach(country => {
|
||||
countries.filter(
|
||||
c => c.substr(0, val.length).toUpperCase()
|
||||
=== val.toUpperCase()
|
||||
).slice(0, 5).forEach(country => {
|
||||
const item = document.createElement('div');
|
||||
item.innerHTML = '<strong>' + country.substr(0, val.length) + '</strong>' + country.substr(val.length);
|
||||
item.innerHTML = '<strong>'
|
||||
+ country.substr(0, val.length)
|
||||
+ '</strong>'
|
||||
+ country.substr(val.length);
|
||||
item.addEventListener('click', function() {
|
||||
input.value = country;
|
||||
closeAllLists();
|
||||
@@ -172,16 +194,14 @@ async def test_autocomplete():
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
fast_result = await bridge.evaluate(
|
||||
tab_id,
|
||||
"(function() { return document.getElementById('search').value; })()"
|
||||
tab_id, "(function() { return document.getElementById('search').value; })()"
|
||||
)
|
||||
fast_value = fast_result.get("result", "")
|
||||
print(f"Value after fast typing: '{fast_value}'")
|
||||
|
||||
# Check events
|
||||
events_result = await bridge.evaluate(
|
||||
tab_id,
|
||||
"(function() { return window.inputEvents; })()"
|
||||
tab_id, "(function() { return window.inputEvents; })()"
|
||||
)
|
||||
print(f"Events logged: {events_result.get('result', [])}")
|
||||
|
||||
@@ -192,8 +212,7 @@ async def test_autocomplete():
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
slow_result = await bridge.evaluate(
|
||||
tab_id,
|
||||
"(function() { return document.getElementById('search').value; })()"
|
||||
tab_id, "(function() { return document.getElementById('search').value; })()"
|
||||
)
|
||||
slow_value = slow_result.get("result", "")
|
||||
print(f"Value after slow typing: '{slow_value}'")
|
||||
@@ -201,7 +220,8 @@ async def test_autocomplete():
|
||||
# Check if dropdown appeared
|
||||
dropdown_result = await bridge.evaluate(
|
||||
tab_id,
|
||||
"(function() { return document.querySelectorAll('.autocomplete-items div').length; })()"
|
||||
"(function() { return document.querySelectorAll("
|
||||
"'.autocomplete-items div').length; })()",
|
||||
)
|
||||
dropdown_count = dropdown_result.get("result", 0)
|
||||
print(f"Dropdown items: {dropdown_count}")
|
||||
|
||||
@@ -88,8 +88,7 @@ async def test_huge_dom():
|
||||
|
||||
# Count elements
|
||||
count_result = await bridge.evaluate(
|
||||
tab_id,
|
||||
"(function() { return document.querySelectorAll('*').length; })()"
|
||||
tab_id, "(function() { return document.querySelectorAll('*').length; })()"
|
||||
)
|
||||
elem_count = count_result.get("result", 0)
|
||||
print(f"DOM elements: {elem_count}")
|
||||
@@ -123,12 +122,13 @@ async def test_huge_dom():
|
||||
|
||||
# Test 3: Real LinkedIn
|
||||
print("\n--- Test 3: Real LinkedIn Feed ---")
|
||||
await bridge.navigate(tab_id, "https://www.linkedin.com/feed", wait_until="load", timeout_ms=30000)
|
||||
await bridge.navigate(
|
||||
tab_id, "https://www.linkedin.com/feed", wait_until="load", timeout_ms=30000
|
||||
)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
count_result = await bridge.evaluate(
|
||||
tab_id,
|
||||
"(function() { return document.querySelectorAll('*').length; })()"
|
||||
tab_id, "(function() { return document.querySelectorAll('*').length; })()"
|
||||
)
|
||||
elem_count = count_result.get("result", 0)
|
||||
print(f"LinkedIn DOM elements: {elem_count}")
|
||||
|
||||
@@ -11,7 +11,6 @@ Fix: Use wait_until="networkidle" or wait_for_selector
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "tools" / "src"))
|
||||
@@ -82,9 +81,13 @@ async def test_spa_navigation():
|
||||
|
||||
// Render content
|
||||
const content = {
|
||||
home: '<h1>Home Page</h1><p>Welcome to the SPA!</p><button id="home-btn">Home Action</button>',
|
||||
about: '<h1>About Page</h1><p>This is a simulated SPA.</p><button id="about-btn">About Action</button>',
|
||||
contact: '<h1>Contact Page</h1><p>Contact us at test@example.com</p><button id="contact-btn">Contact Action</button>'
|
||||
home: '<h1>Home Page</h1><p>Welcome!</p>'
|
||||
+ '<button id="home-btn">Home Action</button>',
|
||||
about: '<h1>About Page</h1><p>Simulated SPA.</p>'
|
||||
+ '<button id="about-btn">About Action</button>',
|
||||
contact: '<h1>Contact Page</h1>'
|
||||
+ '<p>Contact us at test@example.com</p>'
|
||||
+ '<button id="contact-btn">Contact Action</button>'
|
||||
};
|
||||
|
||||
document.getElementById('app').innerHTML = content[page] || '<h1>404</h1>';
|
||||
@@ -121,7 +124,7 @@ async def test_spa_navigation():
|
||||
# Check content immediately
|
||||
content = await bridge.evaluate(
|
||||
tab_id,
|
||||
"(function() { return document.getElementById('app').innerText; })()"
|
||||
"(function() { return document.getElementById('app').innerText; })()",
|
||||
)
|
||||
print(f"Content immediately after load: '{content.get('result', '')}'")
|
||||
|
||||
@@ -137,7 +140,7 @@ async def test_spa_navigation():
|
||||
# Check content after wait
|
||||
content_after = await bridge.evaluate(
|
||||
tab_id,
|
||||
"(function() { return document.getElementById('app').innerText; })()"
|
||||
"(function() { return document.getElementById('app').innerText; })()",
|
||||
)
|
||||
print(f"Content after wait: '{content_after.get('result', '')}'")
|
||||
|
||||
@@ -151,7 +154,7 @@ async def test_spa_navigation():
|
||||
# Check if content changed
|
||||
about_content = await bridge.evaluate(
|
||||
tab_id,
|
||||
"(function() { return document.getElementById('app').innerText; })()"
|
||||
"(function() { return document.getElementById('app').innerText; })()",
|
||||
)
|
||||
print(f"Content after SPA nav: '{about_content.get('result', '')}'")
|
||||
|
||||
@@ -167,7 +170,7 @@ async def test_spa_navigation():
|
||||
# Check content immediately
|
||||
content_networkidle = await bridge.evaluate(
|
||||
tab_id,
|
||||
"(function() { return document.getElementById('app').innerText; })()"
|
||||
"(function() { return document.getElementById('app').innerText; })()",
|
||||
)
|
||||
print(f"Content after networkidle: '{content_networkidle.get('result', '')}'")
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ def check_png(data: str) -> bool:
|
||||
"""Verify that base64 data decodes to a valid PNG."""
|
||||
try:
|
||||
raw = base64.b64decode(data)
|
||||
return raw[:8] == b'\x89PNG\r\n\x1a\n'
|
||||
return raw[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -136,7 +136,10 @@ async def test_selector_screenshot(bridge: BeelineBridge, tab_id: int, data_url:
|
||||
print(" ⚠ WARNING: Selector screenshot not smaller (may be full page)")
|
||||
return False
|
||||
else:
|
||||
print(f" ⚠ NOT IMPLEMENTED: selector param ignored (returns full page) - error={result.get('error')}")
|
||||
print(
|
||||
" ⚠ NOT IMPLEMENTED: selector param ignored"
|
||||
f" (returns full page) - error={result.get('error')}"
|
||||
)
|
||||
print(" NOTE: selector parameter exists in signature but is not used in implementation")
|
||||
return False
|
||||
|
||||
@@ -178,7 +181,9 @@ async def test_screenshot_timeout(bridge: BeelineBridge, tab_id: int, data_url:
|
||||
print(f" ⚠ Fast enough to beat timeout: {err!r} in {elapsed:.3f}s")
|
||||
return True # Not a failure, just fast
|
||||
else:
|
||||
print(f" ⚠ Screenshot completed before timeout ({elapsed:.3f}s) - too fast to test timeout")
|
||||
print(
|
||||
f" ⚠ Screenshot completed before timeout ({elapsed:.3f}s) - too fast to test timeout"
|
||||
)
|
||||
return True # Still ok, just very fast
|
||||
|
||||
|
||||
@@ -218,7 +223,7 @@ async def main():
|
||||
if bridge.is_connected:
|
||||
print("✓ Extension connected!")
|
||||
break
|
||||
print(f"Waiting for extension... ({i+1}/10)")
|
||||
print(f"Waiting for extension... ({i + 1}/10)")
|
||||
else:
|
||||
print("✗ Extension not connected. Ensure Chrome with Beeline extension is running.")
|
||||
return
|
||||
|
||||
@@ -97,7 +97,7 @@ async def test_problematic_site(bridge: BeelineBridge, tab_id: int) -> dict:
|
||||
});
|
||||
return results;
|
||||
})();
|
||||
"""
|
||||
""",
|
||||
)
|
||||
print(f" Before scroll: {before.get('result', {})}")
|
||||
|
||||
@@ -126,7 +126,7 @@ async def test_problematic_site(bridge: BeelineBridge, tab_id: int) -> dict:
|
||||
});
|
||||
return results;
|
||||
})();
|
||||
"""
|
||||
""",
|
||||
)
|
||||
print(f" After scroll: {after.get('result', {})}")
|
||||
|
||||
@@ -137,8 +137,14 @@ async def test_problematic_site(bridge: BeelineBridge, tab_id: int) -> dict:
|
||||
changed = False
|
||||
for key in after_data:
|
||||
if key in before_data:
|
||||
b_val = before_data[key].get("scrollTop", 0) if isinstance(before_data[key], dict) else 0
|
||||
a_val = after_data[key].get("scrollTop", 0) if isinstance(after_data[key], dict) else 0
|
||||
b_val = (
|
||||
before_data[key].get("scrollTop", 0)
|
||||
if isinstance(before_data[key], dict)
|
||||
else 0
|
||||
)
|
||||
a_val = (
|
||||
after_data[key].get("scrollTop", 0) if isinstance(after_data[key], dict) else 0
|
||||
)
|
||||
if a_val != b_val:
|
||||
print(f" ✓ CHANGE DETECTED: {key} scrolled from {b_val} to {a_val}")
|
||||
changed = True
|
||||
@@ -194,7 +200,7 @@ async def detect_root_cause(bridge: BeelineBridge, tab_id: int) -> dict:
|
||||
largest: candidates[0]
|
||||
};
|
||||
})();
|
||||
"""
|
||||
""",
|
||||
)
|
||||
detections["nested_scroll"] = scroll_check.get("result", {})
|
||||
print(f" Nested scroll containers: {detections['nested_scroll']}")
|
||||
@@ -212,7 +218,7 @@ async def detect_root_cause(bridge: BeelineBridge, tab_id: int) -> dict:
|
||||
});
|
||||
return { count: withShadow.length, elements: withShadow.slice(0, 5) };
|
||||
})();
|
||||
"""
|
||||
""",
|
||||
)
|
||||
detections["shadow_dom"] = shadow_check.get("result", {})
|
||||
print(f" Shadow DOM: {detections['shadow_dom']}")
|
||||
@@ -225,7 +231,7 @@ async def detect_root_cause(bridge: BeelineBridge, tab_id: int) -> dict:
|
||||
const iframes = document.querySelectorAll('iframe');
|
||||
return { count: iframes.length };
|
||||
})();
|
||||
"""
|
||||
""",
|
||||
)
|
||||
detections["iframes"] = iframe_check.get("result", {})
|
||||
print(f" iframes: {detections['iframes']}")
|
||||
@@ -240,7 +246,7 @@ async def detect_root_cause(bridge: BeelineBridge, tab_id: int) -> dict:
|
||||
body_children: document.body.children.length
|
||||
};
|
||||
})();
|
||||
"""
|
||||
""",
|
||||
)
|
||||
detections["dom_size"] = dom_check.get("result", {})
|
||||
print(f" DOM size: {detections['dom_size']}")
|
||||
@@ -256,7 +262,7 @@ async def detect_root_cause(bridge: BeelineBridge, tab_id: int) -> dict:
|
||||
angular: !!document.querySelector('[ng-app], [ng-version]')
|
||||
};
|
||||
})();
|
||||
"""
|
||||
""",
|
||||
)
|
||||
detections["frameworks"] = framework_check.get("result", {})
|
||||
print(f" Frameworks: {detections['frameworks']}")
|
||||
@@ -289,7 +295,7 @@ async def main():
|
||||
if bridge.is_connected:
|
||||
print("✓ Extension connected!")
|
||||
break
|
||||
print(f"Waiting for extension... ({i+1}/10)")
|
||||
print(f"Waiting for extension... ({i + 1}/10)")
|
||||
else:
|
||||
print("✗ Extension not connected. Ensure Chrome with Beeline extension is running.")
|
||||
return
|
||||
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
working-directory: core
|
||||
run: |
|
||||
uv sync
|
||||
uv run pytest tests/ -v
|
||||
uv run pytest tests/ -v --ignore=tests/dummy_agents
|
||||
|
||||
test-tools:
|
||||
name: Test Tools (${{ matrix.os }})
|
||||
|
||||
@@ -70,6 +70,8 @@ tmp/
|
||||
temp/
|
||||
|
||||
exports/*
|
||||
exports.old*
|
||||
artifacts/*
|
||||
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -79,3 +81,4 @@ core/tests/*dumps/*
|
||||
screenshots/*
|
||||
|
||||
.gemini/*
|
||||
.coverage
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{"type": "connection", "event": "connect", "ts": "2026-04-04T01:10:38.245667+00:00", "profile": "default"}
|
||||
{"type": "connection", "event": "hello", "details": {"version": "1.0"}, "ts": "2026-04-04T01:10:38.247207+00:00", "profile": "default"}
|
||||
{"type": "connection", "event": "disconnect", "ts": "2026-04-04T01:11:57.148273+00:00", "profile": "default"}
|
||||
{"type": "connection", "event": "connect", "ts": "2026-04-04T01:12:09.162378+00:00", "profile": "default"}
|
||||
{"type": "connection", "event": "hello", "details": {"version": "1.0"}, "ts": "2026-04-04T01:12:09.163899+00:00", "profile": "default"}
|
||||
{"type": "connection", "event": "disconnect", "ts": "2026-04-04T01:15:12.826042+00:00", "profile": "default"}
|
||||
{"type": "connection", "event": "connect", "ts": "2026-04-04T01:15:30.842533+00:00", "profile": "default"}
|
||||
{"type": "connection", "event": "hello", "details": {"version": "1.0"}, "ts": "2026-04-04T01:15:30.845025+00:00", "profile": "default"}
|
||||
{"type": "tool_call", "tool": "browser_stop", "params": {"profile": "gcu-browser-worker:3"}, "result": {"ok": true, "status": "not_running", "profile": "gcu-browser-worker:3"}, "ok": true, "duration_ms": 0.01, "ts": "2026-04-04T01:29:04.294954+00:00", "profile": "default"}
|
||||
+19
-3
@@ -333,6 +333,22 @@ make test-live # Run live API integration tests (requires credentials)
|
||||
- **WebSocket** for real-time updates
|
||||
- **Tailwind CSS** for styling
|
||||
|
||||
### Frontend Dev Workflow
|
||||
|
||||
> **Note:** `./quickstart.sh` handles the full setup including the web UI.
|
||||
> The commands below are for contributors iterating on the frontend code after
|
||||
> initial setup is complete.
|
||||
|
||||
```bash
|
||||
# Start the backend server
|
||||
hive serve
|
||||
|
||||
# In a separate terminal, run the frontend dev server with hot-reload
|
||||
cd core/frontend
|
||||
npm install # only needed after dependency changes
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Useful Development Commands
|
||||
|
||||
```bash
|
||||
@@ -943,7 +959,7 @@ uv run pytest -m "not live"
|
||||
**Unit Test**
|
||||
```python
|
||||
import pytest
|
||||
from framework.graph.node import Node
|
||||
from framework.orchestrator import NodeSpec as Node
|
||||
|
||||
def test_node_creation():
|
||||
node = Node(id="test", name="Test Node", node_type="event_loop")
|
||||
@@ -961,8 +977,8 @@ async def test_node_execution():
|
||||
**Integration Test**
|
||||
```python
|
||||
import pytest
|
||||
from framework.graph.executor import GraphExecutor
|
||||
from framework.graph.node import Node
|
||||
from framework.orchestrator.orchestrator import Orchestrator as GraphExecutor
|
||||
from framework.orchestrator import NodeSpec as Node
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_graph_execution_with_multiple_nodes():
|
||||
|
||||
@@ -28,7 +28,7 @@ check: ## Run all checks without modifying files (CI-safe)
|
||||
cd tools && uv run ruff format --check .
|
||||
|
||||
test: ## Run all tests (core + tools, excludes live)
|
||||
cd core && uv run python -m pytest tests/ -v
|
||||
cd core && uv run python -m pytest tests/ -v --ignore=tests/dummy_agents
|
||||
cd tools && uv run python -m pytest -v
|
||||
|
||||
test-tools: ## Run tool tests only (mocked, no credentials needed)
|
||||
@@ -38,7 +38,7 @@ test-live: ## Run live integration tests (requires real API credentials)
|
||||
cd tools && uv run python -m pytest -m live -s -o "addopts=" --log-cli-level=INFO
|
||||
|
||||
test-all: ## Run everything including live tests
|
||||
cd core && uv run python -m pytest tests/ -v
|
||||
cd core && uv run python -m pytest tests/ -v --ignore=tests/dummy_agents
|
||||
cd tools && uv run python -m pytest -v
|
||||
cd tools && uv run python -m pytest -m live -s -o "addopts=" --log-cli-level=INFO
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img width="100%" alt="Hive Banner" src="https://github.com/user-attachments/assets/a027429b-5d3c-4d34-88e4-0feaeaabbab3" />
|
||||
<img width="100%" alt="Hive Banner" src="https://asset.acho.io/github/img/banner.gif" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -40,7 +40,16 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Hive is a runtime harness for AI agents in production. You describe your goal in natural language; a coding agent (the queen) generates the agent graph and connection code to achieve it. During execution, the harness manages state isolation, checkpoint-based crash recovery, cost enforcement, and real-time observability. When agents fail, the framework captures failure data, evolves the graph through the coding agent, and redeploys automatically. Built-in human-in-the-loop nodes, browser control, credential management, and parallel execution give you production reliability without sacrificing adaptability.
|
||||
OpenHive is a zero-setup, model-agnostic execution harness that dynamically generates multi-agent topologies to tackle complex, long-running business workflows without requiring any orchestration boilerplate. By simply defining your objective, the runtime compiles a strict, graph-based execution DAG that safely coordinates specialized agents to execute concurrent tasks in parallel. Backed by persistent, role-based memory that intelligently evolves with your project's context, OpenHive ensures deterministic fault tolerance, deep state observability, and seamless asynchronous execution across whichever underlying LLMs you choose to plug in.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Multi-Agent Coordination for parallel task execution
|
||||
- ✅ Graph-based execution for recurring and complex processes
|
||||
- ✅ Role-based memory that evolves with your projects
|
||||
- ✅ Zero Setup - No technical configuration required
|
||||
- ✅ General Compute Use and Browser Use with Native Extension
|
||||
- ✅ Custom Model Support
|
||||
|
||||
Visit [adenhq.com](https://adenhq.com) for complete documentation, examples, and guides.
|
||||
|
||||
@@ -51,7 +60,7 @@ https://github.com/user-attachments/assets/bf10edc3-06ba-48b6-98ba-d069b15fb69d
|
||||
|
||||
## Who Is Hive For?
|
||||
|
||||
Hive is the harness layer for teams moving AI agents from prototype to production. Models are getting better on their own — the bottleneck is the infrastructure around them: state management, failure recovery, cost control, and observability.
|
||||
Hive is the multi-agent harness layer for teams moving AI agents from prototype to production. Single agents like Openclaw and Cowork can finish personal jobs pretty well but lack the rigor to fulfil business processes.
|
||||
|
||||
Hive is a good fit if you:
|
||||
|
||||
@@ -139,17 +148,6 @@ Now you can run an agent by selecting the agent (either an existing agent or exa
|
||||
|
||||
<img width="2549" height="1174" alt="Screenshot 2026-03-12 at 9 27 36 PM" src="https://github.com/user-attachments/assets/7c7d30fa-9ceb-4c23-95af-b1caa405547d" />
|
||||
|
||||
## Features
|
||||
|
||||
- **Browser-Use** - Control the browser on your computer to achieve hard tasks
|
||||
- **Parallel Execution** - Execute the generated graph in parallel. This way you can have multiple agents completing the jobs for you
|
||||
- **[Goal-Driven Generation](docs/key_concepts/goals_outcome.md)** - Define objectives in natural language; the coding agent generates the agent graph and connection code to achieve them
|
||||
- **[Adaptiveness](docs/key_concepts/evolution.md)** - Framework captures failures, calibrates according to the objectives, and evolves the agent graph
|
||||
- **[Dynamic Node Connections](docs/key_concepts/graph.md)** - No predefined edges; connection code is generated by any capable LLM based on your goals
|
||||
- **SDK-Wrapped Nodes** - Every node gets a shared data buffer, local RLM memory, monitoring, tools, and LLM access out of the box
|
||||
- **[Human-in-the-Loop](docs/key_concepts/graph.md#human-in-the-loop)** - Intervention nodes that pause execution for human input with configurable timeouts and escalation
|
||||
- **Real-time Observability** - WebSocket streaming for live monitoring of agent execution, decisions, and node-to-node communication
|
||||
|
||||
## Integration
|
||||
|
||||
<a href="https://github.com/aden-hive/hive/tree/main/tools/src/aden_tools/tools"><img width="100%" alt="Integration" src="https://github.com/user-attachments/assets/a1573f93-cf02-4bb8-b3d5-b305b05b1e51" /></a>
|
||||
@@ -194,18 +192,6 @@ flowchart LR
|
||||
style V6 fill:#fff,stroke:#ed8c00,stroke-width:1px,color:#cc5d00
|
||||
```
|
||||
|
||||
### The Hive Advantage
|
||||
|
||||
| Typical Agent Frameworks | Hive |
|
||||
| -------------------------- | -------------------------------------- |
|
||||
| Focus on model orchestration | **Production harness**: state, recovery, observability |
|
||||
| Hardcode agent workflows | Describe goals in natural language |
|
||||
| Manual graph definition | Auto-generated agent graphs |
|
||||
| Reactive error handling | Outcome-evaluation and adaptiveness |
|
||||
| Static tool configurations | Dynamic SDK-wrapped nodes |
|
||||
| Separate monitoring setup | Built-in real-time observability |
|
||||
| DIY budget management | Integrated cost controls & degradation |
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **[Define Your Goal](docs/key_concepts/goals_outcome.md)** → Describe what you want to achieve in plain English
|
||||
@@ -221,131 +207,6 @@ flowchart LR
|
||||
- [Configuration Guide](docs/configuration.md) - All configuration options
|
||||
- [Architecture Overview](docs/architecture/README.md) - System design and structure
|
||||
|
||||
## Roadmap
|
||||
|
||||
Aden Hive Agent Framework aims to help developers build outcome-oriented, self-adaptive agents. See [roadmap.md](docs/roadmap.md) for details.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
%% Main Entity
|
||||
User([User])
|
||||
|
||||
%% =========================================
|
||||
%% EXTERNAL EVENT SOURCES
|
||||
%% =========================================
|
||||
subgraph ExtEventSource [External Event Source]
|
||||
E_Sch["Schedulers"]
|
||||
E_WH["Webhook"]
|
||||
E_SSE["SSE"]
|
||||
end
|
||||
|
||||
%% =========================================
|
||||
%% SYSTEM NODES
|
||||
%% =========================================
|
||||
subgraph WorkerBees [Worker Bees]
|
||||
WB_C["Conversation"]
|
||||
WB_SP["System prompt"]
|
||||
|
||||
subgraph Graph [Graph]
|
||||
direction TB
|
||||
N1["Node"] --> N2["Node"] --> N3["Node"]
|
||||
N1 -.-> AN["Active Node"]
|
||||
N2 -.-> AN
|
||||
N3 -.-> AN
|
||||
|
||||
%% Nested Event Loop Node
|
||||
subgraph EventLoopNode [Event Loop Node]
|
||||
ELN_L["listener"]
|
||||
ELN_SP["System Prompt<br/>(Task)"]
|
||||
ELN_EL["Event loop"]
|
||||
ELN_C["Conversation"]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
subgraph JudgeNode [Judge]
|
||||
J_C["Criteria"]
|
||||
J_P["Principles"]
|
||||
J_EL["Event loop"] <--> J_S["Scheduler"]
|
||||
end
|
||||
|
||||
subgraph QueenBee [Queen Bee]
|
||||
QB_SP["System prompt"]
|
||||
QB_EL["Event loop"]
|
||||
QB_C["Conversation"]
|
||||
end
|
||||
|
||||
subgraph Infra [Infra]
|
||||
SA["Sub Agent"]
|
||||
TR["Tool Registry"]
|
||||
WTM["Write through Conversation Memory<br/>(Logs/RAM/Harddrive)"]
|
||||
SM["Shared Memory<br/>(State/Harddrive)"]
|
||||
EB["Event Bus<br/>(RAM)"]
|
||||
CS["Credential Store<br/>(Harddrive/Cloud)"]
|
||||
end
|
||||
|
||||
subgraph PC [PC]
|
||||
B["Browser"]
|
||||
CB["Codebase<br/>v 0.0.x ... v n.n.n"]
|
||||
end
|
||||
|
||||
%% =========================================
|
||||
%% CONNECTIONS & DATA FLOW
|
||||
%% =========================================
|
||||
|
||||
%% External Event Routing
|
||||
E_Sch --> ELN_L
|
||||
E_WH --> ELN_L
|
||||
E_SSE --> ELN_L
|
||||
ELN_L -->|"triggers"| ELN_EL
|
||||
|
||||
%% User Interactions
|
||||
User -->|"Talk"| WB_C
|
||||
User -->|"Talk"| QB_C
|
||||
User -->|"Read/Write Access"| CS
|
||||
|
||||
%% Inter-System Logic
|
||||
ELN_C <-->|"Mirror"| WB_C
|
||||
WB_C -->|"Focus"| AN
|
||||
|
||||
WorkerBees -->|"Inquire"| JudgeNode
|
||||
JudgeNode -->|"Approve"| WorkerBees
|
||||
|
||||
%% Judge Alignments
|
||||
J_C <-.->|"aligns"| WB_SP
|
||||
J_P <-.->|"aligns"| QB_SP
|
||||
|
||||
%% Escalate path
|
||||
J_EL -->|"Report (Escalate)"| QB_EL
|
||||
|
||||
%% Pub/Sub Logic
|
||||
AN -->|"publish"| EB
|
||||
EB -->|"subscribe"| QB_C
|
||||
|
||||
%% Infra and Process Spawning
|
||||
ELN_EL -->|"Spawn"| SA
|
||||
SA -->|"Inform"| ELN_EL
|
||||
SA -->|"Starts"| B
|
||||
B -->|"Report"| ELN_EL
|
||||
TR -->|"Assigned"| ELN_EL
|
||||
CB -->|"Modify Worker Bee"| WB_C
|
||||
|
||||
%% =========================================
|
||||
%% SHARED MEMORY & LOGS ACCESS
|
||||
%% =========================================
|
||||
|
||||
%% Worker Bees Access (link to node inside Graph subgraph)
|
||||
AN <-->|"Read/Write"| WTM
|
||||
AN <-->|"Read/Write"| SM
|
||||
|
||||
%% Queen Bee Access
|
||||
QB_C <-->|"Read/Write"| WTM
|
||||
QB_EL <-->|"Read/Write"| SM
|
||||
|
||||
%% Credentials Access
|
||||
CS -->|"Read Access"| QB_C
|
||||
```
|
||||
|
||||
## Contributing
|
||||
We welcome contributions from the community! We’re especially looking for help building tools, integrations, and example agents for the framework ([check #2805](https://github.com/aden-hive/hive/issues/2805)). If you’re interested in extending its functionality, this is the perfect place to start. Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
"""
|
||||
Minimal Manual Agent Example
|
||||
----------------------------
|
||||
This example demonstrates how to build and run an agent programmatically
|
||||
without using the Claude Code CLI or external LLM APIs.
|
||||
|
||||
It uses custom NodeProtocol implementations to define logic in pure Python,
|
||||
making it perfect for understanding the core runtime loop:
|
||||
Setup -> Graph definition -> Execution -> Result
|
||||
|
||||
Run with:
|
||||
uv run python core/examples/manual_agent.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from framework.graph import EdgeCondition, EdgeSpec, Goal, GraphSpec, NodeSpec
|
||||
from framework.graph.executor import GraphExecutor
|
||||
from framework.graph.node import NodeContext, NodeProtocol, NodeResult
|
||||
from framework.runtime.core import Runtime
|
||||
|
||||
|
||||
# 1. Define Node Logic (Custom NodeProtocol implementations)
|
||||
class GreeterNode(NodeProtocol):
|
||||
"""Generate a simple greeting."""
|
||||
|
||||
async def execute(self, ctx: NodeContext) -> NodeResult:
|
||||
name = ctx.input_data.get("name", "World")
|
||||
greeting = f"Hello, {name}!"
|
||||
ctx.buffer.write("greeting", greeting)
|
||||
return NodeResult(success=True, output={"greeting": greeting})
|
||||
|
||||
|
||||
class UppercaserNode(NodeProtocol):
|
||||
"""Convert text to uppercase."""
|
||||
|
||||
async def execute(self, ctx: NodeContext) -> NodeResult:
|
||||
greeting = ctx.input_data.get("greeting") or ctx.buffer.read("greeting") or ""
|
||||
result = greeting.upper()
|
||||
ctx.buffer.write("final_greeting", result)
|
||||
return NodeResult(success=True, output={"final_greeting": result})
|
||||
|
||||
|
||||
async def main():
|
||||
print("Setting up Manual Agent...")
|
||||
|
||||
# 2. Define the Goal
|
||||
# Every agent needs a goal with success criteria
|
||||
goal = Goal(
|
||||
id="greet-user",
|
||||
name="Greet User",
|
||||
description="Generate a friendly uppercase greeting",
|
||||
success_criteria=[
|
||||
{
|
||||
"id": "greeting_generated",
|
||||
"description": "Greeting produced",
|
||||
"metric": "custom",
|
||||
"target": "any",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
# 3. Define Nodes
|
||||
# Nodes describe steps in the process
|
||||
node1 = NodeSpec(
|
||||
id="greeter",
|
||||
name="Greeter",
|
||||
description="Generates a simple greeting",
|
||||
node_type="event_loop",
|
||||
input_keys=["name"],
|
||||
output_keys=["greeting"],
|
||||
)
|
||||
|
||||
node2 = NodeSpec(
|
||||
id="uppercaser",
|
||||
name="Uppercaser",
|
||||
description="Converts greeting to uppercase",
|
||||
node_type="event_loop",
|
||||
input_keys=["greeting"],
|
||||
output_keys=["final_greeting"],
|
||||
)
|
||||
|
||||
# 4. Define Edges
|
||||
# Edges define the flow between nodes
|
||||
edge1 = EdgeSpec(
|
||||
id="greet-to-upper",
|
||||
source="greeter",
|
||||
target="uppercaser",
|
||||
condition=EdgeCondition.ON_SUCCESS,
|
||||
)
|
||||
|
||||
# 5. Create Graph
|
||||
# The graph works like a blueprint connecting nodes and edges
|
||||
graph = GraphSpec(
|
||||
id="greeting-agent",
|
||||
goal_id="greet-user",
|
||||
entry_node="greeter",
|
||||
terminal_nodes=["uppercaser"],
|
||||
nodes=[node1, node2],
|
||||
edges=[edge1],
|
||||
)
|
||||
|
||||
# 6. Initialize Runtime & Executor
|
||||
# Runtime handles state/memory; Executor runs the graph
|
||||
from pathlib import Path
|
||||
|
||||
runtime = Runtime(storage_path=Path("./agent_logs"))
|
||||
executor = GraphExecutor(runtime=runtime)
|
||||
|
||||
# 7. Register Node Implementations
|
||||
# Connect node IDs in the graph to actual Python implementations
|
||||
executor.register_node("greeter", GreeterNode())
|
||||
executor.register_node("uppercaser", UppercaserNode())
|
||||
|
||||
# 8. Execute Agent
|
||||
print("Executing agent with input: name='Alice'...")
|
||||
|
||||
result = await executor.execute(graph=graph, goal=goal, input_data={"name": "Alice"})
|
||||
|
||||
# 9. Verify Results
|
||||
if result.success:
|
||||
print("\nSuccess!")
|
||||
print(f"Path taken: {' -> '.join(result.path)}")
|
||||
print(f"Final output: {result.output.get('final_greeting')}")
|
||||
else:
|
||||
print(f"\nFailed: {result.error}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Optional: Enable logging to see internal decision flow
|
||||
# logging.basicConfig(level=logging.INFO)
|
||||
asyncio.run(main())
|
||||
@@ -1,119 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Example: Integrating MCP Servers with the Core Framework
|
||||
|
||||
This example demonstrates how to:
|
||||
1. Register MCP servers programmatically
|
||||
2. Use MCP tools in agents
|
||||
3. Load MCP servers from configuration files
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from framework.runner.runner import AgentRunner
|
||||
|
||||
|
||||
async def example_1_programmatic_registration():
|
||||
"""Example 1: Register MCP server programmatically"""
|
||||
print("\n=== Example 1: Programmatic MCP Server Registration ===\n")
|
||||
|
||||
# Load an existing agent
|
||||
runner = AgentRunner.load("exports/task-planner")
|
||||
|
||||
# Register tools MCP server via STDIO
|
||||
num_tools = runner.register_mcp_server(
|
||||
name="tools",
|
||||
transport="stdio",
|
||||
command="python",
|
||||
args=["-m", "aden_tools.mcp_server", "--stdio"],
|
||||
cwd="../tools",
|
||||
)
|
||||
|
||||
print(f"Registered {num_tools} tools from tools MCP server")
|
||||
|
||||
# List all available tools
|
||||
tools = runner._tool_registry.get_tools()
|
||||
print(f"\nAvailable tools: {list(tools.keys())}")
|
||||
|
||||
# Run the agent with MCP tools available
|
||||
result = await runner.run(
|
||||
{"objective": "Search for 'Claude AI' and summarize the top 3 results"}
|
||||
)
|
||||
|
||||
print(f"\nAgent result: {result}")
|
||||
|
||||
# Cleanup
|
||||
runner.cleanup()
|
||||
|
||||
|
||||
async def example_2_http_transport():
|
||||
"""Example 2: Connect to MCP server via HTTP"""
|
||||
print("\n=== Example 2: HTTP MCP Server Connection ===\n")
|
||||
|
||||
# First, start the tools MCP server in HTTP mode:
|
||||
# cd tools && python mcp_server.py --port 4001
|
||||
|
||||
runner = AgentRunner.load("exports/task-planner")
|
||||
|
||||
# Register tools via HTTP
|
||||
num_tools = runner.register_mcp_server(
|
||||
name="tools-http",
|
||||
transport="http",
|
||||
url="http://localhost:4001",
|
||||
)
|
||||
|
||||
print(f"Registered {num_tools} tools from HTTP MCP server")
|
||||
|
||||
# Cleanup
|
||||
runner.cleanup()
|
||||
|
||||
|
||||
async def example_3_config_file():
|
||||
"""Example 3: Load MCP servers from configuration file"""
|
||||
print("\n=== Example 3: Load from Configuration File ===\n")
|
||||
|
||||
# Create a test agent folder with mcp_servers.json
|
||||
test_agent_path = Path("exports/task-planner")
|
||||
|
||||
# Copy example config (in practice, you'd place this in your agent folder)
|
||||
import shutil
|
||||
|
||||
shutil.copy(Path(__file__).parent / "mcp_servers.json", test_agent_path / "mcp_servers.json")
|
||||
|
||||
# Load agent - MCP servers will be auto-discovered
|
||||
runner = AgentRunner.load(test_agent_path)
|
||||
|
||||
# Tools are automatically available
|
||||
tools = runner._tool_registry.get_tools()
|
||||
print(f"Available tools: {list(tools.keys())}")
|
||||
|
||||
# Cleanup
|
||||
runner.cleanup()
|
||||
|
||||
# Clean up the test config
|
||||
(test_agent_path / "mcp_servers.json").unlink()
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run all examples"""
|
||||
print("=" * 60)
|
||||
print("MCP Integration Examples")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
# Run examples
|
||||
await example_1_programmatic_registration()
|
||||
# await example_2_http_transport() # Requires HTTP server running
|
||||
# await example_3_config_file()
|
||||
# await example_4_custom_agent_with_mcp_tools()
|
||||
|
||||
except Exception as e:
|
||||
print(f"\nError running example: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+14
-60
@@ -1,66 +1,20 @@
|
||||
"""
|
||||
Aden Hive Framework: A goal-driven agent runtime optimized for Builder observability.
|
||||
"""Hive Agent Framework.
|
||||
|
||||
The runtime is designed around DECISIONS, not just actions. Every significant
|
||||
choice the agent makes is captured with:
|
||||
- What it was trying to do (intent)
|
||||
- What options it considered
|
||||
- What it chose and why
|
||||
- What happened as a result
|
||||
- Whether that was good or bad (evaluated post-hoc)
|
||||
|
||||
This gives the Builder LLM the information it needs to improve agent behavior.
|
||||
|
||||
## Testing Framework
|
||||
|
||||
The framework includes a Goal-Based Testing system (Goal → Agent → Eval):
|
||||
- Generate tests from Goal success_criteria and constraints
|
||||
- Mandatory user approval before tests are stored
|
||||
- Parallel test execution with error categorization
|
||||
- Debug tools with fix suggestions
|
||||
|
||||
See `framework.testing` for details.
|
||||
Core classes:
|
||||
ColonyRuntime -- orchestrates parallel worker clones in a colony
|
||||
AgentLoop -- the LLM + tool execution loop (one per worker)
|
||||
AgentLoader -- loads agent config from disk, builds pipeline
|
||||
DecisionTracker -- records decisions for post-hoc analysis
|
||||
"""
|
||||
|
||||
from framework.llm import AnthropicProvider, LLMProvider
|
||||
from framework.runner import AgentRunner
|
||||
from framework.runtime.core import Runtime
|
||||
from framework.schemas.decision import Decision, DecisionEvaluation, Option, Outcome
|
||||
from framework.schemas.run import Problem, Run, RunSummary
|
||||
|
||||
# Testing framework
|
||||
from framework.testing import (
|
||||
ApprovalStatus,
|
||||
DebugTool,
|
||||
ErrorCategory,
|
||||
Test,
|
||||
TestResult,
|
||||
TestStorage,
|
||||
TestSuiteResult,
|
||||
)
|
||||
from framework.agent_loop import AgentLoop
|
||||
from framework.host import ColonyRuntime
|
||||
from framework.loader import AgentLoader
|
||||
from framework.tracker import DecisionTracker
|
||||
|
||||
__all__ = [
|
||||
# Schemas
|
||||
"Decision",
|
||||
"Option",
|
||||
"Outcome",
|
||||
"DecisionEvaluation",
|
||||
"Run",
|
||||
"RunSummary",
|
||||
"Problem",
|
||||
# Runtime
|
||||
"Runtime",
|
||||
# LLM
|
||||
"LLMProvider",
|
||||
"AnthropicProvider",
|
||||
# Runner
|
||||
"AgentRunner",
|
||||
# Testing
|
||||
"Test",
|
||||
"TestResult",
|
||||
"TestSuiteResult",
|
||||
"TestStorage",
|
||||
"ApprovalStatus",
|
||||
"ErrorCategory",
|
||||
"DebugTool",
|
||||
"ColonyRuntime",
|
||||
"AgentLoader",
|
||||
"AgentLoop",
|
||||
"DecisionTracker",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Agent loop -- the core agent execution primitive."""
|
||||
|
||||
from framework.agent_loop.conversation import ( # noqa: F401
|
||||
ConversationStore,
|
||||
Message,
|
||||
NodeConversation,
|
||||
)
|
||||
from framework.agent_loop.types import ( # noqa: F401
|
||||
AgentContext,
|
||||
AgentProtocol,
|
||||
AgentResult,
|
||||
AgentSpec,
|
||||
)
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name in ("AgentLoop", "JudgeProtocol", "JudgeVerdict", "LoopConfig", "OutputAccumulator"):
|
||||
from framework.agent_loop.agent_loop import (
|
||||
AgentLoop,
|
||||
JudgeProtocol,
|
||||
JudgeVerdict,
|
||||
LoopConfig,
|
||||
OutputAccumulator,
|
||||
)
|
||||
|
||||
_exports = {
|
||||
"AgentLoop": AgentLoop,
|
||||
"JudgeProtocol": JudgeProtocol,
|
||||
"JudgeVerdict": JudgeVerdict,
|
||||
"LoopConfig": LoopConfig,
|
||||
"OutputAccumulator": OutputAccumulator,
|
||||
}
|
||||
return _exports[name]
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
+1034
-776
File diff suppressed because it is too large
Load Diff
@@ -3,12 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, Protocol, runtime_checkable
|
||||
|
||||
LEGACY_RUN_ID = "__legacy_run__"
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_legacy_run_id(run_id: str | None) -> bool:
|
||||
@@ -59,9 +61,12 @@ class Message:
|
||||
return {"role": "user", "content": self.content}
|
||||
|
||||
if self.role == "assistant":
|
||||
d: dict[str, Any] = {"role": "assistant", "content": self.content}
|
||||
d: dict[str, Any] = {"role": "assistant"}
|
||||
if self.tool_calls:
|
||||
d["tool_calls"] = self.tool_calls
|
||||
d["content"] = self.content if self.content else None
|
||||
else:
|
||||
d["content"] = self.content or ""
|
||||
return d
|
||||
|
||||
# role == "tool"
|
||||
@@ -233,8 +238,8 @@ def extract_tool_call_history(messages: list[Message], max_entries: int = 30) ->
|
||||
return args.get("query", "")
|
||||
if name == "web_scrape":
|
||||
return args.get("url", "")
|
||||
if name in ("load_data", "save_data"):
|
||||
return args.get("filename", "")
|
||||
if name == "read_file":
|
||||
return args.get("path", "")
|
||||
return ""
|
||||
|
||||
for msg in messages:
|
||||
@@ -250,8 +255,8 @@ def extract_tool_call_history(messages: list[Message], max_entries: int = 30) ->
|
||||
summary = _summarize_input(name, args)
|
||||
tool_calls_detail.setdefault(name, []).append(summary)
|
||||
|
||||
if name == "save_data" and args.get("filename"):
|
||||
files_saved.append(args["filename"])
|
||||
if name == "read_file" and args.get("path"):
|
||||
files_saved.append(args["path"])
|
||||
if name == "set_output" and args.get("key"):
|
||||
outputs_set.append(args["key"])
|
||||
|
||||
@@ -324,7 +329,7 @@ def _try_extract_key(content: str, key: str) -> str | None:
|
||||
3. Colon format: ``key: value``.
|
||||
4. Equals format: ``key = value``.
|
||||
"""
|
||||
from framework.graph.node import find_json_object
|
||||
from framework.orchestrator.node import find_json_object
|
||||
|
||||
# 1. Whole message is JSON
|
||||
try:
|
||||
@@ -376,10 +381,20 @@ class NodeConversation:
|
||||
output_keys: list[str] | None = None,
|
||||
store: ConversationStore | None = None,
|
||||
run_id: str | None = None,
|
||||
compaction_buffer_tokens: int | None = None,
|
||||
compaction_warning_buffer_tokens: int | None = None,
|
||||
) -> None:
|
||||
self._system_prompt = system_prompt
|
||||
self._max_context_tokens = max_context_tokens
|
||||
self._compaction_threshold = compaction_threshold
|
||||
# Buffer-based compaction trigger (Gap 7). When set, takes
|
||||
# precedence over the multiplicative compaction_threshold so the
|
||||
# loop reserves a fixed headroom for the next turn's input+output
|
||||
# instead of trying to get exactly X% of the way to the hard
|
||||
# limit. If left as None the legacy threshold-based rule is
|
||||
# used, keeping old call sites behaving identically.
|
||||
self._compaction_buffer_tokens = compaction_buffer_tokens
|
||||
self._compaction_warning_buffer_tokens = compaction_warning_buffer_tokens
|
||||
self._output_keys = output_keys
|
||||
self._store = store
|
||||
self._messages: list[Message] = []
|
||||
@@ -486,6 +501,27 @@ class NodeConversation:
|
||||
image_content: list[dict[str, Any]] | None = None,
|
||||
is_skill_content: bool = False,
|
||||
) -> Message:
|
||||
# Dedup guard: reject a second tool_result for the same tool_use_id.
|
||||
# Anthropic's API only accepts one result per tool_call, and a duplicate
|
||||
# causes a hard 400 two turns later ("messages with role 'tool' must
|
||||
# be a response to a preceding message with 'tool_calls'"). Duplicates
|
||||
# can arise when a tool_call_timeout fires and records a placeholder
|
||||
# error, then the real executor thread eventually delivers the actual
|
||||
# result (the thread kept running inside run_in_executor — see
|
||||
# tool_result_handler.execute_tool). We keep the FIRST result to
|
||||
# preserve whatever state the agent already reasoned about.
|
||||
for existing in reversed(self._messages):
|
||||
if existing.role == "tool" and existing.tool_use_id == tool_use_id:
|
||||
import logging as _logging
|
||||
|
||||
_logging.getLogger(__name__).warning(
|
||||
"add_tool_result: dropping duplicate result for tool_use_id=%s "
|
||||
"(first result preserved, %d chars; new result ignored, %d chars)",
|
||||
tool_use_id,
|
||||
len(existing.content),
|
||||
len(content),
|
||||
)
|
||||
return existing
|
||||
msg = Message(
|
||||
seq=self._next_seq,
|
||||
role="tool",
|
||||
@@ -513,7 +549,48 @@ class NodeConversation:
|
||||
can happen when a loop is cancelled mid-tool-execution.
|
||||
"""
|
||||
msgs = [m.to_llm_dict() for m in self._messages]
|
||||
return self._repair_orphaned_tool_calls(msgs)
|
||||
msgs = self._repair_orphaned_tool_calls(msgs)
|
||||
msgs = self._sanitize_for_api(msgs)
|
||||
return msgs
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_for_api(msgs: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Final pass: ensure message sequence is valid for strict APIs.
|
||||
|
||||
Rules:
|
||||
1. No two consecutive messages with the same role (merge or drop)
|
||||
2. Tool messages must have a tool_call_id
|
||||
3. Assistant messages with tool_calls must have content=null, not ""
|
||||
4. First message must not be 'tool' or 'assistant' (without prior context)
|
||||
"""
|
||||
cleaned: list[dict[str, Any]] = []
|
||||
for m in msgs:
|
||||
role = m.get("role")
|
||||
|
||||
# Fix assistant content when tool_calls present
|
||||
if role == "assistant" and m.get("tool_calls"):
|
||||
if m.get("content") == "":
|
||||
m["content"] = None
|
||||
|
||||
# Drop tool messages without tool_call_id
|
||||
if role == "tool" and not m.get("tool_call_id"):
|
||||
continue
|
||||
|
||||
# Drop consecutive duplicate roles (merge user messages)
|
||||
if cleaned and cleaned[-1].get("role") == role == "user":
|
||||
prev_content = cleaned[-1].get("content", "")
|
||||
curr_content = m.get("content", "")
|
||||
if isinstance(prev_content, str) and isinstance(curr_content, str):
|
||||
cleaned[-1]["content"] = f"{prev_content}\n{curr_content}"
|
||||
continue
|
||||
|
||||
cleaned.append(m)
|
||||
|
||||
# Drop leading assistant/tool messages (no prior context)
|
||||
while cleaned and cleaned[0].get("role") in ("assistant", "tool"):
|
||||
cleaned.pop(0)
|
||||
|
||||
return cleaned
|
||||
|
||||
@staticmethod
|
||||
def _repair_orphaned_tool_calls(
|
||||
@@ -521,11 +598,18 @@ class NodeConversation:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Ensure tool_call / tool_result pairs are consistent.
|
||||
|
||||
1. **Orphaned tool results** (tool_result with no preceding tool_use)
|
||||
are dropped. This happens when compaction removes an assistant
|
||||
message but leaves its tool-result messages behind.
|
||||
2. **Orphaned tool calls** (tool_use with no following tool_result)
|
||||
get a synthetic error result appended. This happens when a loop
|
||||
1. **Orphaned tool results** (tool_result with no matching tool_use
|
||||
anywhere) are dropped. Happens after compaction removes the
|
||||
parent assistant message.
|
||||
2. **Positionally orphaned tool results** (tool_result separated
|
||||
from its parent by a non-tool message, e.g. a user injection)
|
||||
are dropped. The Anthropic API requires tool messages to
|
||||
follow immediately after the assistant message that issued
|
||||
the matching tool_call.
|
||||
3. **Duplicate tool results** (same tool_call_id appearing more
|
||||
than once) are dropped; only the first is kept.
|
||||
4. **Orphaned tool calls** (tool_use with no following tool_result)
|
||||
get a synthetic error result appended. Happens when the loop
|
||||
is cancelled mid-tool-execution.
|
||||
"""
|
||||
# Pass 1: collect all tool_call IDs from assistant messages so we
|
||||
@@ -538,41 +622,75 @@ class NodeConversation:
|
||||
if tc_id:
|
||||
all_tool_call_ids.add(tc_id)
|
||||
|
||||
# Pass 2: build repaired list — drop orphaned tool results, patch
|
||||
# missing tool results.
|
||||
# Pass 2: build repaired list — drop orphaned tool results, drop
|
||||
# positional orphans and duplicates, patch missing tool results.
|
||||
#
|
||||
# ``open_tool_calls`` holds the tool_call IDs we're still expecting
|
||||
# results for: it's populated when we emit an assistant-with-tool_calls
|
||||
# and drained as matching tool messages follow. Any tool message
|
||||
# whose id is not currently open is positionally invalid and gets
|
||||
# dropped — that closes the gap that caused the tool-after-user
|
||||
# 400 errors.
|
||||
repaired: list[dict[str, Any]] = []
|
||||
for i, m in enumerate(msgs):
|
||||
# Drop tool-result messages whose tool_call_id has no matching
|
||||
# tool_use in any assistant message (orphaned by compaction).
|
||||
if m.get("role") == "tool":
|
||||
tid = m.get("tool_call_id")
|
||||
if tid and tid not in all_tool_call_ids:
|
||||
continue # skip orphaned result
|
||||
open_tool_calls: set[str] = set()
|
||||
seen_tool_ids: set[str] = set()
|
||||
for m in msgs:
|
||||
role = m.get("role")
|
||||
|
||||
repaired.append(m)
|
||||
tool_calls = m.get("tool_calls")
|
||||
if m.get("role") != "assistant" or not tool_calls:
|
||||
if role == "tool":
|
||||
tid = m.get("tool_call_id")
|
||||
# Drop tool results with no matching tool_use anywhere.
|
||||
if not tid or tid not in all_tool_call_ids:
|
||||
continue
|
||||
# Drop duplicates (same id appearing twice) — keep first.
|
||||
if tid in seen_tool_ids:
|
||||
continue
|
||||
# Drop positional orphans — tool messages whose parent
|
||||
# assistant isn't the still-open assistant block.
|
||||
if tid not in open_tool_calls:
|
||||
continue
|
||||
open_tool_calls.discard(tid)
|
||||
seen_tool_ids.add(tid)
|
||||
repaired.append(m)
|
||||
continue
|
||||
# Collect IDs of tool results that follow this assistant message
|
||||
answered: set[str] = set()
|
||||
for j in range(i + 1, len(msgs)):
|
||||
if msgs[j].get("role") == "tool":
|
||||
tid = msgs[j].get("tool_call_id")
|
||||
if tid:
|
||||
answered.add(tid)
|
||||
else:
|
||||
break # stop at first non-tool message
|
||||
# Patch any missing results
|
||||
for tc in tool_calls:
|
||||
tc_id = tc.get("id")
|
||||
if tc_id and tc_id not in answered:
|
||||
|
||||
# Any non-tool message closes the current assistant tool block.
|
||||
# If the previous assistant left tool_calls unanswered, patch
|
||||
# synthetic error results before emitting this message so the
|
||||
# API sees a complete pairing.
|
||||
if open_tool_calls:
|
||||
for stale_id in list(open_tool_calls):
|
||||
repaired.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tc_id,
|
||||
"tool_call_id": stale_id,
|
||||
"content": "ERROR: Tool execution was interrupted.",
|
||||
}
|
||||
)
|
||||
seen_tool_ids.add(stale_id)
|
||||
open_tool_calls.clear()
|
||||
|
||||
repaired.append(m)
|
||||
|
||||
if role == "assistant":
|
||||
for tc in m.get("tool_calls") or []:
|
||||
tc_id = tc.get("id")
|
||||
if tc_id and tc_id not in seen_tool_ids:
|
||||
open_tool_calls.add(tc_id)
|
||||
|
||||
# Tail: if the conversation ends with an assistant that issued
|
||||
# tool_calls and no results followed, patch them so the next
|
||||
# turn's first message can be a valid assistant/user response.
|
||||
if open_tool_calls:
|
||||
for stale_id in list(open_tool_calls):
|
||||
repaired.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": stale_id,
|
||||
"content": "ERROR: Tool execution was interrupted.",
|
||||
}
|
||||
)
|
||||
|
||||
return repaired
|
||||
|
||||
def estimate_tokens(self) -> int:
|
||||
@@ -621,8 +739,37 @@ class NodeConversation:
|
||||
return self.estimate_tokens() / self._max_context_tokens
|
||||
|
||||
def needs_compaction(self) -> bool:
|
||||
"""True when the conversation should be compacted before the
|
||||
next LLM call.
|
||||
|
||||
Buffer-based rule (Gap 7): trigger when the current estimate
|
||||
plus the configured buffer would exceed the hard context limit.
|
||||
Prevents compaction from firing only AFTER we're already over
|
||||
the wire and forced into a reactive binary-split pass.
|
||||
|
||||
When no buffer is configured, falls back to the multiplicative
|
||||
threshold the old callers were built around.
|
||||
"""
|
||||
if self._max_context_tokens <= 0:
|
||||
return False
|
||||
if self._compaction_buffer_tokens is not None:
|
||||
budget = self._max_context_tokens - self._compaction_buffer_tokens
|
||||
return self.estimate_tokens() >= max(0, budget)
|
||||
return self.estimate_tokens() >= self._max_context_tokens * self._compaction_threshold
|
||||
|
||||
def compaction_warning(self) -> bool:
|
||||
"""True when the conversation has crossed the warning threshold
|
||||
but not yet the hard compaction trigger.
|
||||
|
||||
Used by telemetry / UI to show a "context getting tight" hint
|
||||
before a compaction pass actually runs. Returns False when no
|
||||
warning buffer is configured (legacy behaviour).
|
||||
"""
|
||||
if self._max_context_tokens <= 0 or self._compaction_warning_buffer_tokens is None:
|
||||
return False
|
||||
warn_at = self._max_context_tokens - self._compaction_warning_buffer_tokens
|
||||
return self.estimate_tokens() >= max(0, warn_at)
|
||||
|
||||
# --- Output-key extraction ---------------------------------------------
|
||||
|
||||
def _extract_protected_values(self, messages: list[Message]) -> dict[str, str]:
|
||||
@@ -733,7 +880,7 @@ class NodeConversation:
|
||||
placeholder = (
|
||||
f"[Pruned tool result: {orig_len} chars. "
|
||||
f"Full data in '{spillover}'. "
|
||||
f"Use load_data('{spillover}') to retrieve.]"
|
||||
f"Use read_file('{spillover}') to retrieve.]"
|
||||
)
|
||||
else:
|
||||
placeholder = f"[Pruned tool result: {orig_len} chars cleared from context.]"
|
||||
@@ -1024,7 +1171,7 @@ class NodeConversation:
|
||||
full_path = str((spill_path / conv_filename).resolve())
|
||||
ref_parts.append(
|
||||
f"[Previous conversation saved to '{full_path}'. "
|
||||
f"Use load_data('{conv_filename}') to review if needed.]"
|
||||
f"Use read_file('{conv_filename}') to review if needed.]"
|
||||
)
|
||||
elif not collapsed_msgs:
|
||||
ref_parts.append("[Previous freeform messages compacted.]")
|
||||
@@ -1156,6 +1303,10 @@ class NodeConversation:
|
||||
"system_prompt": self._system_prompt,
|
||||
"max_context_tokens": self._max_context_tokens,
|
||||
"compaction_threshold": self._compaction_threshold,
|
||||
"compaction_buffer_tokens": self._compaction_buffer_tokens,
|
||||
"compaction_warning_buffer_tokens": (
|
||||
self._compaction_warning_buffer_tokens
|
||||
),
|
||||
"output_keys": self._output_keys,
|
||||
}
|
||||
await self._store.write_meta(run_meta)
|
||||
@@ -1203,12 +1354,30 @@ class NodeConversation:
|
||||
output_keys=meta.get("output_keys"),
|
||||
store=store,
|
||||
run_id=run_id,
|
||||
compaction_buffer_tokens=meta.get("compaction_buffer_tokens"),
|
||||
compaction_warning_buffer_tokens=meta.get(
|
||||
"compaction_warning_buffer_tokens"
|
||||
),
|
||||
)
|
||||
conv._meta_persisted = True
|
||||
|
||||
parts = await store.read_parts()
|
||||
if phase_id:
|
||||
parts = [p for p in parts if p.get("phase_id") == phase_id]
|
||||
filtered_parts = [p for p in parts if p.get("phase_id") == phase_id]
|
||||
if filtered_parts:
|
||||
parts = filtered_parts
|
||||
elif parts and all(p.get("phase_id") is None for p in parts):
|
||||
# Backward compatibility: older isolated stores (including queen
|
||||
# sessions) persisted parts without phase_id. In that case, the
|
||||
# phase filter would incorrectly hide the entire conversation.
|
||||
logger.info(
|
||||
"Restoring legacy unphased conversation without applying "
|
||||
"phase filter (phase_id=%s, parts=%d)",
|
||||
phase_id,
|
||||
len(parts),
|
||||
)
|
||||
else:
|
||||
parts = filtered_parts
|
||||
# Filter by run_id so intentional restarts (new run_id) start fresh
|
||||
# while crash recovery (same run_id) loads prior parts.
|
||||
if run_id and not is_legacy_run_id(run_id):
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Agent loop internals -- compaction, judge, tools, subagent execution.
|
||||
|
||||
Re-exports from legacy locations for the new import path.
|
||||
"""
|
||||
|
||||
from framework.agent_loop.internals.compaction import * # noqa: F401, F403
|
||||
from framework.agent_loop.internals.synthetic_tools import * # noqa: F401, F403
|
||||
+31
-26
@@ -19,11 +19,11 @@ from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from framework.graph.conversation import Message, NodeConversation
|
||||
from framework.graph.event_loop.event_publishing import publish_context_usage
|
||||
from framework.graph.event_loop.types import LoopConfig, OutputAccumulator
|
||||
from framework.graph.node import NodeContext
|
||||
from framework.runtime.event_bus import EventBus
|
||||
from framework.agent_loop.conversation import Message, NodeConversation
|
||||
from framework.agent_loop.internals.event_publishing import publish_context_usage
|
||||
from framework.agent_loop.internals.types import LoopConfig, OutputAccumulator
|
||||
from framework.host.event_bus import EventBus
|
||||
from framework.orchestrator.node import NodeContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -104,7 +104,7 @@ def microcompact(
|
||||
placeholder = (
|
||||
f"[Old tool result cleared: {orig_len} chars. "
|
||||
f"Full data in '{spillover}'. "
|
||||
f"Use load_data('{spillover}') to retrieve.]"
|
||||
f"Use read_file('{spillover}') to retrieve.]"
|
||||
)
|
||||
else:
|
||||
placeholder = f"[Old tool result cleared: {orig_len} chars.]"
|
||||
@@ -168,13 +168,18 @@ async def compact(
|
||||
"""
|
||||
conv_id = id(conversation)
|
||||
|
||||
# Circuit breaker: stop auto-compacting after repeated failures
|
||||
if _failure_counts.get(conv_id, 0) >= MAX_CONSECUTIVE_FAILURES:
|
||||
# Circuit breaker: stop LLM-based compaction after repeated failures,
|
||||
# but still fall through to the emergency deterministic summary so
|
||||
# the conversation doesn't silently grow past the context window.
|
||||
# Without this, a persistent LLM outage during compaction would
|
||||
# leave the agent stuck sending oversized prompts until the API 400s.
|
||||
_llm_compaction_skipped = _failure_counts.get(conv_id, 0) >= MAX_CONSECUTIVE_FAILURES
|
||||
if _llm_compaction_skipped:
|
||||
logger.warning(
|
||||
"Circuit breaker: skipping compaction after %d consecutive failures",
|
||||
"Circuit breaker: LLM compaction disabled after %d failures — "
|
||||
"skipping straight to emergency summary",
|
||||
_failure_counts[conv_id],
|
||||
)
|
||||
return
|
||||
|
||||
# Recompaction detection
|
||||
now = time.monotonic()
|
||||
@@ -256,7 +261,7 @@ async def compact(
|
||||
return
|
||||
|
||||
# --- Step 3: LLM summary compaction ---
|
||||
if ctx.llm is not None:
|
||||
if ctx.llm is not None and not _llm_compaction_skipped:
|
||||
logger.info(
|
||||
"LLM summary compaction triggered (%.0f%% usage)",
|
||||
conversation.usage_ratio() * 100,
|
||||
@@ -368,8 +373,8 @@ async def llm_compact(
|
||||
in half and each half is summarised independently. Tool history is
|
||||
appended once at the top-level call (``_depth == 0``).
|
||||
"""
|
||||
from framework.graph.conversation import extract_tool_call_history
|
||||
from framework.graph.event_loop.tool_result_handler import is_context_too_large_error
|
||||
from framework.agent_loop.conversation import extract_tool_call_history
|
||||
from framework.agent_loop.internals.tool_result_handler import is_context_too_large_error
|
||||
|
||||
if _depth > max_depth:
|
||||
raise RuntimeError(f"LLM compaction recursion limit ({max_depth})")
|
||||
@@ -506,7 +511,7 @@ def build_llm_compaction_prompt(
|
||||
service. Each section focuses on a different aspect of the conversation
|
||||
so the summariser produces consistently useful, well-organised output.
|
||||
"""
|
||||
spec = ctx.node_spec
|
||||
spec = ctx.agent_spec
|
||||
ctx_lines = [f"NODE: {spec.name} (id={spec.id})"]
|
||||
if spec.description:
|
||||
ctx_lines.append(f"PURPOSE: {spec.description}")
|
||||
@@ -622,13 +627,13 @@ def write_compaction_debug_log(
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%S_%f")
|
||||
node_label = ctx.node_id.replace("/", "_")
|
||||
node_label = ctx.agent_id.replace("/", "_")
|
||||
log_path = log_dir / f"{ts}_{node_label}.md"
|
||||
|
||||
lines: list[str] = [
|
||||
f"# Compaction Debug — {ctx.node_id}",
|
||||
f"# Compaction Debug — {ctx.agent_id}",
|
||||
f"**Time:** {datetime.now(UTC).isoformat()}",
|
||||
f"**Node:** {ctx.node_spec.name} (`{ctx.node_id}`)",
|
||||
f"**Node:** {ctx.agent_spec.name} (`{ctx.agent_id}`)",
|
||||
]
|
||||
if ctx.stream_id:
|
||||
lines.append(f"**Stream:** {ctx.stream_id}")
|
||||
@@ -715,7 +720,7 @@ async def log_compaction(
|
||||
|
||||
if ctx.runtime_logger:
|
||||
ctx.runtime_logger.log_step(
|
||||
node_id=ctx.node_id,
|
||||
node_id=ctx.agent_id,
|
||||
node_type="event_loop",
|
||||
step_index=-1,
|
||||
llm_text=f"Context compacted ({level}): {before_pct}% \u2192 {after_pct}%",
|
||||
@@ -724,7 +729,7 @@ async def log_compaction(
|
||||
)
|
||||
|
||||
if event_bus:
|
||||
from framework.runtime.event_bus import AgentEvent, EventType
|
||||
from framework.host.event_bus import AgentEvent, EventType
|
||||
|
||||
event_data: dict[str, Any] = {
|
||||
"level": level,
|
||||
@@ -736,8 +741,8 @@ async def log_compaction(
|
||||
await event_bus.publish(
|
||||
AgentEvent(
|
||||
type=EventType.CONTEXT_COMPACTED,
|
||||
stream_id=ctx.stream_id or ctx.node_id,
|
||||
node_id=ctx.node_id,
|
||||
stream_id=ctx.stream_id or ctx.agent_id,
|
||||
node_id=ctx.agent_id,
|
||||
data=event_data,
|
||||
)
|
||||
)
|
||||
@@ -768,7 +773,7 @@ def build_emergency_summary(
|
||||
]
|
||||
|
||||
# 1. Node identity
|
||||
spec = ctx.node_spec
|
||||
spec = ctx.agent_spec
|
||||
parts.append(f"NODE: {spec.name} (id={spec.id})")
|
||||
if spec.description:
|
||||
parts.append(f"PURPOSE: {spec.description}")
|
||||
@@ -776,7 +781,7 @@ def build_emergency_summary(
|
||||
# 2. Inputs the node received
|
||||
input_lines = []
|
||||
for key in spec.input_keys:
|
||||
value = ctx.input_data.get(key) or ctx.buffer.read(key)
|
||||
value = ctx.input_data.get(key)
|
||||
if value is not None:
|
||||
# Truncate long values but keep them recognisable
|
||||
v_str = str(value)
|
||||
@@ -823,13 +828,13 @@ def build_emergency_summary(
|
||||
)
|
||||
parts.append(
|
||||
"CONVERSATION HISTORY (freeform messages saved during compaction — "
|
||||
"use load_data('<filename>') to review earlier dialogue):\n" + conv_list
|
||||
"use read_file('<filename>') to review earlier dialogue):\n" + conv_list
|
||||
)
|
||||
if data_files:
|
||||
file_list = "\n".join(
|
||||
f" - {f} (full path: {data_dir / f})" for f in data_files[:30]
|
||||
)
|
||||
parts.append("DATA FILES (use load_data('<filename>') to read):\n" + file_list)
|
||||
parts.append("DATA FILES (use read_file('<filename>') to read):\n" + file_list)
|
||||
if not all_files:
|
||||
parts.append(
|
||||
"NOTE: Large tool results may have been saved to files. "
|
||||
@@ -861,6 +866,6 @@ def _extract_tool_call_history(conversation: NodeConversation) -> str:
|
||||
directly (vs. the module-level extract_tool_call_history in conversation.py
|
||||
which works on raw message lists).
|
||||
"""
|
||||
from framework.graph.conversation import extract_tool_call_history
|
||||
from framework.agent_loop.conversation import extract_tool_call_history
|
||||
|
||||
return extract_tool_call_history(list(conversation.messages))
|
||||
+25
-11
@@ -14,10 +14,10 @@ from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from framework.graph.conversation import ConversationStore, NodeConversation
|
||||
from framework.graph.event_loop.types import LoopConfig, OutputAccumulator, TriggerEvent
|
||||
from framework.graph.node import NodeContext
|
||||
from framework.agent_loop.conversation import ConversationStore, NodeConversation
|
||||
from framework.agent_loop.internals.types import LoopConfig, OutputAccumulator, TriggerEvent
|
||||
from framework.llm.capabilities import supports_image_tool_results
|
||||
from framework.orchestrator.node import NodeContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -53,15 +53,31 @@ async def restore(
|
||||
# continuous mode (or when _restore is called for timer-resume)
|
||||
# load all parts — the full conversation threads across nodes.
|
||||
_is_continuous = getattr(ctx, "continuous_mode", False)
|
||||
phase_filter = None if _is_continuous else ctx.node_id
|
||||
# The queen has agent_id="queen" but messages are stored with phase_id=None.
|
||||
# Only apply phase filtering for non-queen workers in a multi-agent setup.
|
||||
phase_filter = None if (_is_continuous or ctx.agent_id == "queen") else ctx.agent_id
|
||||
conversation = await NodeConversation.restore(
|
||||
conversation_store,
|
||||
phase_id=phase_filter,
|
||||
run_id=ctx.effective_run_id,
|
||||
)
|
||||
if conversation is None:
|
||||
logger.info(
|
||||
"[restore] No conversation found for agent_id=%s phase_filter=%s run_id=%s",
|
||||
ctx.agent_id,
|
||||
phase_filter,
|
||||
ctx.effective_run_id,
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"[restore] Restored %d messages for agent_id=%s phase_filter=%s run_id=%s",
|
||||
conversation.message_count,
|
||||
ctx.agent_id,
|
||||
phase_filter,
|
||||
ctx.effective_run_id,
|
||||
)
|
||||
|
||||
# If run_id filtering removed all messages, this is an intentional
|
||||
# restart (new run), not a crash recovery. Return None so the caller
|
||||
# falls through to the fresh-conversation path.
|
||||
@@ -124,7 +140,7 @@ async def write_cursor(
|
||||
cursor.update(
|
||||
{
|
||||
"iteration": iteration,
|
||||
"node_id": ctx.node_id,
|
||||
"node_id": ctx.agent_id,
|
||||
"outputs": accumulator.to_dict(),
|
||||
}
|
||||
)
|
||||
@@ -153,7 +169,10 @@ async def drain_injection_queue(
|
||||
) -> int:
|
||||
"""Drain all pending injected events as user messages. Returns count."""
|
||||
count = 0
|
||||
logger.debug("[drain_injection_queue] Starting to drain queue, initial queue size: %s", queue.qsize() if hasattr(queue, 'qsize') else 'unknown')
|
||||
logger.debug(
|
||||
"[drain_injection_queue] Starting to drain queue, initial queue size: %s",
|
||||
queue.qsize() if hasattr(queue, "qsize") else "unknown",
|
||||
)
|
||||
while not queue.empty():
|
||||
try:
|
||||
content, is_client_input, image_content = queue.get_nowait()
|
||||
@@ -242,11 +261,6 @@ async def check_pause(
|
||||
|
||||
# Check context-level pause flags (legacy/alternative methods)
|
||||
pause_requested = ctx.input_data.get("pause_requested", False)
|
||||
if not pause_requested:
|
||||
try:
|
||||
pause_requested = ctx.buffer.read("pause_requested") or False
|
||||
except (PermissionError, KeyError):
|
||||
pause_requested = False
|
||||
if pause_requested:
|
||||
completed = iteration
|
||||
logger.info(f"⏸ Pausing after {completed} iteration(s) completed (context-level)")
|
||||
+10
-12
@@ -9,10 +9,10 @@ from __future__ import annotations
|
||||
import logging
|
||||
import time
|
||||
|
||||
from framework.graph.conversation import NodeConversation
|
||||
from framework.graph.event_loop.types import HookContext
|
||||
from framework.graph.node import NodeContext
|
||||
from framework.runtime.event_bus import EventBus
|
||||
from framework.agent_loop.conversation import NodeConversation
|
||||
from framework.agent_loop.internals.types import HookContext
|
||||
from framework.host.event_bus import EventBus
|
||||
from framework.orchestrator.node import NodeContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,14 +45,14 @@ async def generate_action_plan(
|
||||
Runs as a fire-and-forget task so it never blocks the main loop.
|
||||
"""
|
||||
try:
|
||||
system_prompt = ctx.node_spec.system_prompt or ""
|
||||
system_prompt = ctx.agent_spec.system_prompt or ""
|
||||
# Trim to keep the prompt small
|
||||
prompt_summary = system_prompt[:500]
|
||||
if len(system_prompt) > 500:
|
||||
prompt_summary += "..."
|
||||
|
||||
tool_names = [t.name for t in ctx.available_tools]
|
||||
output_keys = ctx.node_spec.output_keys or []
|
||||
output_keys = ctx.agent_spec.output_keys or []
|
||||
|
||||
prompt = (
|
||||
f'You are about to work on a task as node "{node_id}".\n\n'
|
||||
@@ -177,7 +177,7 @@ async def publish_context_usage(
|
||||
if not event_bus:
|
||||
return
|
||||
|
||||
from framework.runtime.event_bus import AgentEvent, EventType
|
||||
from framework.host.event_bus import AgentEvent, EventType
|
||||
|
||||
estimated = conversation.estimate_tokens()
|
||||
max_tokens = conversation._max_context_tokens
|
||||
@@ -185,8 +185,8 @@ async def publish_context_usage(
|
||||
await event_bus.publish(
|
||||
AgentEvent(
|
||||
type=EventType.CONTEXT_USAGE_UPDATED,
|
||||
stream_id=ctx.stream_id or ctx.node_id,
|
||||
node_id=ctx.node_id,
|
||||
stream_id=ctx.stream_id or ctx.agent_id,
|
||||
node_id=ctx.agent_id,
|
||||
data={
|
||||
"usage_ratio": round(ratio, 4),
|
||||
"usage_pct": round(ratio * 100),
|
||||
@@ -319,9 +319,7 @@ async def publish_output_key_set(
|
||||
execution_id: str = "",
|
||||
) -> None:
|
||||
if event_bus:
|
||||
await event_bus.emit_output_key_set(
|
||||
stream_id=stream_id, node_id=node_id, key=key, execution_id=execution_id
|
||||
)
|
||||
pass
|
||||
|
||||
|
||||
async def run_hooks(
|
||||
+14
-28
@@ -5,9 +5,9 @@ from __future__ import annotations
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
|
||||
from framework.graph.conversation import NodeConversation
|
||||
from framework.graph.event_loop.types import JudgeProtocol, JudgeVerdict, OutputAccumulator
|
||||
from framework.graph.node import NodeContext
|
||||
from framework.agent_loop.conversation import NodeConversation
|
||||
from framework.agent_loop.internals.types import JudgeProtocol, JudgeVerdict, OutputAccumulator
|
||||
from framework.orchestrator.node import NodeContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -79,7 +79,7 @@ async def judge_turn(
|
||||
if mark_complete_flag:
|
||||
return JudgeVerdict(action="ACCEPT")
|
||||
|
||||
if ctx.node_spec.skip_judge:
|
||||
if ctx.agent_spec.skip_judge:
|
||||
return JudgeVerdict(action="RETRY") # feedback=None → not logged
|
||||
|
||||
# --- Level 1: custom judge -----------------------------------------
|
||||
@@ -92,9 +92,9 @@ async def judge_turn(
|
||||
"accumulator": accumulator,
|
||||
"iteration": iteration,
|
||||
"conversation_summary": conversation.export_summary(),
|
||||
"output_keys": ctx.node_spec.output_keys,
|
||||
"output_keys": ctx.agent_spec.output_keys,
|
||||
"missing_keys": get_missing_output_keys_fn(
|
||||
accumulator, ctx.node_spec.output_keys, ctx.node_spec.nullable_output_keys
|
||||
accumulator, ctx.agent_spec.output_keys, ctx.agent_spec.nullable_output_keys
|
||||
),
|
||||
}
|
||||
verdict = await judge.evaluate(context)
|
||||
@@ -110,7 +110,7 @@ async def judge_turn(
|
||||
return JudgeVerdict(action="RETRY") # feedback=None → not logged
|
||||
|
||||
missing = get_missing_output_keys_fn(
|
||||
accumulator, ctx.node_spec.output_keys, ctx.node_spec.nullable_output_keys
|
||||
accumulator, ctx.agent_spec.output_keys, ctx.agent_spec.nullable_output_keys
|
||||
)
|
||||
|
||||
if missing:
|
||||
@@ -124,8 +124,8 @@ async def judge_turn(
|
||||
|
||||
# All output keys present — run safety checks before accepting.
|
||||
|
||||
output_keys = ctx.node_spec.output_keys or []
|
||||
nullable_keys = set(ctx.node_spec.nullable_output_keys or [])
|
||||
output_keys = ctx.agent_spec.output_keys or []
|
||||
nullable_keys = set(ctx.agent_spec.nullable_output_keys or [])
|
||||
|
||||
# All-nullable with nothing set → node produced nothing useful.
|
||||
all_nullable = output_keys and nullable_keys >= set(output_keys)
|
||||
@@ -139,30 +139,16 @@ async def judge_turn(
|
||||
),
|
||||
)
|
||||
|
||||
# Queen with no output keys → continuous interaction node.
|
||||
# Inject tool-use pressure instead of auto-accepting.
|
||||
if not output_keys and ctx.supports_direct_user_io:
|
||||
return JudgeVerdict(
|
||||
action="RETRY",
|
||||
feedback=(
|
||||
"STOP describing what you will do. "
|
||||
"You have FULL access to all tools — file creation, "
|
||||
"shell commands, MCP tools — and you CAN call them "
|
||||
"directly in your response. Respond ONLY with tool "
|
||||
"calls, no prose. Execute the task now."
|
||||
),
|
||||
)
|
||||
|
||||
# Level 2b: conversation-aware quality check (if success_criteria set)
|
||||
if ctx.node_spec.success_criteria and ctx.llm:
|
||||
from framework.graph.conversation_judge import evaluate_phase_completion
|
||||
if ctx.agent_spec.success_criteria and ctx.llm:
|
||||
from framework.orchestrator.conversation_judge import evaluate_phase_completion
|
||||
|
||||
verdict = await evaluate_phase_completion(
|
||||
llm=ctx.llm,
|
||||
conversation=conversation,
|
||||
phase_name=ctx.node_spec.name,
|
||||
phase_description=ctx.node_spec.description,
|
||||
success_criteria=ctx.node_spec.success_criteria,
|
||||
phase_name=ctx.agent_spec.name,
|
||||
phase_description=ctx.agent_spec.description,
|
||||
success_criteria=ctx.agent_spec.success_criteria,
|
||||
accumulator_state=accumulator.to_dict(),
|
||||
max_context_tokens=max_context_tokens,
|
||||
)
|
||||
+161
-93
@@ -15,6 +15,82 @@ from typing import Any
|
||||
from framework.llm.provider import Tool, ToolResult
|
||||
|
||||
|
||||
def sanitize_ask_user_inputs(
|
||||
raw_question: Any,
|
||||
raw_options: Any,
|
||||
) -> tuple[str, list[str] | None]:
|
||||
"""Self-heal a malformed ``ask_user`` tool call.
|
||||
|
||||
Some model families (notably when the system prompt teaches them
|
||||
XML-ish scratchpad tags like ``<relationship>...</relationship>``)
|
||||
carry that style into tool arguments and produce calls like::
|
||||
|
||||
ask_user({
|
||||
"question": "What now?</question>\\n_OPTIONS: [\\"A\\", \\"B\\"]"
|
||||
})
|
||||
|
||||
Symptoms:
|
||||
- The chat UI renders ``</question>`` and ``_OPTIONS: [...]`` as
|
||||
literal text in the question bubble.
|
||||
- No buttons appear because the real ``options`` parameter is
|
||||
empty.
|
||||
|
||||
This function:
|
||||
- Strips leading/trailing whitespace.
|
||||
- Removes a trailing ``</question>`` (with optional preceding
|
||||
whitespace) from the question text.
|
||||
- Detects an inline ``_OPTIONS:``, ``OPTIONS:``, or ``options:``
|
||||
line followed by a JSON array, parses it, and returns the
|
||||
recovered list as the second element.
|
||||
- Removes the parsed line from the returned question text.
|
||||
|
||||
Returns ``(cleaned_question, recovered_options_or_None)``. The
|
||||
caller should treat the recovered list as a fallback only when
|
||||
the model did not also supply a real ``options`` array.
|
||||
"""
|
||||
import json as _json
|
||||
import re as _re
|
||||
|
||||
if raw_question is None:
|
||||
return "", None
|
||||
q = str(raw_question)
|
||||
|
||||
# Strip a stray </question> tag (case-insensitive, with optional
|
||||
# preceding whitespace) anywhere in the string. This is the most
|
||||
# common failure mode and never represents valid content.
|
||||
q = _re.sub(r"\s*</\s*question\s*>\s*", "\n", q, flags=_re.IGNORECASE)
|
||||
|
||||
# Look for an inline options line. Match _OPTIONS, OPTIONS, options
|
||||
# (with or without leading underscore), followed by ':' or '=', then
|
||||
# a JSON array on the same line OR on the next line.
|
||||
inline_options_re = _re.compile(
|
||||
r"(?im)^\s*_?options\s*[:=]\s*(\[.*?\])\s*$",
|
||||
_re.DOTALL,
|
||||
)
|
||||
|
||||
recovered: list[str] | None = None
|
||||
match = inline_options_re.search(q)
|
||||
if match is not None:
|
||||
try:
|
||||
parsed = _json.loads(match.group(1))
|
||||
if isinstance(parsed, list):
|
||||
cleaned = [str(o).strip() for o in parsed if str(o).strip()]
|
||||
if 1 <= len(cleaned) <= 8:
|
||||
recovered = cleaned
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if recovered is not None:
|
||||
# Remove the parsed line so it doesn't leak into the
|
||||
# rendered question text.
|
||||
q = inline_options_re.sub("", q, count=1)
|
||||
|
||||
# Strip any final whitespace / leftover blank lines from the
|
||||
# question after removals.
|
||||
q = _re.sub(r"\n{3,}", "\n\n", q).strip()
|
||||
|
||||
return q, recovered
|
||||
|
||||
|
||||
def build_ask_user_tool() -> Tool:
|
||||
"""Build the synthetic ask_user tool for explicit user-input requests.
|
||||
|
||||
@@ -28,7 +104,20 @@ def build_ask_user_tool() -> Tool:
|
||||
"You MUST call this tool whenever you need the user's response. "
|
||||
"Always call it after greeting the user, asking a question, or "
|
||||
"requesting approval. Do NOT call it for status updates or "
|
||||
"summaries that don't require a response. "
|
||||
"summaries that don't require a response.\n\n"
|
||||
"STRUCTURE RULES (CRITICAL):\n"
|
||||
"- The 'question' field is PLAIN TEXT shown to the user. Do NOT "
|
||||
"include XML tags, pseudo-tags like </question>, or option lists "
|
||||
"in the question string. The UI does not parse them — they "
|
||||
"render as raw text and look broken.\n"
|
||||
"- The 'options' parameter is the ONLY way to render buttons. "
|
||||
"If you want buttons, put them in the 'options' array, not in "
|
||||
"the question string. Do NOT write 'OPTIONS: [...]', "
|
||||
"'_options: [...]', or any inline list inside 'question'.\n"
|
||||
"- The question text must read as a single clean prompt with "
|
||||
"no markup. Example: 'What would you like to do?' — not "
|
||||
"'What would you like to do?</question>'.\n\n"
|
||||
"USAGE:\n"
|
||||
"Always include 2-3 predefined options. The UI automatically "
|
||||
"appends an 'Other' free-text input after your options, so NEVER "
|
||||
"include catch-all options like 'Custom idea', 'Something else', "
|
||||
@@ -39,11 +128,14 @@ def build_ask_user_tool() -> Tool:
|
||||
"free-text input. "
|
||||
"The ONLY exception: omit options when the question demands a "
|
||||
"free-form answer the user must type out (e.g. 'Describe your "
|
||||
"agent idea', 'Paste the error message'). "
|
||||
"agent idea', 'Paste the error message').\n\n"
|
||||
"CORRECT EXAMPLE:\n"
|
||||
'{"question": "What would you like to do?", "options": '
|
||||
'["Build a new agent", "Modify existing agent", "Run tests"]} '
|
||||
"Free-form example: "
|
||||
'{"question": "Describe the agent you want to build."}'
|
||||
'["Build a new agent", "Modify existing agent", "Run tests"]}\n\n'
|
||||
"FREE-FORM EXAMPLE:\n"
|
||||
'{"question": "Describe the agent you want to build."}\n\n'
|
||||
"WRONG (do NOT do this — buttons will not render):\n"
|
||||
'{"question": "What now?</question>\\n_OPTIONS: [\\"A\\", \\"B\\"]"}'
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
@@ -205,117 +297,93 @@ def build_escalate_tool() -> Tool:
|
||||
)
|
||||
|
||||
|
||||
def build_delegate_tool(sub_agents: list[str], node_registry: dict[str, Any]) -> Tool | None:
|
||||
"""Build the synthetic delegate_to_sub_agent tool for subagent invocation.
|
||||
|
||||
Args:
|
||||
sub_agents: List of node IDs that can be invoked as subagents.
|
||||
node_registry: Map of node_id -> NodeSpec for looking up subagent descriptions.
|
||||
|
||||
Returns:
|
||||
Tool definition if sub_agents is non-empty, None otherwise.
|
||||
"""
|
||||
if not sub_agents:
|
||||
return None
|
||||
|
||||
agent_descriptions = []
|
||||
for agent_id in sub_agents:
|
||||
spec = node_registry.get(agent_id)
|
||||
if spec:
|
||||
desc = getattr(spec, "description", "(no description)")
|
||||
agent_descriptions.append(f"- {agent_id}: {desc}")
|
||||
else:
|
||||
agent_descriptions.append(f"- {agent_id}: (not found in registry)")
|
||||
|
||||
return Tool(
|
||||
name="delegate_to_sub_agent",
|
||||
description=(
|
||||
"Delegate a task to a specialized sub-agent. The sub-agent runs "
|
||||
"autonomously with read-only access to current memory and returns "
|
||||
"its result. Use this to parallelize work or leverage specialized capabilities.\n\n"
|
||||
"Available sub-agents:\n" + "\n".join(agent_descriptions)
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string",
|
||||
"description": f"The sub-agent to invoke. Must be one of: {sub_agents}",
|
||||
"enum": sub_agents,
|
||||
},
|
||||
"task": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"The task description for the sub-agent to execute. "
|
||||
"Be specific about what you want the sub-agent to do and "
|
||||
"what information to return."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["agent_id", "task"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def build_report_to_parent_tool() -> Tool:
|
||||
"""Build the synthetic report_to_parent tool for sub-agent progress reports.
|
||||
"""Build the synthetic ``report_to_parent`` tool.
|
||||
|
||||
Sub-agents call this to send one-way progress updates, partial findings,
|
||||
or status reports to the parent node (and external observers via event bus)
|
||||
without blocking execution.
|
||||
Parallel workers (those spawned by the overseer via
|
||||
``run_parallel_workers``) call this to send a structured report back
|
||||
to the overseer queen when they have finished their task. Calling
|
||||
``report_to_parent`` terminates the worker's loop cleanly -- do not
|
||||
call other tools after it.
|
||||
|
||||
When ``wait_for_response`` is True, the sub-agent blocks until the parent
|
||||
relays the user's response — used for escalation (e.g. login pages, CAPTCHAs).
|
||||
|
||||
When ``mark_complete`` is True, the sub-agent terminates immediately after
|
||||
sending the report — no need to call set_output for each output key.
|
||||
The overseer receives these as ``SUBAGENT_REPORT`` events and
|
||||
aggregates them into a single summary for the user.
|
||||
"""
|
||||
return Tool(
|
||||
name="report_to_parent",
|
||||
description=(
|
||||
"Send a report to the parent agent. By default this is fire-and-forget: "
|
||||
"the parent receives the report but does not respond. "
|
||||
"Set wait_for_response=true to BLOCK until the user replies — use this "
|
||||
"when you need human intervention (e.g. login pages, CAPTCHAs, "
|
||||
"authentication walls). The user's response is returned as the tool result. "
|
||||
"Set mark_complete=true to finish your task and terminate immediately "
|
||||
"after sending the report — use this when your findings are in the "
|
||||
"message/data fields and you don't need to call set_output."
|
||||
"Send a structured report back to the parent overseer and "
|
||||
"terminate. Call this when you have finished your task "
|
||||
"(success, partial, or failed) or cannot make further "
|
||||
"progress. Your loop ends after this call -- do not call any "
|
||||
"other tool afterwards. The overseer reads the summary + "
|
||||
"data fields and aggregates them into a user-facing response."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "A human-readable status or progress message.",
|
||||
"enum": ["success", "partial", "failed"],
|
||||
"description": (
|
||||
"Overall outcome. 'success' = task complete. "
|
||||
"'partial' = some progress but incomplete. "
|
||||
"'failed' = could not make progress."
|
||||
),
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"One-paragraph narrative for the overseer. What "
|
||||
"you did, what you found, and any notable issues."
|
||||
),
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": "Optional structured data to include with the report.",
|
||||
},
|
||||
"wait_for_response": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"If true, block execution until the user responds. "
|
||||
"Use for escalation scenarios requiring human intervention."
|
||||
"Optional structured payload (rows fetched, IDs "
|
||||
"processed, files written, etc.) that the "
|
||||
"overseer can merge into its final summary."
|
||||
),
|
||||
"default": False,
|
||||
},
|
||||
"mark_complete": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"If true, terminate the sub-agent immediately after sending "
|
||||
"this report. The report message and data are delivered to the "
|
||||
"parent as the final result. No set_output calls are needed."
|
||||
),
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
"required": ["message"],
|
||||
"required": ["status", "summary"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def handle_report_to_parent(tool_input: dict[str, Any]) -> ToolResult:
|
||||
"""Normalise + validate a ``report_to_parent`` tool call.
|
||||
|
||||
Returns a ``ToolResult`` with the acknowledgement text the LLM sees;
|
||||
the side effects (record on Worker, emit SUBAGENT_REPORT, terminate
|
||||
loop) are performed by ``AgentLoop`` after this helper returns.
|
||||
"""
|
||||
status = str(tool_input.get("status", "success")).strip().lower()
|
||||
if status not in ("success", "partial", "failed"):
|
||||
status = "success"
|
||||
summary = str(tool_input.get("summary", "")).strip()
|
||||
if not summary:
|
||||
summary = f"(worker returned {status} with no summary)"
|
||||
data = tool_input.get("data") or {}
|
||||
if not isinstance(data, dict):
|
||||
data = {"value": data}
|
||||
# Store the normalised payload back on the input dict so the caller
|
||||
# can pick it up without re-parsing.
|
||||
tool_input["_normalised"] = {
|
||||
"status": status,
|
||||
"summary": summary,
|
||||
"data": data,
|
||||
}
|
||||
return ToolResult(
|
||||
tool_use_id=tool_input.get("tool_use_id", ""),
|
||||
content=(
|
||||
f"Report delivered to overseer (status={status}). "
|
||||
f"This worker will terminate now."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def handle_set_output(
|
||||
tool_input: dict[str, Any],
|
||||
output_keys: list[str] | None,
|
||||
+20
-4
@@ -222,7 +222,7 @@ def truncate_tool_result(
|
||||
- Small results (≤ limit): full content kept + file annotation
|
||||
- Large results (> limit): preview + file reference
|
||||
- Errors: pass through unchanged
|
||||
- read_file/load_data results: truncate with pagination hint (no re-spill)
|
||||
- read_file results: truncate with pagination hint (no re-spill)
|
||||
"""
|
||||
limit = max_tool_result_chars
|
||||
|
||||
@@ -230,9 +230,9 @@ def truncate_tool_result(
|
||||
if result.is_error:
|
||||
return result
|
||||
|
||||
# read_file/load_data reads FROM spilled files — never re-spill (circular).
|
||||
# read_file reads FROM spilled files — never re-spill (circular).
|
||||
# Just truncate with a pagination hint if the result is too large.
|
||||
if tool_name in ("load_data", "read_file"):
|
||||
if tool_name == "read_file":
|
||||
if limit <= 0 or len(result.content) <= limit:
|
||||
return result # Small result — pass through as-is
|
||||
# Large result — truncate with smart preview
|
||||
@@ -423,7 +423,7 @@ async def execute_tool(
|
||||
)
|
||||
|
||||
skill_dirs = skill_dirs or []
|
||||
skill_read_tools = {"view_file", "load_data", "read_file"}
|
||||
skill_read_tools = {"view_file", "read_file"}
|
||||
if tc.tool_name in skill_read_tools and skill_dirs:
|
||||
raw_path = tc.tool_input.get("path", "")
|
||||
if raw_path:
|
||||
@@ -467,6 +467,22 @@ async def execute_tool(
|
||||
result = await _run()
|
||||
except TimeoutError:
|
||||
logger.warning("Tool '%s' timed out after %.0fs", tc.tool_name, timeout)
|
||||
# asyncio.wait_for cancels the awaiting coroutine, but the sync
|
||||
# executor running inside run_in_executor keeps going — and so
|
||||
# does any MCP subprocess it is blocked on. Reach through to the
|
||||
# owning MCPClient and force-disconnect it so the subprocess is
|
||||
# torn down. Next call_tool triggers a reconnect. Without this
|
||||
# the executor thread and MCP child leak on every timeout.
|
||||
kill_for_tool = getattr(tool_executor, "kill_for_tool", None)
|
||||
if callable(kill_for_tool):
|
||||
try:
|
||||
await asyncio.to_thread(kill_for_tool, tc.tool_name)
|
||||
except Exception as exc: # defensive — never let cleanup crash the loop
|
||||
logger.warning(
|
||||
"kill_for_tool('%s') raised during timeout handling: %s",
|
||||
tc.tool_name,
|
||||
exc,
|
||||
)
|
||||
return ToolResult(
|
||||
tool_use_id=tc.tool_use_id,
|
||||
content=(
|
||||
+31
-4
@@ -9,10 +9,8 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, Protocol, runtime_checkable
|
||||
|
||||
from framework.graph.conversation import (
|
||||
from framework.agent_loop.conversation import (
|
||||
ConversationStore,
|
||||
get_run_cursor,
|
||||
update_run_cursor,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -56,6 +54,17 @@ class LoopConfig:
|
||||
stall_detection_threshold: int = 3
|
||||
stall_similarity_threshold: float = 0.85
|
||||
max_context_tokens: int = 32_000
|
||||
# Headroom reserved for the NEXT turn's input + output so that
|
||||
# proactive compaction always finishes before the hard context limit
|
||||
# is hit mid-stream. Scaled to match Claude Code's 13k-buffer-on-
|
||||
# 200k-window ratio (~6.5%) applied to hive's default 32k window,
|
||||
# with extra margin because hive's token estimator is char-based
|
||||
# and less tight than Anthropic's own counting. Override via
|
||||
# LoopConfig for larger windows.
|
||||
compaction_buffer_tokens: int = 8_000
|
||||
# Warning is emitted one buffer earlier so the user/telemetry gets
|
||||
# a "we're close" signal without triggering a compaction pass.
|
||||
compaction_warning_buffer_tokens: int = 12_000
|
||||
store_prefix: str = ""
|
||||
|
||||
# Overflow margin for max_tool_calls_per_turn. Tool calls are only
|
||||
@@ -70,9 +79,16 @@ class LoopConfig:
|
||||
max_output_value_chars: int = 2_000
|
||||
|
||||
# Stream retry.
|
||||
max_stream_retries: int = 3
|
||||
max_stream_retries: int = 5
|
||||
stream_retry_backoff_base: float = 2.0
|
||||
stream_retry_max_delay: float = 60.0
|
||||
# Persistent retry for capacity-class errors (429, 529, overloaded).
|
||||
# Unlike the bounded retry above, these keep trying until the wall-clock
|
||||
# budget below is exhausted — modelled after claude-code's withRetry.
|
||||
# The loop still publishes a retry event each attempt so the UI can
|
||||
# see progress. Set to 0 to disable and fall back to bounded retry.
|
||||
capacity_retry_max_seconds: float = 600.0
|
||||
capacity_retry_max_delay: float = 60.0
|
||||
|
||||
# Tool doom loop detection.
|
||||
tool_doom_loop_threshold: int = 3
|
||||
@@ -82,10 +98,21 @@ class LoopConfig:
|
||||
# Worker auto-escalation: text-only turns before escalating to queen.
|
||||
worker_escalation_grace_turns: int = 1
|
||||
tool_doom_loop_enabled: bool = True
|
||||
# Silent worker: consecutive tool-only turns (no user-facing text)
|
||||
# before injecting a nudge to communicate progress.
|
||||
silent_tool_streak_threshold: int = 5
|
||||
|
||||
# Per-tool-call timeout.
|
||||
tool_call_timeout_seconds: float = 60.0
|
||||
|
||||
# LLM stream inactivity watchdog. If no stream event (delta, tool call,
|
||||
# finish) arrives within this many seconds, the stream task is cancelled
|
||||
# and a transient error is raised so the retry loop can back off and
|
||||
# reconnect. Prevents agents from hanging forever on a silently dead
|
||||
# HTTP connection (no provider heartbeat, no exception, just silence).
|
||||
# Set to 0 to disable.
|
||||
llm_stream_inactivity_timeout_seconds: float = 120.0
|
||||
|
||||
# Subagent delegation timeout (wall-clock max).
|
||||
subagent_timeout_seconds: float = 3600.0
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Prompt composition for agent loops.
|
||||
|
||||
Builds canonical system prompts from AgentContext fields.
|
||||
Extracted from the former orchestrator/prompting module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PromptSpec:
|
||||
identity_prompt: str = ""
|
||||
focus_prompt: str = ""
|
||||
narrative: str = ""
|
||||
accounts_prompt: str = ""
|
||||
skills_catalog_prompt: str = ""
|
||||
protocols_prompt: str = ""
|
||||
memory_prompt: str = ""
|
||||
agent_type: str = "event_loop"
|
||||
output_keys: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def stamp_prompt_datetime(prompt: str) -> str:
|
||||
local = datetime.now().astimezone()
|
||||
stamp = f"Current date and time: {local.strftime('%Y-%m-%d %H:%M %Z (UTC%z)')}"
|
||||
return f"{prompt}\n\n{stamp}" if prompt else stamp
|
||||
|
||||
|
||||
def build_prompt_spec(
|
||||
ctx: Any,
|
||||
*,
|
||||
focus_prompt: str | None = None,
|
||||
narrative: str | None = None,
|
||||
memory_prompt: str | None = None,
|
||||
) -> PromptSpec:
|
||||
resolved_memory = memory_prompt
|
||||
if resolved_memory is None:
|
||||
resolved_memory = getattr(ctx, "memory_prompt", "") or ""
|
||||
dynamic = getattr(ctx, "dynamic_memory_provider", None)
|
||||
if dynamic is not None:
|
||||
try:
|
||||
resolved_memory = dynamic() or ""
|
||||
except Exception:
|
||||
resolved_memory = getattr(ctx, "memory_prompt", "") or ""
|
||||
return PromptSpec(
|
||||
identity_prompt=ctx.identity_prompt or "",
|
||||
focus_prompt=focus_prompt
|
||||
if focus_prompt is not None
|
||||
else (ctx.agent_spec.system_prompt or ""),
|
||||
narrative=narrative if narrative is not None else (ctx.narrative or ""),
|
||||
accounts_prompt=ctx.accounts_prompt or "",
|
||||
skills_catalog_prompt=ctx.skills_catalog_prompt or "",
|
||||
protocols_prompt=ctx.protocols_prompt or "",
|
||||
memory_prompt=resolved_memory,
|
||||
agent_type=ctx.agent_spec.agent_type,
|
||||
output_keys=tuple(ctx.agent_spec.output_keys or ()),
|
||||
)
|
||||
|
||||
|
||||
def build_system_prompt(spec: PromptSpec) -> str:
|
||||
parts: list[str] = []
|
||||
if spec.identity_prompt:
|
||||
parts.append(spec.identity_prompt)
|
||||
if spec.accounts_prompt:
|
||||
parts.append(f"\n{spec.accounts_prompt}")
|
||||
if spec.skills_catalog_prompt:
|
||||
parts.append(f"\n{spec.skills_catalog_prompt}")
|
||||
if spec.protocols_prompt:
|
||||
parts.append(f"\n{spec.protocols_prompt}")
|
||||
if spec.memory_prompt:
|
||||
parts.append(f"\n{spec.memory_prompt}")
|
||||
if spec.focus_prompt:
|
||||
parts.append(f"\n{spec.focus_prompt}")
|
||||
if spec.narrative:
|
||||
parts.append(f"\n{spec.narrative}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def build_system_prompt_for_context(
|
||||
ctx: Any,
|
||||
*,
|
||||
focus_prompt: str | None = None,
|
||||
narrative: str | None = None,
|
||||
memory_prompt: str | None = None,
|
||||
) -> str:
|
||||
spec = build_prompt_spec(
|
||||
ctx, focus_prompt=focus_prompt, narrative=narrative, memory_prompt=memory_prompt
|
||||
)
|
||||
return build_system_prompt(spec)
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Core types for the agent loop — the execution primitive of the colony.
|
||||
|
||||
AgentSpec: Declarative definition of what an agent does.
|
||||
AgentContext: Everything an agent loop needs to execute.
|
||||
AgentResult: What comes out of an agent loop execution.
|
||||
AgentProtocol: Interface that all agent implementations must satisfy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from framework.llm.provider import LLMProvider, Tool
|
||||
from framework.tracker.decision_tracker import DecisionTracker
|
||||
|
||||
|
||||
class AgentSpec(BaseModel):
|
||||
"""Declarative definition of an agent's capabilities and configuration.
|
||||
|
||||
This is the blueprint from which AgentLoop instances are created.
|
||||
Workers in a colony are exact copies of the queen's AgentSpec.
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
|
||||
agent_type: str = Field(
|
||||
default="event_loop",
|
||||
description="Type: 'event_loop' (recommended), 'gcu' (browser automation).",
|
||||
)
|
||||
|
||||
input_keys: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Keys this agent reads from input data",
|
||||
)
|
||||
output_keys: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Keys this agent produces as output",
|
||||
)
|
||||
nullable_output_keys: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Output keys that can be None without triggering validation errors",
|
||||
)
|
||||
|
||||
input_schema: dict[str, dict] = Field(
|
||||
default_factory=dict,
|
||||
description="Optional schema for input validation.",
|
||||
)
|
||||
output_schema: dict[str, dict] = Field(
|
||||
default_factory=dict,
|
||||
description="Optional schema for output validation.",
|
||||
)
|
||||
|
||||
system_prompt: str | None = Field(default=None, description="System prompt for the LLM")
|
||||
tools: list[str] = Field(default_factory=list, description="Tool names this agent can use")
|
||||
tool_access_policy: str = Field(
|
||||
default="explicit",
|
||||
description=(
|
||||
"'all' = all tools from registry, "
|
||||
"'explicit' = only tools listed in `tools` (default), "
|
||||
"'none' = no tools at all."
|
||||
),
|
||||
)
|
||||
model: str | None = Field(default=None, description="Specific model override")
|
||||
|
||||
function: str | None = Field(default=None, description="Function name or path")
|
||||
routes: dict[str, str] = Field(default_factory=dict, description="Condition -> target mapping")
|
||||
|
||||
max_retries: int = Field(default=3)
|
||||
retry_on: list[str] = Field(default_factory=list, description="Error types to retry on")
|
||||
|
||||
max_visits: int = Field(
|
||||
default=0,
|
||||
description=(
|
||||
"Max times this agent executes in one colony run. "
|
||||
"0 = unlimited. Set >1 for one-shot agents."
|
||||
),
|
||||
)
|
||||
|
||||
output_model: type[BaseModel] | None = Field(
|
||||
default=None,
|
||||
description="Optional Pydantic model for validating LLM output.",
|
||||
)
|
||||
max_validation_retries: int = Field(
|
||||
default=2,
|
||||
description="Maximum retries when Pydantic validation fails",
|
||||
)
|
||||
|
||||
client_facing: bool = Field(
|
||||
default=False,
|
||||
description="Deprecated — the queen is intrinsically interactive.",
|
||||
)
|
||||
|
||||
success_criteria: str | None = Field(
|
||||
default=None,
|
||||
description="Natural-language criteria for phase completion.",
|
||||
)
|
||||
|
||||
skip_judge: bool = Field(
|
||||
default=False,
|
||||
description="When True, the implicit judge is bypassed entirely.",
|
||||
)
|
||||
|
||||
model_config = {"extra": "allow", "arbitrary_types_allowed": True}
|
||||
|
||||
def is_queen(self) -> bool:
|
||||
return self.id == "queen"
|
||||
|
||||
def supports_direct_user_io(self) -> bool:
|
||||
return self.is_queen()
|
||||
|
||||
|
||||
def deprecated_client_facing_warning(spec: AgentSpec) -> str | None:
|
||||
if spec.client_facing and not spec.is_queen():
|
||||
return (
|
||||
f"Agent '{spec.id}' sets deprecated client_facing=True. "
|
||||
"Non-queen direct human I/O is no longer supported; route worker "
|
||||
"questions and approvals through queen escalation instead."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def warn_if_deprecated_client_facing(spec: AgentSpec) -> None:
|
||||
import logging
|
||||
|
||||
warning = deprecated_client_facing_warning(spec)
|
||||
if warning:
|
||||
logging.getLogger(__name__).warning(warning)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentContext:
|
||||
"""Everything an agent loop needs to execute.
|
||||
|
||||
Passed to every agent implementation and provides:
|
||||
- Runtime (for decision logging)
|
||||
- LLM access
|
||||
- Tools
|
||||
- Goal context
|
||||
- Execution metadata
|
||||
"""
|
||||
|
||||
runtime: DecisionTracker
|
||||
|
||||
agent_id: str
|
||||
agent_spec: AgentSpec
|
||||
|
||||
input_data: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
llm: LLMProvider | None = None
|
||||
available_tools: list[Tool] = field(default_factory=list)
|
||||
|
||||
goal_context: str = ""
|
||||
goal: Any = None
|
||||
|
||||
max_tokens: int = 4096
|
||||
|
||||
attempt: int = 1
|
||||
max_attempts: int = 3
|
||||
|
||||
runtime_logger: Any = None
|
||||
pause_event: Any = None
|
||||
|
||||
accounts_prompt: str = ""
|
||||
|
||||
identity_prompt: str = ""
|
||||
narrative: str = ""
|
||||
memory_prompt: str = ""
|
||||
|
||||
event_triggered: bool = False
|
||||
|
||||
execution_id: str = ""
|
||||
run_id: str = ""
|
||||
|
||||
@property
|
||||
def effective_run_id(self) -> str | None:
|
||||
return self.run_id or None
|
||||
|
||||
stream_id: str = ""
|
||||
|
||||
dynamic_tools_provider: Any = None
|
||||
dynamic_prompt_provider: Any = None
|
||||
dynamic_memory_provider: Any = None
|
||||
|
||||
skills_catalog_prompt: str = ""
|
||||
protocols_prompt: str = ""
|
||||
skill_dirs: list[str] = field(default_factory=list)
|
||||
default_skill_batch_nudge: str | None = None
|
||||
default_skill_warn_ratio: float | None = None
|
||||
|
||||
iteration_metadata_provider: Any = None
|
||||
|
||||
@property
|
||||
def is_queen_stream(self) -> bool:
|
||||
return self.stream_id == "queen" or self.agent_spec.is_queen()
|
||||
|
||||
@property
|
||||
def emits_client_io(self) -> bool:
|
||||
return self.is_queen_stream
|
||||
|
||||
@property
|
||||
def supports_direct_user_io(self) -> bool:
|
||||
return self.is_queen_stream and not self.event_triggered
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentResult:
|
||||
"""Output of an agent loop execution."""
|
||||
|
||||
success: bool
|
||||
output: dict[str, Any] = field(default_factory=dict)
|
||||
error: str | None = None
|
||||
|
||||
next_agent: str | None = None
|
||||
route_reason: str | None = None
|
||||
|
||||
tokens_used: int = 0
|
||||
latency_ms: int = 0
|
||||
|
||||
validation_errors: list[str] = field(default_factory=list)
|
||||
|
||||
conversation: Any = None
|
||||
|
||||
# Machine-readable reason the loop stopped (see LoopExitReason in
|
||||
# agent_loop/internals/types.py). "?" means the loop didn't set one,
|
||||
# which should itself be treated as a diagnostic.
|
||||
exit_reason: str = "?"
|
||||
# Counters for reliability events surfaced during this execution.
|
||||
# Populated from the loop's TaskRegistry-style counters at return
|
||||
# time so callers can spot recurring failure modes without tailing
|
||||
# logs. Keys are stable strings; missing keys mean "zero".
|
||||
reliability_stats: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
def to_summary(self, spec: Any = None) -> str:
|
||||
if not self.success:
|
||||
return f"Failed: {self.error}"
|
||||
|
||||
if not self.output:
|
||||
return "Completed (no output)"
|
||||
|
||||
parts = [f"Completed with {len(self.output)} outputs:"]
|
||||
for key, value in list(self.output.items())[:5]:
|
||||
value_str = str(value)[:100]
|
||||
if len(str(value)) > 100:
|
||||
value_str += "..."
|
||||
parts.append(f" - {key}: {value_str}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
class AgentProtocol(ABC):
|
||||
"""Interface all agent implementations must satisfy."""
|
||||
|
||||
@abstractmethod
|
||||
async def execute(self, ctx: AgentContext) -> AgentResult:
|
||||
pass
|
||||
|
||||
def validate_input(self, ctx: AgentContext) -> list[str]:
|
||||
errors = []
|
||||
for key in ctx.agent_spec.input_keys:
|
||||
if key not in ctx.input_data:
|
||||
errors.append(f"Missing required input: {key}")
|
||||
return errors
|
||||
@@ -8,6 +8,10 @@ FRAMEWORK_AGENTS_DIR = Path(__file__).parent
|
||||
def list_framework_agents() -> list[Path]:
|
||||
"""List all framework agent directories."""
|
||||
return sorted(
|
||||
[p for p in FRAMEWORK_AGENTS_DIR.iterdir() if p.is_dir() and (p / "agent.py").exists()],
|
||||
[
|
||||
p
|
||||
for p in FRAMEWORK_AGENTS_DIR.iterdir()
|
||||
if p.is_dir() and ((p / "agent.json").exists() or (p / "agent.py").exists())
|
||||
],
|
||||
key=lambda p: p.name,
|
||||
)
|
||||
|
||||
@@ -21,15 +21,15 @@ from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from framework.config import get_max_context_tokens
|
||||
from framework.graph import Goal, NodeSpec, SuccessCriterion
|
||||
from framework.graph.checkpoint_config import CheckpointConfig
|
||||
from framework.graph.edge import GraphSpec
|
||||
from framework.graph.executor import ExecutionResult
|
||||
from framework.host.agent_host import AgentHost
|
||||
from framework.host.execution_manager import EntryPointSpec
|
||||
from framework.llm import LiteLLMProvider
|
||||
from framework.runner.mcp_registry import MCPRegistry
|
||||
from framework.runner.tool_registry import ToolRegistry
|
||||
from framework.runtime.agent_runtime import AgentRuntime, create_agent_runtime
|
||||
from framework.runtime.execution_stream import EntryPointSpec
|
||||
from framework.loader.mcp_registry import MCPRegistry
|
||||
from framework.loader.tool_registry import ToolRegistry
|
||||
from framework.orchestrator import Goal, NodeSpec, SuccessCriterion
|
||||
from framework.orchestrator.checkpoint_config import CheckpointConfig
|
||||
from framework.orchestrator.edge import GraphSpec
|
||||
from framework.orchestrator.orchestrator import ExecutionResult
|
||||
|
||||
from .config import default_config
|
||||
from .nodes import build_tester_node
|
||||
@@ -37,7 +37,7 @@ from .nodes import build_tester_node
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from framework.runner import AgentRunner
|
||||
from framework.loader import AgentLoader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -233,7 +233,7 @@ requires_account_selection = True
|
||||
"""Signal TUI to show account picker before starting the agent."""
|
||||
|
||||
|
||||
def configure_for_account(runner: AgentRunner, account: dict) -> None:
|
||||
def configure_for_account(runner: AgentLoader, account: dict) -> None:
|
||||
"""Scope the tester node's tools to the selected provider.
|
||||
|
||||
Handles both Aden accounts (account= routing) and local accounts
|
||||
@@ -325,7 +325,7 @@ def _activate_local_account(credential_id: str, alias: str) -> None:
|
||||
|
||||
|
||||
def _configure_aden_node(
|
||||
runner: AgentRunner,
|
||||
runner: AgentLoader,
|
||||
provider: str,
|
||||
alias: str,
|
||||
detail: str,
|
||||
@@ -368,7 +368,7 @@ or any other identifier — always use the alias exactly as shown.
|
||||
|
||||
|
||||
def _configure_local_node(
|
||||
runner: AgentRunner,
|
||||
runner: AgentLoader,
|
||||
provider: str,
|
||||
alias: str,
|
||||
identity: dict,
|
||||
@@ -497,7 +497,7 @@ class CredentialTesterAgent:
|
||||
def __init__(self, config=None):
|
||||
self.config = config or default_config
|
||||
self._selected_account: dict | None = None
|
||||
self._agent_runtime: AgentRuntime | None = None
|
||||
self._agent_runtime: AgentHost | None = None
|
||||
self._tool_registry: ToolRegistry | None = None
|
||||
self._storage_path: Path | None = None
|
||||
|
||||
@@ -613,7 +613,7 @@ class CredentialTesterAgent:
|
||||
|
||||
graph = self._build_graph()
|
||||
|
||||
self._agent_runtime = create_agent_runtime(
|
||||
self._agent_runtime = AgentHost(
|
||||
graph=graph,
|
||||
goal=goal,
|
||||
storage_path=self._storage_path,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"hive-tools": {
|
||||
"hive_tools": {
|
||||
"transport": "stdio",
|
||||
"command": "uv",
|
||||
"args": ["run", "python", "mcp_server.py", "--stdio"],
|
||||
"cwd": "../../../../tools",
|
||||
"description": "Hive tools MCP server with provider-specific tools"
|
||||
"description": "hive_tools MCP server with provider-specific tools"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Node definitions for Credential Tester agent."""
|
||||
|
||||
from framework.graph import NodeSpec
|
||||
from framework.orchestrator import NodeSpec
|
||||
|
||||
|
||||
def build_tester_node(
|
||||
|
||||
@@ -7,6 +7,32 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkerEntry:
|
||||
"""A single worker within a colony."""
|
||||
|
||||
name: str
|
||||
config_path: Path
|
||||
description: str = ""
|
||||
tool_count: int = 0
|
||||
task: str = ""
|
||||
spawned_at: str = ""
|
||||
queen_name: str = ""
|
||||
colony_name: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"name": self.name,
|
||||
"config_path": str(self.config_path),
|
||||
"description": self.description,
|
||||
"tool_count": self.tool_count,
|
||||
"task": self.task,
|
||||
"spawned_at": self.spawned_at,
|
||||
"queen_name": self.queen_name,
|
||||
"colony_name": self.colony_name,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentEntry:
|
||||
"""Lightweight agent metadata for the picker / API discover endpoint."""
|
||||
@@ -21,14 +47,15 @@ class AgentEntry:
|
||||
tool_count: int = 0
|
||||
tags: list[str] = field(default_factory=list)
|
||||
last_active: str | None = None
|
||||
workers: list[WorkerEntry] = field(default_factory=list)
|
||||
|
||||
|
||||
def _get_last_active(agent_path: Path) -> str | None:
|
||||
"""Return the most recent updated_at timestamp across all sessions.
|
||||
|
||||
Checks both worker sessions (``~/.hive/agents/{name}/sessions/``) and
|
||||
queen sessions (``~/.hive/queen/session/``) whose ``meta.json`` references
|
||||
the same *agent_path*.
|
||||
queen sessions (``~/.hive/agents/queens/default/sessions/``) whose
|
||||
``meta.json`` references the same *agent_path*.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
@@ -52,26 +79,33 @@ def _get_last_active(agent_path: Path) -> str | None:
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# 2. Queen sessions
|
||||
queen_sessions_dir = Path.home() / ".hive" / "queen" / "session"
|
||||
if queen_sessions_dir.exists():
|
||||
# 2. Queen sessions (scan all queen identity directories)
|
||||
from framework.config import QUEENS_DIR
|
||||
|
||||
if QUEENS_DIR.exists():
|
||||
resolved = agent_path.resolve()
|
||||
for d in queen_sessions_dir.iterdir():
|
||||
if not d.is_dir():
|
||||
for queen_dir in QUEENS_DIR.iterdir():
|
||||
if not queen_dir.is_dir():
|
||||
continue
|
||||
meta_file = d / "meta.json"
|
||||
if not meta_file.exists():
|
||||
sessions_dir = queen_dir / "sessions"
|
||||
if not sessions_dir.exists():
|
||||
continue
|
||||
try:
|
||||
meta = json.loads(meta_file.read_text(encoding="utf-8"))
|
||||
stored = meta.get("agent_path")
|
||||
if not stored or Path(stored).resolve() != resolved:
|
||||
for d in sessions_dir.iterdir():
|
||||
if not d.is_dir():
|
||||
continue
|
||||
meta_file = d / "meta.json"
|
||||
if not meta_file.exists():
|
||||
continue
|
||||
try:
|
||||
meta = json.loads(meta_file.read_text(encoding="utf-8"))
|
||||
stored = meta.get("agent_path")
|
||||
if not stored or Path(stored).resolve() != resolved:
|
||||
continue
|
||||
ts = datetime.fromtimestamp(d.stat().st_mtime).isoformat()
|
||||
if latest is None or ts > latest:
|
||||
latest = ts
|
||||
except Exception:
|
||||
continue
|
||||
ts = datetime.fromtimestamp(d.stat().st_mtime).isoformat()
|
||||
if latest is None or ts > latest:
|
||||
latest = ts
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return latest
|
||||
|
||||
@@ -109,85 +143,118 @@ def _count_runs(agent_name: str) -> int:
|
||||
return len(run_ids)
|
||||
|
||||
|
||||
_EXCLUDED_JSON_STEMS = {"agent", "flowchart", "triggers", "configuration", "metadata"}
|
||||
|
||||
|
||||
def _is_colony_dir(path: Path) -> bool:
|
||||
"""Check if a directory is a colony with worker config files."""
|
||||
if not path.is_dir():
|
||||
return False
|
||||
return any(
|
||||
f.suffix == ".json"
|
||||
and f.stem not in _EXCLUDED_JSON_STEMS
|
||||
for f in path.iterdir()
|
||||
if f.is_file()
|
||||
)
|
||||
|
||||
|
||||
def _find_worker_configs(colony_dir: Path) -> list[Path]:
|
||||
"""Find all worker config JSON files in a colony directory."""
|
||||
return sorted(
|
||||
p
|
||||
for p in colony_dir.iterdir()
|
||||
if p.is_file()
|
||||
and p.suffix == ".json"
|
||||
and p.stem not in _EXCLUDED_JSON_STEMS
|
||||
)
|
||||
|
||||
|
||||
def _extract_agent_stats(agent_path: Path) -> tuple[int, int, list[str]]:
|
||||
"""Extract node count, tool count, and tags from an agent directory.
|
||||
"""Extract worker count, tool count, and tags from a colony directory."""
|
||||
tool_count, tags = 0, []
|
||||
|
||||
Prefers agent.py (AST-parsed) over agent.json for node/tool counts
|
||||
since agent.json may be stale. Tags are only available from agent.json.
|
||||
"""
|
||||
import ast
|
||||
worker_configs = _find_worker_configs(agent_path)
|
||||
if worker_configs:
|
||||
all_tools: set[str] = set()
|
||||
for wc_path in worker_configs:
|
||||
try:
|
||||
data = json.loads(wc_path.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict):
|
||||
tools = data.get("tools", [])
|
||||
if isinstance(tools, list):
|
||||
all_tools.update(tools)
|
||||
except Exception:
|
||||
pass
|
||||
return len(worker_configs), len(all_tools), tags
|
||||
|
||||
node_count, tool_count, tags = 0, 0, []
|
||||
|
||||
agent_py = agent_path / "agent.py"
|
||||
if agent_py.exists():
|
||||
try:
|
||||
tree = ast.parse(agent_py.read_text(encoding="utf-8"))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id == "nodes":
|
||||
if isinstance(node.value, ast.List):
|
||||
node_count = len(node.value.elts)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
agent_json = agent_path / "agent.json"
|
||||
if agent_json.exists():
|
||||
try:
|
||||
data = json.loads(agent_json.read_text(encoding="utf-8"))
|
||||
json_nodes = data.get("graph", {}).get("nodes", []) or data.get("nodes", [])
|
||||
if node_count == 0:
|
||||
node_count = len(json_nodes)
|
||||
tools: set[str] = set()
|
||||
for n in json_nodes:
|
||||
tools.update(n.get("tools", []))
|
||||
tool_count = len(tools)
|
||||
tags = data.get("agent", {}).get("tags", [])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return node_count, tool_count, tags
|
||||
return 0, 0, tags
|
||||
|
||||
|
||||
def discover_agents() -> dict[str, list[AgentEntry]]:
|
||||
"""Discover agents from all known sources grouped by category."""
|
||||
from framework.runner.cli import (
|
||||
_extract_python_agent_metadata,
|
||||
_get_framework_agents_dir,
|
||||
_is_valid_agent_dir,
|
||||
)
|
||||
from framework.config import COLONIES_DIR
|
||||
|
||||
groups: dict[str, list[AgentEntry]] = {}
|
||||
sources = [
|
||||
("Your Agents", Path("exports")),
|
||||
("Framework", _get_framework_agents_dir()),
|
||||
("Examples", Path("examples/templates")),
|
||||
("Your Agents", COLONIES_DIR),
|
||||
]
|
||||
|
||||
# Track seen agent directory names to avoid duplicates when the same
|
||||
# agent exists in both colonies/ and exports/ (colonies takes priority).
|
||||
_seen_agent_names: set[str] = set()
|
||||
|
||||
for category, base_dir in sources:
|
||||
if not base_dir.exists():
|
||||
continue
|
||||
entries: list[AgentEntry] = []
|
||||
for path in sorted(base_dir.iterdir(), key=lambda p: p.name):
|
||||
if not _is_valid_agent_dir(path):
|
||||
if not _is_colony_dir(path):
|
||||
continue
|
||||
if path.name in _seen_agent_names:
|
||||
continue
|
||||
_seen_agent_names.add(path.name)
|
||||
|
||||
name, desc = _extract_python_agent_metadata(path)
|
||||
config_fallback_name = path.name.replace("_", " ").title()
|
||||
used_config = name != config_fallback_name
|
||||
name = config_fallback_name
|
||||
desc = ""
|
||||
|
||||
node_count, tool_count, tags = _extract_agent_stats(path)
|
||||
if not used_config:
|
||||
agent_json = path / "agent.json"
|
||||
if agent_json.exists():
|
||||
try:
|
||||
data = json.loads(agent_json.read_text(encoding="utf-8"))
|
||||
meta = data.get("agent", {})
|
||||
name = meta.get("name", name)
|
||||
desc = meta.get("description", desc)
|
||||
except Exception:
|
||||
pass
|
||||
# Read colony metadata for queen provenance
|
||||
colony_queen_name = ""
|
||||
metadata_path = path / "metadata.json"
|
||||
if metadata_path.exists():
|
||||
try:
|
||||
mdata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
colony_queen_name = mdata.get("queen_name", "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
worker_entries: list[WorkerEntry] = []
|
||||
worker_configs = _find_worker_configs(path)
|
||||
for wc_path in worker_configs:
|
||||
try:
|
||||
data = json.loads(wc_path.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict):
|
||||
w = WorkerEntry(
|
||||
name=data.get("name", wc_path.stem),
|
||||
config_path=wc_path,
|
||||
description=data.get("description", ""),
|
||||
tool_count=len(data.get("tools", [])),
|
||||
task=data.get("goal", {}).get("description", ""),
|
||||
spawned_at=data.get("spawned_at", ""),
|
||||
queen_name=colony_queen_name,
|
||||
colony_name=path.name,
|
||||
)
|
||||
worker_entries.append(w)
|
||||
if not desc:
|
||||
desc = data.get("description", "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
node_count = len(worker_entries)
|
||||
all_tools: set[str] = set()
|
||||
for w in worker_entries:
|
||||
pass # tool_count already per-worker
|
||||
tool_count = max((w.tool_count for w in worker_entries), default=0)
|
||||
|
||||
entries.append(
|
||||
AgentEntry(
|
||||
@@ -199,11 +266,14 @@ def discover_agents() -> dict[str, list[AgentEntry]]:
|
||||
run_count=_count_runs(path.name),
|
||||
node_count=node_count,
|
||||
tool_count=tool_count,
|
||||
tags=tags,
|
||||
tags=[],
|
||||
last_active=_get_last_active(path),
|
||||
workers=worker_entries,
|
||||
)
|
||||
)
|
||||
if entries:
|
||||
groups[category] = entries
|
||||
existing = groups.get(category, [])
|
||||
existing.extend(entries)
|
||||
groups[category] = existing
|
||||
|
||||
return groups
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
"""
|
||||
Queen — Native agent builder for the Hive framework.
|
||||
"""Queen -- the agent builder for the Hive framework."""
|
||||
|
||||
Deeply understands the agent framework and produces complete Python packages
|
||||
with goals, nodes, edges, system prompts, MCP configuration, and tests
|
||||
from natural language specifications.
|
||||
"""
|
||||
|
||||
from .agent import queen_goal, queen_graph
|
||||
from .agent import queen_goal, queen_loop_config
|
||||
from .config import AgentMetadata, RuntimeConfig, default_config, metadata
|
||||
|
||||
__version__ = "1.0.0"
|
||||
|
||||
__all__ = [
|
||||
"queen_goal",
|
||||
"queen_graph",
|
||||
"queen_loop_config",
|
||||
"RuntimeConfig",
|
||||
"AgentMetadata",
|
||||
"default_config",
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
"""Queen graph definition."""
|
||||
"""Queen agent definition.
|
||||
|
||||
from framework.graph import Goal
|
||||
from framework.graph.edge import GraphSpec
|
||||
The queen is a single AgentLoop — no orchestrator dependency.
|
||||
Loaded by queen_orchestrator.create_queen().
|
||||
"""
|
||||
|
||||
from framework.schemas.goal import Goal
|
||||
|
||||
from .nodes import queen_node
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Queen graph — the primary persistent conversation.
|
||||
# Loaded by queen_orchestrator.create_queen(), NOT by AgentRunner.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
queen_goal = Goal(
|
||||
id="queen-manager",
|
||||
name="Queen Manager",
|
||||
@@ -20,19 +18,11 @@ queen_goal = Goal(
|
||||
constraints=[],
|
||||
)
|
||||
|
||||
queen_graph = GraphSpec(
|
||||
id="queen-graph",
|
||||
goal_id=queen_goal.id,
|
||||
version="1.0.0",
|
||||
entry_node="queen",
|
||||
entry_points={"start": "queen"},
|
||||
terminal_nodes=[],
|
||||
pause_nodes=[],
|
||||
nodes=[queen_node],
|
||||
edges=[],
|
||||
conversation_mode="continuous",
|
||||
loop_config={
|
||||
"max_iterations": 999_999,
|
||||
"max_tool_calls_per_turn": 30,
|
||||
},
|
||||
)
|
||||
# Loop config -- used by queen_orchestrator to build LoopConfig
|
||||
queen_loop_config = {
|
||||
"max_iterations": 999_999,
|
||||
"max_tool_calls_per_turn": 30,
|
||||
"max_context_tokens": 180_000,
|
||||
}
|
||||
|
||||
__all__ = ["queen_goal", "queen_loop_config", "queen_node"]
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"include": ["gcu-tools", "hive_tools"]
|
||||
}
|
||||
@@ -5,5 +5,19 @@
|
||||
"args": ["run", "python", "coder_tools_server.py", "--stdio"],
|
||||
"cwd": "../../../../tools",
|
||||
"description": "Unsandboxed file system tools for code generation and validation"
|
||||
},
|
||||
"gcu-tools": {
|
||||
"transport": "stdio",
|
||||
"command": "uv",
|
||||
"args": ["run", "python", "-m", "gcu.server", "--stdio", "--capabilities", "browser"],
|
||||
"cwd": "../../../../tools",
|
||||
"description": "Browser automation tools (Playwright-based)"
|
||||
},
|
||||
"hive_tools": {
|
||||
"transport": "stdio",
|
||||
"command": "uv",
|
||||
"args": ["run", "python", "mcp_server.py", "--stdio"],
|
||||
"cwd": "../../../../tools",
|
||||
"description": "Aden integration tools (gmail, calendar, hubspot, etc.) — gated by credentials and the verified manifest"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,138 +0,0 @@
|
||||
"""Queen thinking hook — persona + communication style classifier.
|
||||
|
||||
Fires once when the queen enters building mode at session start.
|
||||
Makes a single non-streaming LLM call (acting as an HR Director) to select
|
||||
the best-fit expert persona for the user's request AND classify the user's
|
||||
communication style, then returns a PersonaResult containing both.
|
||||
|
||||
This is designed to activate the model's latent domain expertise — a CFO
|
||||
persona on a financial question, a Lawyer on a legal question, etc. — while
|
||||
also adapting the Queen's communication approach to the individual user.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from framework.llm.provider import LLMProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_HR_SYSTEM_PROMPT = """\
|
||||
You are an expert HR Director and communication consultant at a world-class firm.
|
||||
A new request has arrived. You must:
|
||||
1. Identify which professional role best serves this request.
|
||||
2. Read the user's signals to determine HOW to communicate with them.
|
||||
|
||||
For communication style, look for:
|
||||
- Technical depth: Do they use precise terms? Do they ask "how" or "what"?
|
||||
- Pace: Short messages = fast and direct. Long explanations = exploratory.
|
||||
- Tone: Are they casual ("hey, can you...") or formal ("I need a system that...")?
|
||||
|
||||
If cross-session memory is provided, factor in what is already known about this \
|
||||
person — don't rediscover what's already understood.
|
||||
|
||||
Reply with ONLY a valid JSON object — no markdown, no prose, no explanation:
|
||||
{"role": "<job title>", "persona": "<2-3 sentence first-person identity statement>", \
|
||||
"style": "<one of: peer-technical, mentor-guiding, consultant-structured>"}
|
||||
|
||||
Rules:
|
||||
- Choose from any real professional role: CFO, CEO, CTO, Lawyer, Data Scientist,
|
||||
Product Manager, Security Engineer, DevOps Engineer, Software Architect,
|
||||
HR Director, Marketing Director, Business Analyst, UX Designer,
|
||||
Financial Analyst, Operations Director, Legal Counsel, etc.
|
||||
- The persona statement must be written in first person ("I am..." or "I have...").
|
||||
- Select the role whose domain knowledge most directly applies to solving the request.
|
||||
- If the request is clearly about coding or building software systems, pick Software Architect.
|
||||
- "Queen" is your internal alias — do not include it in the persona.
|
||||
- For style: "peer-technical" for users who demonstrate domain expertise, \
|
||||
"mentor-guiding" for users who are learning or exploring, \
|
||||
"consultant-structured" for users who want structured, accountable delivery.
|
||||
- Default to "peer-technical" if signals are ambiguous.
|
||||
"""
|
||||
|
||||
# Communication style directives injected into the Queen's system prompt.
|
||||
_STYLE_DIRECTIVES: dict[str, str] = {
|
||||
"peer-technical": (
|
||||
"## Communication Style: Peer\n\n"
|
||||
"This person is technical. Use precise language, skip high-level "
|
||||
"overviews they already know, and get into specifics quickly. "
|
||||
"When they push back on a design choice, engage with the technical "
|
||||
"argument directly."
|
||||
),
|
||||
"mentor-guiding": (
|
||||
"## Communication Style: Guide\n\n"
|
||||
"This person is learning or exploring. Explain your reasoning as you "
|
||||
"go — not patronizingly, but so they can follow the logic. When you "
|
||||
"make a design choice, briefly say why. Offer to go deeper on anything."
|
||||
),
|
||||
"consultant-structured": (
|
||||
"## Communication Style: Structured\n\n"
|
||||
"This person wants structured, accountable delivery. Lead with "
|
||||
"summaries and options. Number your proposals. Be explicit about "
|
||||
"trade-offs. Avoid open-ended questions — give them choices to react to."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PersonaResult:
|
||||
"""Result of persona + style classification."""
|
||||
|
||||
persona_prefix: str # e.g. "You are a CFO. I am a CFO with 20 years..."
|
||||
style_directive: str # e.g. "## Communication Style: Peer\n\n..."
|
||||
|
||||
|
||||
async def select_expert_persona(
|
||||
user_message: str,
|
||||
llm: LLMProvider,
|
||||
*,
|
||||
memory_context: str = "",
|
||||
) -> PersonaResult | None:
|
||||
"""Run the HR classifier and return a PersonaResult.
|
||||
|
||||
Makes a single non-streaming acomplete() call with the session LLM.
|
||||
Returns None on any failure so the queen falls back gracefully to its
|
||||
default character with no style directive.
|
||||
|
||||
Args:
|
||||
user_message: The user's opening message for the session.
|
||||
llm: The session LLM provider.
|
||||
memory_context: Optional cross-session memory to inform style classification.
|
||||
|
||||
Returns:
|
||||
A PersonaResult with persona_prefix and style_directive, or None on failure.
|
||||
"""
|
||||
if not user_message.strip():
|
||||
return None
|
||||
|
||||
prompt = user_message
|
||||
if memory_context:
|
||||
prompt = f"{user_message}\n\n{memory_context}"
|
||||
|
||||
try:
|
||||
response = await llm.acomplete(
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
system=_HR_SYSTEM_PROMPT,
|
||||
max_tokens=1024,
|
||||
json_mode=True,
|
||||
)
|
||||
raw = response.content.strip()
|
||||
parsed = json.loads(raw)
|
||||
role = parsed.get("role", "").strip()
|
||||
persona = parsed.get("persona", "").strip()
|
||||
style_key = parsed.get("style", "peer-technical").strip()
|
||||
if not role or not persona:
|
||||
logger.warning("Thinking hook: empty role/persona in response: %r", raw)
|
||||
return None
|
||||
persona_prefix = f"You are a {role}. {persona}"
|
||||
style_directive = _STYLE_DIRECTIVES.get(style_key, _STYLE_DIRECTIVES["peer-technical"])
|
||||
logger.info("Thinking hook: selected persona — %s, style — %s", role, style_key)
|
||||
return PersonaResult(persona_prefix=persona_prefix, style_directive=style_directive)
|
||||
except Exception:
|
||||
logger.warning("Thinking hook: persona classification failed", exc_info=True)
|
||||
return None
|
||||
@@ -1,420 +0,0 @@
|
||||
"""Queen global cross-session memory.
|
||||
|
||||
Three-tier memory architecture:
|
||||
~/.hive/queen/MEMORY.md — semantic (who, what, why)
|
||||
~/.hive/queen/memories/MEMORY-YYYY-MM-DD.md — episodic (daily journals)
|
||||
~/.hive/queen/session/{id}/data/adapt.md — working (session-scoped)
|
||||
|
||||
Semantic and episodic files are injected at queen session start.
|
||||
|
||||
Semantic memory (MEMORY.md) is updated automatically at session end via
|
||||
consolidate_queen_memory() — the queen never rewrites this herself.
|
||||
|
||||
Episodic memory (MEMORY-date.md) can be written by the queen during a session
|
||||
via the write_to_diary tool, and is also appended to at session end by
|
||||
consolidate_queen_memory().
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _queen_dir() -> Path:
|
||||
return Path.home() / ".hive" / "queen"
|
||||
|
||||
|
||||
def format_memory_date(d: date) -> str:
|
||||
"""Return a cross-platform long date label without a zero-padded day."""
|
||||
return f"{d.strftime('%B')} {d.day}, {d.year}"
|
||||
|
||||
|
||||
def semantic_memory_path() -> Path:
|
||||
return _queen_dir() / "MEMORY.md"
|
||||
|
||||
|
||||
def episodic_memory_path(d: date | None = None) -> Path:
|
||||
d = d or date.today()
|
||||
return _queen_dir() / "memories" / f"MEMORY-{d.strftime('%Y-%m-%d')}.md"
|
||||
|
||||
|
||||
def read_semantic_memory() -> str:
|
||||
path = semantic_memory_path()
|
||||
return path.read_text(encoding="utf-8").strip() if path.exists() else ""
|
||||
|
||||
|
||||
def read_episodic_memory(d: date | None = None) -> str:
|
||||
path = episodic_memory_path(d)
|
||||
return path.read_text(encoding="utf-8").strip() if path.exists() else ""
|
||||
|
||||
|
||||
def _find_recent_episodic(lookback: int = 7) -> tuple[date, str] | None:
|
||||
"""Find the most recent non-empty episodic memory within *lookback* days."""
|
||||
from datetime import timedelta
|
||||
|
||||
today = date.today()
|
||||
for offset in range(lookback):
|
||||
d = today - timedelta(days=offset)
|
||||
content = read_episodic_memory(d)
|
||||
if content:
|
||||
return d, content
|
||||
return None
|
||||
|
||||
|
||||
# Budget (in characters) for episodic memory in the system prompt.
|
||||
_EPISODIC_CHAR_BUDGET = 6_000
|
||||
|
||||
|
||||
def format_for_injection() -> str:
|
||||
"""Format cross-session memory for system prompt injection.
|
||||
|
||||
Returns an empty string if no meaningful content exists yet (e.g. first
|
||||
session with only the seed template).
|
||||
"""
|
||||
semantic = read_semantic_memory()
|
||||
recent = _find_recent_episodic()
|
||||
|
||||
# Suppress injection if semantic is still just the seed template
|
||||
if semantic and semantic.startswith("# My Understanding of the User\n\n*No sessions"):
|
||||
semantic = ""
|
||||
|
||||
parts: list[str] = []
|
||||
if semantic:
|
||||
parts.append(semantic)
|
||||
|
||||
if recent:
|
||||
d, content = recent
|
||||
# Trim oversized episodic entries to keep the prompt manageable
|
||||
if len(content) > _EPISODIC_CHAR_BUDGET:
|
||||
content = content[:_EPISODIC_CHAR_BUDGET] + "\n\n…(truncated)"
|
||||
today = date.today()
|
||||
if d == today:
|
||||
label = f"## Today — {format_memory_date(d)}"
|
||||
else:
|
||||
label = f"## {format_memory_date(d)}"
|
||||
parts.append(f"{label}\n\n{content}")
|
||||
|
||||
if not parts:
|
||||
return ""
|
||||
|
||||
body = "\n\n---\n\n".join(parts)
|
||||
return "--- Your Cross-Session Memory ---\n\n" + body + "\n\n--- End Cross-Session Memory ---"
|
||||
|
||||
|
||||
_SEED_TEMPLATE = """\
|
||||
# My Understanding of the User
|
||||
|
||||
*No sessions recorded yet.*
|
||||
|
||||
## Who They Are
|
||||
|
||||
## How They Communicate
|
||||
|
||||
## What They're Trying to Achieve
|
||||
|
||||
## What's Working
|
||||
|
||||
## What I've Learned
|
||||
"""
|
||||
|
||||
|
||||
def append_episodic_entry(content: str) -> None:
|
||||
"""Append a timestamped prose entry to today's episodic memory file.
|
||||
|
||||
Creates the file (with a date heading) if it doesn't exist yet.
|
||||
Used both by the queen's diary tool and by the consolidation hook.
|
||||
"""
|
||||
ep_path = episodic_memory_path()
|
||||
ep_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
today = date.today()
|
||||
today_str = format_memory_date(today)
|
||||
timestamp = datetime.now().strftime("%H:%M")
|
||||
if not ep_path.exists():
|
||||
header = f"# {today_str}\n\n"
|
||||
block = f"{header}### {timestamp}\n\n{content.strip()}\n"
|
||||
else:
|
||||
block = f"\n\n### {timestamp}\n\n{content.strip()}\n"
|
||||
with ep_path.open("a", encoding="utf-8") as f:
|
||||
f.write(block)
|
||||
|
||||
|
||||
def seed_if_missing() -> None:
|
||||
"""Create MEMORY.md with a blank template if it doesn't exist yet."""
|
||||
path = semantic_memory_path()
|
||||
if path.exists():
|
||||
return
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(_SEED_TEMPLATE, encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Consolidation prompt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SEMANTIC_SYSTEM = """\
|
||||
You maintain the persistent cross-session memory of an AI assistant called the Queen.
|
||||
Review the session notes and rewrite MEMORY.md — the Queen's durable understanding of the
|
||||
person she works with across all sessions.
|
||||
|
||||
Write entirely in the Queen's voice — first person, reflective, honest.
|
||||
Not a log of events, but genuine understanding of who this person is over time.
|
||||
|
||||
Rules:
|
||||
- Update and synthesise: incorporate new understanding, update facts that have changed, remove
|
||||
details that are stale, superseded, or no longer say anything meaningful about the person.
|
||||
- Keep it as structured markdown with named sections about the PERSON, not about today.
|
||||
- Do NOT include diary sections, daily logs, or session summaries. Those belong elsewhere.
|
||||
MEMORY.md is about who they are, what they want, what works — not what happened today.
|
||||
- Maintain a "How They Communicate" section: technical depth, preferred pace
|
||||
(fast/exploratory/thorough), what communication approaches have worked or not,
|
||||
tone preferences. Update based on diary reflections about communication.
|
||||
This section should evolve — "prefers direct answers" is useful on day 1;
|
||||
"prefers direct answers for technical questions but wants more context when
|
||||
discussing architecture trade-offs" is better by day 5.
|
||||
- Reference dates only when noting a lasting milestone (e.g. "since March 8th they prefer X").
|
||||
- If the session had no meaningful new information about the person,
|
||||
return the existing text unchanged.
|
||||
- Do not add fictional details. Only reflect what is evidenced in the notes.
|
||||
- Stay concise. Prune rather than accumulate. A lean, accurate file is more useful than a
|
||||
dense one. If something was true once but has been resolved or superseded, remove it.
|
||||
- Output only the raw markdown content of MEMORY.md. No preamble, no code fences.
|
||||
"""
|
||||
|
||||
_DIARY_SYSTEM = """\
|
||||
You maintain the daily episodic diary of an AI assistant called the Queen.
|
||||
You receive: (1) today's existing diary so far, and (2) notes from the latest session.
|
||||
|
||||
Rewrite the complete diary for today as a single unified narrative —
|
||||
first person, reflective, honest.
|
||||
Merge and deduplicate: if the same story (e.g. a research agent stalling) recurred several times,
|
||||
describe it once with appropriate weight rather than retelling it. Weave in new developments from
|
||||
the session notes. Preserve important milestones, emotional texture, and session path references.
|
||||
Preserve reflections about communication effectiveness — these are important inputs for the
|
||||
Queen's evolving understanding of the user. A reflection like "they responded much better when
|
||||
I led with the recommendation instead of listing options" is as important as
|
||||
"we built a Gmail agent."
|
||||
|
||||
If today's diary is empty, write the initial entry based on the session notes alone.
|
||||
|
||||
Output only the full diary prose — no date heading, no timestamp headers,
|
||||
no preamble, no code fences.
|
||||
"""
|
||||
|
||||
|
||||
def read_session_context(session_dir: Path, max_messages: int = 80) -> str:
|
||||
"""Extract a readable transcript from conversation parts + adapt.md.
|
||||
|
||||
Reads the last ``max_messages`` conversation parts and the session's
|
||||
adapt.md (working memory). Tool results are omitted — only user and
|
||||
assistant turns (with tool-call names noted) are included.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
|
||||
# Working notes
|
||||
adapt_path = session_dir / "data" / "adapt.md"
|
||||
if adapt_path.exists():
|
||||
text = adapt_path.read_text(encoding="utf-8").strip()
|
||||
if text:
|
||||
parts.append(f"## Session Working Notes (adapt.md)\n\n{text}")
|
||||
|
||||
# Conversation transcript
|
||||
parts_dir = session_dir / "conversations" / "parts"
|
||||
if parts_dir.exists():
|
||||
part_files = sorted(parts_dir.glob("*.json"))[-max_messages:]
|
||||
lines: list[str] = []
|
||||
for pf in part_files:
|
||||
try:
|
||||
data = json.loads(pf.read_text(encoding="utf-8"))
|
||||
role = data.get("role", "")
|
||||
content = str(data.get("content", "")).strip()
|
||||
tool_calls = data.get("tool_calls") or []
|
||||
if role == "tool":
|
||||
continue # skip verbose tool results
|
||||
if role == "assistant" and tool_calls and not content:
|
||||
names = [tc.get("function", {}).get("name", "?") for tc in tool_calls]
|
||||
lines.append(f"[queen calls: {', '.join(names)}]")
|
||||
elif content:
|
||||
label = "user" if role == "user" else "queen"
|
||||
lines.append(f"[{label}]: {content[:600]}")
|
||||
except (KeyError, TypeError) as exc:
|
||||
logger.debug("Skipping malformed conversation message: %s", exc)
|
||||
continue
|
||||
except Exception:
|
||||
logger.warning("Unexpected error parsing conversation message", exc_info=True)
|
||||
continue
|
||||
if lines:
|
||||
parts.append("## Conversation\n\n" + "\n".join(lines))
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Context compaction (binary-split LLM summarisation)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# If the raw session context exceeds this many characters, compact it first
|
||||
# before sending to the consolidation LLM. ~200 k chars ≈ 50 k tokens.
|
||||
_CTX_COMPACT_CHAR_LIMIT = 200_000
|
||||
_CTX_COMPACT_MAX_DEPTH = 8
|
||||
|
||||
_COMPACT_SYSTEM = (
|
||||
"Summarise this conversation segment. Preserve: user goals, key decisions, "
|
||||
"what was built or changed, emotional tone, and important outcomes. "
|
||||
"Write concisely in third person past tense. Omit routine tool invocations "
|
||||
"unless the result matters."
|
||||
)
|
||||
|
||||
|
||||
async def _compact_context(text: str, llm: object, *, _depth: int = 0) -> str:
|
||||
"""Binary-split and LLM-summarise *text* until it fits within the char limit.
|
||||
|
||||
Mirrors the recursive binary-splitting strategy used by the main agent
|
||||
compaction pipeline (EventLoopNode._llm_compact).
|
||||
"""
|
||||
if len(text) <= _CTX_COMPACT_CHAR_LIMIT or _depth >= _CTX_COMPACT_MAX_DEPTH:
|
||||
return text
|
||||
|
||||
# Split near the midpoint on a line boundary so we don't cut mid-message
|
||||
mid = len(text) // 2
|
||||
split_at = text.rfind("\n", 0, mid) + 1
|
||||
if split_at <= 0:
|
||||
split_at = mid
|
||||
|
||||
half1, half2 = text[:split_at], text[split_at:]
|
||||
|
||||
async def _summarise(chunk: str) -> str:
|
||||
try:
|
||||
resp = await llm.acomplete(
|
||||
messages=[{"role": "user", "content": chunk}],
|
||||
system=_COMPACT_SYSTEM,
|
||||
max_tokens=2048,
|
||||
)
|
||||
return resp.content.strip()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"queen_memory: context compaction LLM call failed (depth=%d), truncating",
|
||||
_depth,
|
||||
)
|
||||
return chunk[: _CTX_COMPACT_CHAR_LIMIT // 4]
|
||||
|
||||
s1, s2 = await asyncio.gather(_summarise(half1), _summarise(half2))
|
||||
combined = s1 + "\n\n" + s2
|
||||
if len(combined) > _CTX_COMPACT_CHAR_LIMIT:
|
||||
return await _compact_context(combined, llm, _depth=_depth + 1)
|
||||
return combined
|
||||
|
||||
|
||||
async def consolidate_queen_memory(
|
||||
session_id: str,
|
||||
session_dir: Path,
|
||||
llm: object,
|
||||
) -> None:
|
||||
"""Update MEMORY.md and append a diary entry based on the current session.
|
||||
|
||||
Reads conversation parts and adapt.md from session_dir. Called
|
||||
periodically in the background and once at session end. Failures are
|
||||
logged and silently swallowed so they never block teardown.
|
||||
|
||||
Args:
|
||||
session_id: The session ID (used for the adapt.md path reference).
|
||||
session_dir: Path to the session directory (~/.hive/queen/session/{id}).
|
||||
llm: LLMProvider instance (must support acomplete()).
|
||||
"""
|
||||
try:
|
||||
session_context = read_session_context(session_dir)
|
||||
if not session_context:
|
||||
logger.debug("queen_memory: no session context, skipping consolidation")
|
||||
return
|
||||
|
||||
logger.info("queen_memory: consolidating memory for session %s ...", session_id)
|
||||
|
||||
# If the transcript is very large, compact it with recursive binary LLM
|
||||
# summarisation before sending to the consolidation model.
|
||||
if len(session_context) > _CTX_COMPACT_CHAR_LIMIT:
|
||||
logger.info(
|
||||
"queen_memory: session context is %d chars — compacting first",
|
||||
len(session_context),
|
||||
)
|
||||
session_context = await _compact_context(session_context, llm)
|
||||
logger.info("queen_memory: compacted to %d chars", len(session_context))
|
||||
|
||||
existing_semantic = read_semantic_memory()
|
||||
today_journal = read_episodic_memory()
|
||||
today = date.today()
|
||||
today_str = format_memory_date(today)
|
||||
adapt_path = session_dir / "data" / "adapt.md"
|
||||
|
||||
user_msg = (
|
||||
f"## Existing Semantic Memory (MEMORY.md)\n\n"
|
||||
f"{existing_semantic or '(none yet)'}\n\n"
|
||||
f"## Today's Diary So Far ({today_str})\n\n"
|
||||
f"{today_journal or '(none yet)'}\n\n"
|
||||
f"{session_context}\n\n"
|
||||
f"## Session Reference\n\n"
|
||||
f"Session ID: {session_id}\n"
|
||||
f"Session path: {adapt_path}\n"
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"queen_memory: calling LLM (%d chars of context, ~%d tokens est.)",
|
||||
len(user_msg),
|
||||
len(user_msg) // 4,
|
||||
)
|
||||
|
||||
from framework.agents.queen.config import default_config
|
||||
|
||||
semantic_resp, diary_resp = await asyncio.gather(
|
||||
llm.acomplete(
|
||||
messages=[{"role": "user", "content": user_msg}],
|
||||
system=_SEMANTIC_SYSTEM,
|
||||
max_tokens=default_config.max_tokens,
|
||||
),
|
||||
llm.acomplete(
|
||||
messages=[{"role": "user", "content": user_msg}],
|
||||
system=_DIARY_SYSTEM,
|
||||
max_tokens=default_config.max_tokens,
|
||||
),
|
||||
)
|
||||
|
||||
new_semantic = semantic_resp.content.strip()
|
||||
diary_entry = diary_resp.content.strip()
|
||||
|
||||
if new_semantic:
|
||||
path = semantic_memory_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(new_semantic, encoding="utf-8")
|
||||
logger.info("queen_memory: semantic memory updated (%d chars)", len(new_semantic))
|
||||
|
||||
if diary_entry:
|
||||
# Rewrite today's episodic file in-place — the LLM has merged and
|
||||
# deduplicated the full day's content, so we replace rather than append.
|
||||
ep_path = episodic_memory_path()
|
||||
ep_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
heading = f"# {today_str}"
|
||||
ep_path.write_text(f"{heading}\n\n{diary_entry}\n", encoding="utf-8")
|
||||
logger.info(
|
||||
"queen_memory: episodic diary rewritten for %s (%d chars)",
|
||||
today_str,
|
||||
len(diary_entry),
|
||||
)
|
||||
|
||||
except Exception:
|
||||
tb = traceback.format_exc()
|
||||
logger.exception("queen_memory: consolidation failed")
|
||||
# Write to file so the cause is findable regardless of log verbosity.
|
||||
error_path = _queen_dir() / "consolidation_error.txt"
|
||||
try:
|
||||
error_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
error_path.write_text(
|
||||
f"session: {session_id}\ntime: {datetime.now().isoformat()}\n\n{tb}",
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
pass # Cannot write error file; original exception already logged
|
||||
@@ -1,25 +1,23 @@
|
||||
"""Shared memory helpers for queen/worker recall and reflection.
|
||||
"""Queen global memory helpers.
|
||||
|
||||
Each memory is an individual ``.md`` file in ``~/.hive/queen/memories/``
|
||||
with optional YAML frontmatter (name, type, description). Frontmatter
|
||||
is a convention enforced by prompt instructions — parsing is lenient and
|
||||
malformed files degrade gracefully (appear in scans with ``None`` metadata).
|
||||
Memory hierarchy::
|
||||
|
||||
Cursor-based incremental processing tracks which conversation messages
|
||||
have already been processed by the reflection agent.
|
||||
~/.hive/memories/
|
||||
global/ # shared across all queens and colonies
|
||||
colonies/{name}/ # colony-scoped memories
|
||||
agents/queens/{name}/ # queen-specific memories
|
||||
agents/{name}/ # per-worker-agent memories
|
||||
|
||||
Each memory is an individual ``.md`` file with optional YAML frontmatter
|
||||
(name, type, description).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -27,54 +25,35 @@ logger = logging.getLogger(__name__)
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MEMORY_TYPES: tuple[str, ...] = ("goal", "environment", "technique", "reference", "diary")
|
||||
GLOBAL_MEMORY_CATEGORIES: tuple[str, ...] = ("profile", "preference", "environment", "feedback")
|
||||
|
||||
_HIVE_QUEEN_DIR = Path.home() / ".hive" / "queen"
|
||||
# Legacy shared v2 root. Colony memory now lives under queen sessions.
|
||||
MEMORY_DIR: Path = _HIVE_QUEEN_DIR / "memories"
|
||||
from framework.config import MEMORIES_DIR
|
||||
|
||||
MAX_FILES: int = 200
|
||||
MAX_FILE_SIZE_BYTES: int = 4096 # 4 KB hard limit per memory file
|
||||
|
||||
# How many lines of a memory file to read for header scanning.
|
||||
_HEADER_LINE_LIMIT: int = 30
|
||||
_MIGRATION_MARKER = ".migrated-from-shared-memory"
|
||||
_GLOBAL_MEMORY_CODE_PATTERN = re.compile(
|
||||
r"(/Users/|~/.hive|\.py\b|\.ts\b|\.tsx\b|\.js\b|"
|
||||
r"\b(graph|node|runtime|session|execution|worker|queen|subagent|checkpoint|flowchart)\b)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Frontmatter example provided to the reflection agent via prompt.
|
||||
MEMORY_FRONTMATTER_EXAMPLE: list[str] = [
|
||||
"```markdown",
|
||||
"---",
|
||||
"name: {{memory name}}",
|
||||
(
|
||||
"description: {{one-line description — used to decide "
|
||||
"relevance in future conversations, so be specific}}"
|
||||
),
|
||||
f"type: {{{{{', '.join(MEMORY_TYPES)}}}}}",
|
||||
"---",
|
||||
"",
|
||||
(
|
||||
"{{memory content — for feedback/project types, "
|
||||
"structure as: rule/fact, then **Why:** "
|
||||
"and **How to apply:** lines}}"
|
||||
),
|
||||
"```",
|
||||
]
|
||||
|
||||
|
||||
def colony_memory_dir(colony_id: str) -> Path:
|
||||
"""Return the colony memory directory for a queen session."""
|
||||
return _HIVE_QUEEN_DIR / "session" / colony_id / "memory" / "colony"
|
||||
|
||||
|
||||
def global_memory_dir() -> Path:
|
||||
"""Return the queen-global memory directory."""
|
||||
return _HIVE_QUEEN_DIR / "global_memory"
|
||||
"""Return the global memory directory (shared across all queens/colonies)."""
|
||||
return MEMORIES_DIR / "global"
|
||||
|
||||
|
||||
def colony_memory_dir(colony_name: str) -> Path:
|
||||
"""Return the memory directory for a named colony."""
|
||||
return MEMORIES_DIR / "colonies" / colony_name
|
||||
|
||||
|
||||
def queen_memory_dir(queen_name: str = "default") -> Path:
|
||||
"""Return the memory directory for a named queen."""
|
||||
return MEMORIES_DIR / "agents" / "queens" / queen_name
|
||||
|
||||
|
||||
def agent_memory_dir(agent_name: str) -> Path:
|
||||
"""Return the memory directory for a worker agent."""
|
||||
return MEMORIES_DIR / "agents" / agent_name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -108,15 +87,6 @@ def parse_frontmatter(text: str) -> dict[str, str]:
|
||||
return result
|
||||
|
||||
|
||||
def parse_memory_type(raw: str | None) -> str | None:
|
||||
"""Validate *raw* against supported memory categories."""
|
||||
if raw is None:
|
||||
return None
|
||||
normalized = raw.strip().lower()
|
||||
allowed = set(MEMORY_TYPES) | set(GLOBAL_MEMORY_CATEGORIES)
|
||||
return normalized if normalized in allowed else None
|
||||
|
||||
|
||||
def parse_global_memory_category(raw: str | None) -> str | None:
|
||||
"""Validate *raw* against ``GLOBAL_MEMORY_CATEGORIES``."""
|
||||
if raw is None:
|
||||
@@ -165,7 +135,7 @@ class MemoryFile:
|
||||
filename=path.name,
|
||||
path=path,
|
||||
name=fm.get("name"),
|
||||
type=parse_memory_type(fm.get("type")),
|
||||
type=parse_global_memory_category(fm.get("type")),
|
||||
description=fm.get("description"),
|
||||
header_lines=lines,
|
||||
mtime=mtime,
|
||||
@@ -183,7 +153,7 @@ def scan_memory_files(memory_dir: Path | None = None) -> list[MemoryFile]:
|
||||
Files are sorted by modification time (newest first). Dotfiles and
|
||||
subdirectories are ignored.
|
||||
"""
|
||||
d = memory_dir or MEMORY_DIR
|
||||
d = memory_dir or global_memory_dir()
|
||||
if not d.is_dir():
|
||||
return []
|
||||
|
||||
@@ -236,318 +206,30 @@ def build_memory_document(
|
||||
)
|
||||
|
||||
|
||||
def diary_filename(d: date | None = None) -> str:
|
||||
"""Return the diary memory filename for date *d* (default: today)."""
|
||||
d = d or date.today()
|
||||
return f"MEMORY-{d.strftime('%Y-%m-%d')}.md"
|
||||
|
||||
|
||||
def build_diary_document(*, date_str: str, body: str) -> str:
|
||||
"""Build a diary memory file with frontmatter."""
|
||||
return build_memory_document(
|
||||
name=f"diary-{date_str}",
|
||||
description=f"Daily session narrative for {date_str}",
|
||||
mem_type="diary",
|
||||
body=body,
|
||||
)
|
||||
|
||||
|
||||
def validate_global_memory_payload(
|
||||
*,
|
||||
category: str,
|
||||
description: str,
|
||||
content: str,
|
||||
) -> str:
|
||||
"""Validate a queen-global memory save request."""
|
||||
parsed = parse_global_memory_category(category)
|
||||
if parsed is None:
|
||||
raise ValueError(
|
||||
"Invalid global memory category. Use one of: "
|
||||
+ ", ".join(GLOBAL_MEMORY_CATEGORIES)
|
||||
)
|
||||
if not description.strip():
|
||||
raise ValueError("Global memory description cannot be empty.")
|
||||
if not content.strip():
|
||||
raise ValueError("Global memory content cannot be empty.")
|
||||
|
||||
probe = f"{description}\n{content}"
|
||||
if _GLOBAL_MEMORY_CODE_PATTERN.search(probe):
|
||||
raise ValueError(
|
||||
"Global memory is only for durable user profile, preferences, "
|
||||
"environment, or feedback — not task/code/runtime details."
|
||||
)
|
||||
return parsed
|
||||
|
||||
|
||||
def save_global_memory(
|
||||
*,
|
||||
category: str,
|
||||
description: str,
|
||||
content: str,
|
||||
name: str | None = None,
|
||||
memory_dir: Path | None = None,
|
||||
) -> tuple[str, Path]:
|
||||
"""Persist one queen-global memory entry."""
|
||||
parsed = validate_global_memory_payload(
|
||||
category=category,
|
||||
description=description,
|
||||
content=content,
|
||||
)
|
||||
target_dir = memory_dir or global_memory_dir()
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
memory_name = (name or description).strip()
|
||||
filename = allocate_memory_filename(target_dir, memory_name)
|
||||
doc = build_memory_document(
|
||||
name=memory_name,
|
||||
description=description,
|
||||
mem_type=parsed,
|
||||
body=content,
|
||||
)
|
||||
if len(doc.encode("utf-8")) > MAX_FILE_SIZE_BYTES:
|
||||
raise ValueError(
|
||||
f"Global memory entry exceeds the {MAX_FILE_SIZE_BYTES} byte limit."
|
||||
)
|
||||
path = target_dir / filename
|
||||
path.write_text(doc, encoding="utf-8")
|
||||
return filename, path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manifest formatting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _age_label(mtime: float) -> str:
|
||||
"""Human-readable age string from an mtime."""
|
||||
age_days = memory_age_days(mtime)
|
||||
if age_days <= 0:
|
||||
return "today"
|
||||
if age_days == 1:
|
||||
return "1 day ago"
|
||||
return f"{age_days} days ago"
|
||||
|
||||
|
||||
def format_memory_manifest(files: list[MemoryFile]) -> str:
|
||||
"""One-line-per-file text manifest for the recall selector / reflection agent.
|
||||
"""One-line-per-file text manifest.
|
||||
|
||||
Format: ``[type] filename (age): description``
|
||||
Format: ``[type] filename: description``
|
||||
"""
|
||||
lines: list[str] = []
|
||||
for mf in files:
|
||||
t = mf.type or "unknown"
|
||||
desc = mf.description or "(no description)"
|
||||
age = _age_label(mf.mtime)
|
||||
lines.append(f"[{t}] {mf.filename} ({age}): {desc}")
|
||||
lines.append(f"[{t}] {mf.filename}: {desc}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Freshness / staleness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SECONDS_PER_DAY = 86_400
|
||||
|
||||
|
||||
def memory_age_days(mtime: float) -> int:
|
||||
"""Return the age of a memory file in whole days."""
|
||||
if mtime <= 0:
|
||||
return 0
|
||||
return int((time.time() - mtime) / _SECONDS_PER_DAY)
|
||||
|
||||
|
||||
def memory_freshness_text(mtime: float) -> str:
|
||||
"""Return a staleness warning for injection, or empty string if fresh."""
|
||||
d = memory_age_days(mtime)
|
||||
if d <= 1:
|
||||
return ""
|
||||
return (
|
||||
f"This memory is {d} days old. "
|
||||
"Memories are point-in-time observations, not live state — "
|
||||
"claims about code behavior or file:line citations may be outdated. "
|
||||
"Verify against current code before asserting as fact."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cursor-based incremental processing
|
||||
# Initialisation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def read_conversation_parts(session_dir: Path) -> list[dict[str, Any]]:
|
||||
"""Read all conversation parts for a session using FileConversationStore.
|
||||
|
||||
Returns a list of raw message dicts in sequence order.
|
||||
"""
|
||||
from framework.storage.conversation_store import FileConversationStore
|
||||
|
||||
store = FileConversationStore(session_dir / "conversations")
|
||||
return await store.read_parts()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Initialisation and legacy migration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def init_memory_dir(
|
||||
memory_dir: Path | None = None,
|
||||
*,
|
||||
migrate_legacy: bool = False,
|
||||
) -> None:
|
||||
"""Create the memory directory if missing.
|
||||
|
||||
When ``migrate_legacy`` is true, migrate both v1 memory files and the
|
||||
previous shared v2 queen memory store into this directory.
|
||||
"""
|
||||
d = memory_dir or MEMORY_DIR
|
||||
first_run = not d.exists()
|
||||
def init_memory_dir(memory_dir: Path | None = None) -> None:
|
||||
"""Create the memory directory if missing."""
|
||||
d = memory_dir or global_memory_dir()
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
if migrate_legacy:
|
||||
migrate_legacy_memories(d)
|
||||
migrate_shared_v2_memories(d)
|
||||
elif first_run and d == MEMORY_DIR:
|
||||
migrate_legacy_memories(d)
|
||||
|
||||
|
||||
def migrate_legacy_memories(memory_dir: Path | None = None) -> None:
|
||||
"""Convert old MEMORY.md + MEMORY-YYYY-MM-DD.md files to individual memory files.
|
||||
|
||||
Originals are moved to ``{memory_dir}/.legacy/``.
|
||||
"""
|
||||
d = memory_dir or MEMORY_DIR
|
||||
queen_dir = _HIVE_QUEEN_DIR
|
||||
legacy_archive = d / ".legacy"
|
||||
|
||||
migrated_any = False
|
||||
|
||||
# --- Semantic memory (MEMORY.md) ---
|
||||
semantic = queen_dir / "MEMORY.md"
|
||||
if semantic.exists():
|
||||
content = semantic.read_text(encoding="utf-8").strip()
|
||||
# Skip the blank seed template.
|
||||
if content and not content.startswith("# My Understanding of the User\n\n*No sessions"):
|
||||
_write_migration_file(
|
||||
d,
|
||||
filename="legacy-semantic-memory.md",
|
||||
name="legacy-semantic-memory",
|
||||
mem_type="reference",
|
||||
description="Migrated semantic memory from previous memory system",
|
||||
body=content,
|
||||
)
|
||||
migrated_any = True
|
||||
# Archive original.
|
||||
legacy_archive.mkdir(parents=True, exist_ok=True)
|
||||
semantic.rename(legacy_archive / "MEMORY.md")
|
||||
|
||||
# --- Episodic memories (MEMORY-YYYY-MM-DD.md) ---
|
||||
old_memories_dir = queen_dir / "memories"
|
||||
if old_memories_dir.is_dir():
|
||||
for ep_file in sorted(old_memories_dir.glob("MEMORY-*.md")):
|
||||
content = ep_file.read_text(encoding="utf-8").strip()
|
||||
if not content:
|
||||
continue
|
||||
date_part = ep_file.stem.replace("MEMORY-", "")
|
||||
slug = f"legacy-diary-{date_part}.md"
|
||||
_write_migration_file(
|
||||
d,
|
||||
filename=slug,
|
||||
name=f"legacy-diary-{date_part}",
|
||||
mem_type="diary",
|
||||
description=f"Migrated diary entry from {date_part}",
|
||||
body=content,
|
||||
)
|
||||
migrated_any = True
|
||||
# Archive original.
|
||||
legacy_archive.mkdir(parents=True, exist_ok=True)
|
||||
ep_file.rename(legacy_archive / ep_file.name)
|
||||
|
||||
if migrated_any:
|
||||
logger.info("queen_memory_v2: migrated legacy memory files to %s", d)
|
||||
|
||||
|
||||
def migrate_shared_v2_memories(
|
||||
memory_dir: Path | None = None,
|
||||
*,
|
||||
source_dir: Path | None = None,
|
||||
) -> None:
|
||||
"""Move shared queen v2 memory files into a colony directory once."""
|
||||
d = memory_dir or MEMORY_DIR
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
src = source_dir or MEMORY_DIR
|
||||
if d.resolve() == src.resolve():
|
||||
return
|
||||
|
||||
marker = d / _MIGRATION_MARKER
|
||||
if marker.exists():
|
||||
return
|
||||
|
||||
if not src.is_dir():
|
||||
return
|
||||
|
||||
md_files = sorted(
|
||||
f for f in src.glob("*.md")
|
||||
if f.is_file() and not f.name.startswith(".")
|
||||
)
|
||||
if not md_files:
|
||||
marker.write_text("no shared memories found\n", encoding="utf-8")
|
||||
return
|
||||
|
||||
archive = src / ".legacy_colony_migration"
|
||||
archive.mkdir(parents=True, exist_ok=True)
|
||||
migrated_any = False
|
||||
|
||||
for src_file in md_files:
|
||||
target = d / src_file.name
|
||||
if not target.exists():
|
||||
try:
|
||||
shutil.copy2(src_file, target)
|
||||
migrated_any = True
|
||||
except OSError:
|
||||
logger.debug("shared memory migration copy failed for %s", src_file, exc_info=True)
|
||||
continue
|
||||
|
||||
archived = archive / src_file.name
|
||||
counter = 2
|
||||
while archived.exists():
|
||||
archived = archive / f"{src_file.stem}-{counter}{src_file.suffix}"
|
||||
counter += 1
|
||||
try:
|
||||
src_file.rename(archived)
|
||||
except OSError:
|
||||
logger.debug("shared memory migration archive failed for %s", src_file, exc_info=True)
|
||||
|
||||
if migrated_any:
|
||||
logger.info("queen_memory_v2: migrated shared queen memories to %s", d)
|
||||
marker.write_text(
|
||||
f"migrated_at={int(time.time())}\nsource={src}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _write_migration_file(
|
||||
memory_dir: Path,
|
||||
filename: str,
|
||||
name: str,
|
||||
mem_type: str,
|
||||
description: str,
|
||||
body: str,
|
||||
) -> None:
|
||||
"""Write a single migrated memory file with frontmatter."""
|
||||
# Truncate body to respect file size limit (leave room for frontmatter).
|
||||
header = (
|
||||
f"---\n"
|
||||
f"name: {name}\n"
|
||||
f"description: {description}\n"
|
||||
f"type: {mem_type}\n"
|
||||
f"---\n\n"
|
||||
)
|
||||
max_body = MAX_FILE_SIZE_BYTES - len(header.encode("utf-8"))
|
||||
if len(body.encode("utf-8")) > max_body:
|
||||
# Rough truncation — cut at character level then trim to last newline.
|
||||
body = body[: max_body - 20]
|
||||
nl = body.rfind("\n")
|
||||
if nl > 0:
|
||||
body = body[:nl]
|
||||
body += "\n\n...(truncated during migration)"
|
||||
|
||||
path = memory_dir / filename
|
||||
path.write_text(header + body + "\n", encoding="utf-8")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
||||
"""Recall selector — pre-turn memory selection for queen and worker memory.
|
||||
"""Recall selector — pre-turn memory selection for the queen.
|
||||
|
||||
Before each conversation turn the system:
|
||||
1. Scans the memory directory for ``.md`` files (cap: 200).
|
||||
1. Scans one or more memory directories for ``.md`` files (cap: 200 each).
|
||||
2. Reads headers (frontmatter + first 30 lines).
|
||||
3. Uses a single LLM call with structured JSON output to pick the ~5
|
||||
most relevant memories.
|
||||
4. Injects them into context with staleness warnings for older ones.
|
||||
3. Uses an LLM call with structured JSON output to pick the most relevant
|
||||
memories for each scope.
|
||||
4. Injects them into the system prompt.
|
||||
|
||||
The selector only sees the user's query string — no full conversation
|
||||
context. This keeps it cheap and fast. Errors are caught and return
|
||||
@@ -20,9 +20,8 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from framework.agents.queen.queen_memory_v2 import (
|
||||
MEMORY_DIR,
|
||||
format_memory_manifest,
|
||||
memory_freshness_text,
|
||||
global_memory_dir as _default_global_memory_dir,
|
||||
scan_memory_files,
|
||||
)
|
||||
|
||||
@@ -32,29 +31,6 @@ logger = logging.getLogger(__name__)
|
||||
# Structured output schema
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
RECALL_SCHEMA: dict[str, Any] = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "memory_selection",
|
||||
"strict": True,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"selected_memories": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
},
|
||||
"required": ["selected_memories"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System prompt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SELECT_MEMORIES_SYSTEM_PROMPT = """\
|
||||
You are selecting memories that will be useful to the Queen agent as it \
|
||||
processes a user's query.
|
||||
@@ -72,9 +48,6 @@ name and description.
|
||||
query, then do not include it in your list. Be selective and discerning.
|
||||
- If there are no memories in the list that would clearly be useful, \
|
||||
return an empty list.
|
||||
- If a list of recently-used tools is provided, do not select memories \
|
||||
that are usage reference or API documentation for those tools (the Queen \
|
||||
is already exercising them). Still select warnings or gotchas about them.
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -86,7 +59,6 @@ async def select_memories(
|
||||
query: str,
|
||||
llm: Any,
|
||||
memory_dir: Path | None = None,
|
||||
active_tools: list[str] | None = None,
|
||||
*,
|
||||
max_results: int = 5,
|
||||
) -> list[str]:
|
||||
@@ -94,51 +66,83 @@ async def select_memories(
|
||||
|
||||
Returns a list of filenames. Best-effort: on any error returns ``[]``.
|
||||
"""
|
||||
mem_dir = memory_dir or MEMORY_DIR
|
||||
mem_dir = memory_dir or _default_global_memory_dir()
|
||||
files = scan_memory_files(mem_dir)
|
||||
if not files:
|
||||
logger.debug("recall: no memory files found, skipping selection")
|
||||
return []
|
||||
|
||||
logger.debug("recall: selecting from %d memory files for query: %.80s", len(files), query)
|
||||
logger.debug("recall: selecting from %d memories for query: %.100s", len(files), query)
|
||||
manifest = format_memory_manifest(files)
|
||||
|
||||
user_msg_parts = [f"## User query\n\n{query}\n\n## Available memories\n\n{manifest}"]
|
||||
if active_tools:
|
||||
user_msg_parts.append(f"\n\n## Recently-used tools\n\n{', '.join(active_tools)}")
|
||||
|
||||
user_msg = "".join(user_msg_parts)
|
||||
user_msg = f"## User query\n\n{query}\n\n## Available memories\n\n{manifest}"
|
||||
|
||||
try:
|
||||
resp = await llm.acomplete(
|
||||
messages=[{"role": "user", "content": user_msg}],
|
||||
system=SELECT_MEMORIES_SYSTEM_PROMPT,
|
||||
max_tokens=512,
|
||||
response_format=RECALL_SCHEMA,
|
||||
max_tokens=1024,
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
data = json.loads(resp.content)
|
||||
raw = (resp.content or "").strip()
|
||||
if not raw:
|
||||
logger.warning(
|
||||
"recall: LLM returned empty response (model=%s, stop=%s)",
|
||||
resp.model,
|
||||
resp.stop_reason,
|
||||
)
|
||||
return []
|
||||
# Some models wrap JSON in markdown fences or add preamble text.
|
||||
# Try to extract the JSON object if raw parse fails.
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
import re
|
||||
|
||||
m = re.search(r"\{.*\}", raw, re.DOTALL)
|
||||
if m:
|
||||
data = json.loads(m.group())
|
||||
else:
|
||||
logger.warning("recall: LLM returned non-JSON: %.200s", raw)
|
||||
return []
|
||||
selected = data.get("selected_memories", [])
|
||||
# Validate: only return filenames that actually exist.
|
||||
valid_names = {f.filename for f in files}
|
||||
result = [s for s in selected if s in valid_names][:max_results]
|
||||
logger.debug("recall: selected %d memories: %s", len(result), result)
|
||||
return result
|
||||
except Exception:
|
||||
logger.debug("recall: memory selection failed, returning []", exc_info=True)
|
||||
except Exception as exc:
|
||||
logger.warning("recall: memory selection failed (%s), returning []", exc)
|
||||
return []
|
||||
|
||||
|
||||
def _format_relative_age(mtime: float) -> str | None:
|
||||
"""Return age description if memory is older than 48 hours.
|
||||
|
||||
Returns None if 48 hours or newer, otherwise returns "X days old".
|
||||
"""
|
||||
import time
|
||||
|
||||
age_seconds = time.time() - mtime
|
||||
hours = age_seconds / 3600
|
||||
if hours <= 48:
|
||||
return None
|
||||
days = int(age_seconds / 86400)
|
||||
if days == 1:
|
||||
return "1 day old"
|
||||
return f"{days} days old"
|
||||
|
||||
|
||||
def format_recall_injection(
|
||||
filenames: list[str],
|
||||
memory_dir: Path | None = None,
|
||||
*,
|
||||
heading: str = "Selected Memories",
|
||||
label: str = "Global Memories",
|
||||
) -> str:
|
||||
"""Read selected memory files and format for system prompt injection.
|
||||
|
||||
Prepends a staleness warning for memories older than 1 day.
|
||||
Includes relative timestamp (e.g., "3 days old") for memories older than 48 hours.
|
||||
"""
|
||||
mem_dir = memory_dir or MEMORY_DIR
|
||||
|
||||
mem_dir = memory_dir or _default_global_memory_dir()
|
||||
if not filenames:
|
||||
return ""
|
||||
|
||||
@@ -149,88 +153,63 @@ def format_recall_injection(
|
||||
continue
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8").strip()
|
||||
# Get file modification time for age calculation
|
||||
mtime = path.stat().st_mtime
|
||||
age_note = _format_relative_age(mtime)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
try:
|
||||
mtime = path.stat().st_mtime
|
||||
except OSError:
|
||||
mtime = 0.0
|
||||
|
||||
freshness = memory_freshness_text(mtime)
|
||||
header = f"### {fname}"
|
||||
if freshness:
|
||||
header += f"\n\n> {freshness}"
|
||||
# Build header with optional age note
|
||||
if age_note:
|
||||
header = f"### {fname} ({age_note})"
|
||||
else:
|
||||
header = f"### {fname}"
|
||||
blocks.append(f"{header}\n\n{content}")
|
||||
|
||||
if not blocks:
|
||||
return ""
|
||||
|
||||
body = "\n\n---\n\n".join(blocks)
|
||||
logger.debug("recall: injecting %d memory blocks into context", len(blocks))
|
||||
return f"--- {heading} ---\n\n{body}\n\n--- End {heading} ---"
|
||||
return f"--- {label} ---\n\n{body}\n\n--- End {label} ---"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache update (called after each queen turn)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def update_recall_cache(
|
||||
session_dir: Path,
|
||||
async def build_scoped_recall_blocks(
|
||||
query: str,
|
||||
llm: Any,
|
||||
phase_state: Any | None = None,
|
||||
memory_dir: Path | None = None,
|
||||
*,
|
||||
cache_setter: Any = None,
|
||||
heading: str = "Selected Memories",
|
||||
active_tools: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Update the recall cache on *phase_state* for the next turn.
|
||||
global_memory_dir: Path | None = None,
|
||||
queen_memory_dir: Path | None = None,
|
||||
queen_id: str | None = None,
|
||||
global_max_results: int = 3,
|
||||
queen_max_results: int = 3,
|
||||
) -> tuple[str, str]:
|
||||
"""Build separate recall blocks for global and queen-scoped memory."""
|
||||
global_dir = global_memory_dir or _default_global_memory_dir()
|
||||
global_selected = await select_memories(
|
||||
query,
|
||||
llm,
|
||||
memory_dir=global_dir,
|
||||
max_results=global_max_results,
|
||||
)
|
||||
global_block = format_recall_injection(
|
||||
global_selected,
|
||||
memory_dir=global_dir,
|
||||
label="Global Memories",
|
||||
)
|
||||
|
||||
Reads the latest user message from conversation parts to use as the
|
||||
query for memory selection.
|
||||
"""
|
||||
mem_dir = memory_dir or MEMORY_DIR
|
||||
|
||||
# Extract latest user message as the query.
|
||||
query = _extract_latest_user_query(session_dir)
|
||||
if not query:
|
||||
logger.debug("recall: no user query found, skipping cache update")
|
||||
return
|
||||
logger.debug("recall: updating cache for query: %.80s", query)
|
||||
|
||||
try:
|
||||
selected = await select_memories(
|
||||
queen_block = ""
|
||||
if queen_memory_dir is not None:
|
||||
queen_selected = await select_memories(
|
||||
query,
|
||||
llm,
|
||||
mem_dir,
|
||||
active_tools=active_tools,
|
||||
memory_dir=queen_memory_dir,
|
||||
max_results=queen_max_results,
|
||||
)
|
||||
queen_label = f"Queen Memories: {queen_id}" if queen_id else "Queen Memories"
|
||||
queen_block = format_recall_injection(
|
||||
queen_selected,
|
||||
memory_dir=queen_memory_dir,
|
||||
label=queen_label,
|
||||
)
|
||||
injection = format_recall_injection(selected, mem_dir, heading=heading)
|
||||
if cache_setter is not None:
|
||||
cache_setter(injection)
|
||||
elif phase_state is not None:
|
||||
phase_state._cached_recall_block = injection
|
||||
except Exception:
|
||||
logger.debug("recall: cache update failed", exc_info=True)
|
||||
|
||||
|
||||
def _extract_latest_user_query(session_dir: Path) -> str:
|
||||
"""Read the most recent user message from conversation parts."""
|
||||
parts_dir = session_dir / "conversations" / "parts"
|
||||
if not parts_dir.is_dir():
|
||||
return ""
|
||||
|
||||
part_files = sorted(parts_dir.glob("*.json"), reverse=True)
|
||||
for f in part_files[:20]: # Look back at most 20 messages.
|
||||
try:
|
||||
data = json.loads(f.read_text(encoding="utf-8"))
|
||||
if data.get("role") == "user":
|
||||
content = str(data.get("content", "")).strip()
|
||||
if content:
|
||||
# Truncate very long queries.
|
||||
return content[:1000] if len(content) > 1000 else content
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
return ""
|
||||
return global_block, queen_block
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
6. **Calling set_output in same turn as tool calls** — Call set_output in a SEPARATE turn.
|
||||
|
||||
## File Template Errors
|
||||
7. **Wrong import paths** — Use `from framework.graph import ...`, NOT `from core.framework.graph import ...`.
|
||||
7. **Wrong import paths** — Use `from framework.orchestrator import ...`, NOT `from framework.graph import ...` or `from core.framework...`.
|
||||
8. **Missing storage path** — Agent class must set `self._storage_path = Path.home() / ".hive" / "agents" / "agent_name"`.
|
||||
9. **Missing mcp_servers.json** — Without this, the agent has no tools at runtime.
|
||||
10. **Bare `python` command** — Use `"command": "uv"` with args `["run", "python", ...]`.
|
||||
@@ -25,10 +25,7 @@
|
||||
14. **Forgetting sys.path setup in conftest.py** — Tests need `exports/` and `core/` on sys.path.
|
||||
|
||||
## GCU Errors
|
||||
15. **Manually wiring browser tools on event_loop nodes** — Use `node_type="gcu"` which auto-includes browser tools. Do NOT manually list browser tool names.
|
||||
16. **Using GCU nodes as regular graph nodes** — GCU nodes are subagents only. They must ONLY appear in `sub_agents=["gcu-node-id"]` and be invoked via `delegate_to_sub_agent()`. Never connect via edges or use as entry/terminal nodes.
|
||||
17. **Reusing the same GCU node ID for parallel tasks** — Each concurrent browser task needs a distinct GCU node ID (e.g. `gcu-site-a`, `gcu-site-b`). Two `delegate_to_sub_agent` calls with the same `agent_id` share a browser profile and will interfere with each other's pages.
|
||||
18. **Passing `profile=` in GCU tool calls** — Profile isolation for parallel subagents is automatic. The framework injects a unique profile per subagent via an asyncio `ContextVar`. Hardcoding `profile="default"` in a GCU system prompt breaks this isolation.
|
||||
15. **Manually wiring browser tools on event_loop nodes** — Browser nodes use tools: {policy: "all"} to get all browser tools.
|
||||
|
||||
## Worker Agent Errors
|
||||
19. **Adding client-facing intake node to workers** — The queen owns intake. Workers should start with an autonomous processing node. Route worker review/approval through queen escalation instead of direct worker HITL.
|
||||
|
||||
@@ -55,7 +55,7 @@ metadata = AgentMetadata()
|
||||
```python
|
||||
"""Node definitions for My Agent."""
|
||||
|
||||
from framework.graph import NodeSpec
|
||||
from framework.orchestrator import NodeSpec
|
||||
|
||||
# Node 1: Process (autonomous entry node)
|
||||
# The queen handles intake and passes structured input via
|
||||
@@ -123,14 +123,15 @@ __all__ = ["process_node", "handoff_node"]
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from framework.graph import EdgeSpec, EdgeCondition, Goal, SuccessCriterion, Constraint
|
||||
from framework.graph.edge import GraphSpec
|
||||
from framework.graph.executor import ExecutionResult
|
||||
from framework.graph.checkpoint_config import CheckpointConfig
|
||||
from framework.orchestrator import EdgeSpec, EdgeCondition, Goal, SuccessCriterion, Constraint
|
||||
from framework.orchestrator.edge import GraphSpec
|
||||
from framework.orchestrator.orchestrator import ExecutionResult
|
||||
from framework.orchestrator.checkpoint_config import CheckpointConfig
|
||||
from framework.llm import LiteLLMProvider
|
||||
from framework.runner.tool_registry import ToolRegistry
|
||||
from framework.runtime.agent_runtime import AgentRuntime, create_agent_runtime
|
||||
from framework.runtime.execution_stream import EntryPointSpec
|
||||
from framework.loader.tool_registry import ToolRegistry
|
||||
from framework.host.agent_host import AgentHost
|
||||
from framework.host.execution_manager import EntryPointSpec
|
||||
|
||||
|
||||
from .config import default_config, metadata
|
||||
from .nodes import process_node, handoff_node
|
||||
@@ -227,7 +228,7 @@ class MyAgent:
|
||||
tools = list(self._tool_registry.get_tools().values())
|
||||
tool_executor = self._tool_registry.get_executor()
|
||||
self._graph = self._build_graph()
|
||||
self._agent_runtime = create_agent_runtime(
|
||||
self._agent_runtime = AgentHost(
|
||||
graph=self._graph, goal=self.goal, storage_path=self._storage_path,
|
||||
entry_points=[EntryPointSpec(id="default", name="Default", entry_node=self.entry_node,
|
||||
trigger_type="manual", isolation_level="shared")],
|
||||
@@ -460,8 +461,8 @@ def tui():
|
||||
from framework.tui.app import AdenTUI
|
||||
from framework.llm import LiteLLMProvider
|
||||
from framework.runner.tool_registry import ToolRegistry
|
||||
from framework.runtime.agent_runtime import create_agent_runtime
|
||||
from framework.runtime.execution_stream import EntryPointSpec
|
||||
from framework.host.agent_host import AgentHost
|
||||
from framework.host.execution_manager import EntryPointSpec
|
||||
|
||||
async def run_tui():
|
||||
agent = MyAgent()
|
||||
@@ -471,7 +472,7 @@ def tui():
|
||||
mcp_cfg = Path(__file__).parent / "mcp_servers.json"
|
||||
if mcp_cfg.exists(): agent._tool_registry.load_mcp_config(mcp_cfg)
|
||||
llm = LiteLLMProvider(model=agent.config.model, api_key=agent.config.api_key, api_base=agent.config.api_base)
|
||||
runtime = create_agent_runtime(
|
||||
runtime = AgentHost(
|
||||
graph=agent._build_graph(), goal=agent.goal, storage_path=storage,
|
||||
entry_points=[EntryPointSpec(id="start", name="Start", entry_node="process", trigger_type="manual", isolation_level="isolated")],
|
||||
llm=llm, tools=list(agent._tool_registry.get_tools().values()), tool_executor=agent._tool_registry.get_executor())
|
||||
@@ -509,17 +510,17 @@ if __name__ == "__main__":
|
||||
|
||||
## mcp_servers.json
|
||||
|
||||
> **Auto-generated.** `initialize_and_build_agent` creates this file with hive-tools
|
||||
> **Auto-generated.** `initialize_and_build_agent` creates this file with hive_tools
|
||||
> as the default. Only edit manually to add additional MCP servers.
|
||||
|
||||
```json
|
||||
{
|
||||
"hive-tools": {
|
||||
"hive_tools": {
|
||||
"transport": "stdio",
|
||||
"command": "uv",
|
||||
"args": ["run", "python", "mcp_server.py", "--stdio"],
|
||||
"cwd": "../../tools",
|
||||
"description": "Hive tools MCP server"
|
||||
"description": "hive_tools MCP server"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
# Declarative Agent File Templates
|
||||
|
||||
Agents are defined as a single `agent.yaml` file. No Python code needed.
|
||||
The runner loads this file directly -- no `agent.py`, `config.py`, or
|
||||
`nodes/__init__.py` required.
|
||||
|
||||
## agent.yaml -- Complete Agent Definition
|
||||
|
||||
```yaml
|
||||
name: my-agent
|
||||
version: 1.0.0
|
||||
description: What this agent does.
|
||||
|
||||
metadata:
|
||||
intro_message: Welcome! What would you like me to do?
|
||||
|
||||
# Template variables -- substituted into system_prompt and identity_prompt
|
||||
# via {{variable_name}} syntax. Use this for config values that appear
|
||||
# in prompts (spreadsheet IDs, API endpoints, account names, etc.)
|
||||
variables:
|
||||
spreadsheet_id: "1ZVxWDL..."
|
||||
sheet_name: "contacts"
|
||||
|
||||
goal:
|
||||
description: What this agent achieves.
|
||||
success_criteria:
|
||||
- "First success criterion"
|
||||
- "Second success criterion"
|
||||
constraints:
|
||||
- "Hard constraint the agent must respect"
|
||||
|
||||
identity_prompt: |
|
||||
You are a helpful agent.
|
||||
|
||||
conversation_mode: continuous # always "continuous" for Hive agents
|
||||
|
||||
loop_config:
|
||||
max_iterations: 100
|
||||
max_tool_calls_per_turn: 30
|
||||
max_context_tokens: 32000
|
||||
|
||||
# MCP servers to connect (resolved by name from ~/.hive/mcp_registry/)
|
||||
mcp_servers:
|
||||
- name: hive_tools
|
||||
- name: gcu-tools
|
||||
|
||||
nodes:
|
||||
# Node 1: Process (autonomous entry node)
|
||||
# The queen handles intake and passes structured input via
|
||||
# run_agent_with_input(task). NO client-facing intake node.
|
||||
- id: process
|
||||
name: Process
|
||||
description: Execute the task using available tools
|
||||
max_node_visits: 0 # 0 = unlimited (forever-alive agents)
|
||||
input_keys: [user_request, feedback]
|
||||
output_keys: [results]
|
||||
nullable_output_keys: [feedback]
|
||||
tools:
|
||||
policy: explicit
|
||||
allowed: [web_search, web_scrape, save_data, load_data, list_data_files]
|
||||
success_criteria: Results are complete and accurate.
|
||||
system_prompt: |
|
||||
You are a processing agent. Your task is in memory under "user_request".
|
||||
If "feedback" is present, this is a revision.
|
||||
|
||||
Work in phases:
|
||||
1. Use tools to gather/process data
|
||||
2. Analyze results
|
||||
3. Call set_output in a SEPARATE turn:
|
||||
- set_output("results", "structured results")
|
||||
|
||||
# Node 2: Handoff (autonomous)
|
||||
- id: handoff
|
||||
name: Handoff
|
||||
description: Prepare worker results for queen review
|
||||
max_node_visits: 0
|
||||
input_keys: [results, user_request]
|
||||
output_keys: [next_action, feedback, worker_summary]
|
||||
nullable_output_keys: [feedback, worker_summary]
|
||||
tools:
|
||||
policy: none # handoff nodes don't need tools
|
||||
success_criteria: Results are packaged for queen decision-making.
|
||||
system_prompt: |
|
||||
Do NOT talk to the user directly. The queen is the only user interface.
|
||||
|
||||
If blocked, call escalate(reason, context) then set:
|
||||
- set_output("next_action", "escalated")
|
||||
- set_output("feedback", "what help is needed")
|
||||
|
||||
Otherwise summarize and set:
|
||||
- set_output("worker_summary", "short summary for queen")
|
||||
- set_output("next_action", "done") or "revise"
|
||||
- set_output("feedback", "what to revise") only when revising
|
||||
|
||||
edges:
|
||||
- from_node: process
|
||||
to_node: handoff
|
||||
# Feedback loop
|
||||
- from_node: handoff
|
||||
to_node: process
|
||||
condition: conditional
|
||||
condition_expr: "str(next_action).lower() == 'revise'"
|
||||
priority: 2
|
||||
# Escalation loop
|
||||
- from_node: handoff
|
||||
to_node: process
|
||||
condition: conditional
|
||||
condition_expr: "str(next_action).lower() == 'escalated'"
|
||||
priority: 3
|
||||
# Loop back for next task
|
||||
- from_node: handoff
|
||||
to_node: process
|
||||
condition: conditional
|
||||
condition_expr: "str(next_action).lower() == 'done'"
|
||||
|
||||
entry_node: process
|
||||
terminal_nodes: [] # [] = forever-alive
|
||||
```
|
||||
|
||||
## Key differences from Python templates
|
||||
|
||||
| Before (Python) | After (YAML) |
|
||||
|-------------------------------------|----------------------------------------|
|
||||
| `agent.py` (250 lines boilerplate) | Not needed |
|
||||
| `config.py` (dataclass + metadata) | `variables:` + `metadata:` in YAML |
|
||||
| `nodes/__init__.py` (NodeSpec calls)| `nodes:` list in YAML |
|
||||
| `__init__.py`, `__main__.py` | Not needed |
|
||||
| f-string config injection | `{{variable_name}}` templates |
|
||||
| `mcp_servers.json` (separate file) | `mcp_servers:` in YAML (or keep file) |
|
||||
|
||||
## Node types
|
||||
|
||||
| Type | Description | Tools |
|
||||
|--------------|---------------------------------------|--------------------------|
|
||||
| `event_loop` | LLM-driven orchestration (default) | Explicit list or `none` |
|
||||
| `gcu` | Browser automation via GCU tools | `policy: all` (auto) |
|
||||
|
||||
## Tool access policies
|
||||
|
||||
```yaml
|
||||
# Explicit list (recommended for most nodes)
|
||||
tools:
|
||||
policy: explicit
|
||||
allowed: [web_search, save_data]
|
||||
|
||||
# All tools (for browser automation nodes)
|
||||
tools:
|
||||
policy: all
|
||||
|
||||
# No tools (for handoff/summary nodes)
|
||||
tools:
|
||||
policy: none
|
||||
```
|
||||
|
||||
## Edge conditions
|
||||
|
||||
| Condition | When to use |
|
||||
|---------------|-------------------------------------------------------|
|
||||
| `on_success` | Default. Next node after current succeeds. |
|
||||
| `on_failure` | Fallback path when current node fails. |
|
||||
| `always` | Always traverse regardless of outcome. |
|
||||
| `conditional` | Evaluate `condition_expr` against shared memory keys. |
|
||||
| `llm_decide` | Let the LLM decide at runtime. |
|
||||
|
||||
## Template variables
|
||||
|
||||
Use `{{variable_name}}` in `system_prompt` and `identity_prompt`.
|
||||
Variables are defined in the top-level `variables:` map.
|
||||
|
||||
```yaml
|
||||
variables:
|
||||
spreadsheet_id: "1ZVxWDL..."
|
||||
api_endpoint: "https://api.example.com"
|
||||
|
||||
nodes:
|
||||
- id: start
|
||||
system_prompt: |
|
||||
Connect to spreadsheet: {{spreadsheet_id}}
|
||||
API endpoint: {{api_endpoint}}
|
||||
```
|
||||
|
||||
## Entry points
|
||||
|
||||
Default is a single manual entry point. For timer/scheduled triggers:
|
||||
|
||||
```yaml
|
||||
entry_points:
|
||||
- id: default
|
||||
trigger_type: manual
|
||||
- id: daily-check
|
||||
trigger_type: timer
|
||||
trigger_config:
|
||||
interval_minutes: 30
|
||||
```
|
||||
|
||||
## mcp_servers.json -- Still Supported
|
||||
|
||||
The `mcp_servers.json` file is still loaded automatically if present alongside
|
||||
`agent.yaml`. You can also inline servers in the YAML:
|
||||
|
||||
```yaml
|
||||
mcp_servers:
|
||||
- name: hive_tools
|
||||
- name: gcu-tools
|
||||
```
|
||||
|
||||
Both approaches work. The JSON file takes precedence for backward compatibility.
|
||||
|
||||
## Migration from Python agents
|
||||
|
||||
Run the migration tool to convert existing agents:
|
||||
|
||||
```bash
|
||||
uv run python -m framework.tools.migrate_agent exports/my_agent
|
||||
```
|
||||
|
||||
This generates `agent.yaml` from the existing `agent.py` + `nodes/` + `config.py`.
|
||||
The original files are left untouched. Once verified, you can delete the Python files.
|
||||
|
||||
## Files after migration
|
||||
|
||||
```
|
||||
my_agent/
|
||||
agent.yaml # The only required file
|
||||
mcp_servers.json # Optional (can inline in YAML)
|
||||
flowchart.json # Optional (auto-generated)
|
||||
```
|
||||
@@ -1,306 +1,193 @@
|
||||
# Hive Agent Framework — Condensed Reference
|
||||
# Hive Agent Framework -- Condensed Reference
|
||||
|
||||
## Architecture
|
||||
|
||||
Agents are Python packages in `exports/`:
|
||||
Agents are declarative JSON configs in `exports/`:
|
||||
```
|
||||
exports/my_agent/
|
||||
├── __init__.py # MUST re-export ALL module-level vars from agent.py
|
||||
├── __main__.py # CLI (run, tui, info, validate, shell)
|
||||
├── agent.py # Graph construction (goal, edges, agent class)
|
||||
├── config.py # Runtime config
|
||||
├── nodes/__init__.py # Node definitions (NodeSpec)
|
||||
├── mcp_servers.json # MCP tool server config
|
||||
└── tests/ # pytest tests
|
||||
agent.json # The entire agent definition
|
||||
mcp_servers.json # MCP tool server config (optional, prefer registry refs)
|
||||
```
|
||||
|
||||
## Agent Loading Contract
|
||||
No Python files. No `__init__.py`, `__main__.py`, `config.py`, or `nodes/`.
|
||||
|
||||
`AgentRunner.load()` imports the package (`__init__.py`) and reads these
|
||||
module-level variables via `getattr()`:
|
||||
## Agent Loading
|
||||
|
||||
| Variable | Required | Default if missing | Consequence |
|
||||
|----------|----------|--------------------|-------------|
|
||||
| `goal` | YES | `None` | **FATAL** — "must define goal, nodes, edges" |
|
||||
| `nodes` | YES | `None` | **FATAL** — same error |
|
||||
| `edges` | YES | `None` | **FATAL** — same error |
|
||||
| `entry_node` | no | `nodes[0].id` | Probably wrong node |
|
||||
| `entry_points` | no | `{}` | **Nodes unreachable** — validation fails |
|
||||
| `terminal_nodes` | **YES** | `[]` | **FATAL** — graph must have at least one terminal node |
|
||||
| `pause_nodes` | no | `[]` | OK |
|
||||
| `conversation_mode` | no | not passed | Isolated mode (no context carryover) |
|
||||
| `identity_prompt` | no | not passed | No agent-level identity |
|
||||
| `loop_config` | no | `{}` | No iteration limits |
|
||||
| `triggers.json` (file) | no | not present | No triggers (timers, webhooks) |
|
||||
`AgentLoader.load()` reads `agent.json` and builds the execution graph.
|
||||
If `agent.py` exists (legacy), it's loaded as a Python module instead.
|
||||
|
||||
**CRITICAL:** `__init__.py` MUST import and re-export ALL of these from
|
||||
`agent.py`. Missing exports silently fall back to defaults, causing
|
||||
hard-to-debug failures.
|
||||
## agent.json Schema
|
||||
|
||||
**Why `default_agent.validate()` is NOT sufficient:**
|
||||
`validate()` checks the agent CLASS's internal graph (self.nodes, self.edges).
|
||||
These are always correct because the constructor references agent.py's module
|
||||
vars directly. But `AgentRunner.load()` reads from the PACKAGE (`__init__.py`),
|
||||
not the class. So `validate()` passes while `AgentRunner.load()` fails.
|
||||
Always test with `AgentRunner.load("exports/{name}")` — this is the same
|
||||
code path the TUI and `hive run` use.
|
||||
|
||||
## Goal
|
||||
|
||||
Defines success criteria and constraints:
|
||||
```python
|
||||
goal = Goal(
|
||||
id="kebab-case-id",
|
||||
name="Display Name",
|
||||
description="What the agent does",
|
||||
success_criteria=[
|
||||
SuccessCriterion(id="sc-id", description="...", metric="...", target="...", weight=0.25),
|
||||
],
|
||||
constraints=[
|
||||
Constraint(id="c-id", description="...", constraint_type="hard", category="quality"),
|
||||
],
|
||||
)
|
||||
```json
|
||||
{
|
||||
"name": "my-agent",
|
||||
"version": "1.0.0",
|
||||
"description": "What this agent does",
|
||||
"goal": {
|
||||
"description": "What to achieve",
|
||||
"success_criteria": ["criterion 1", "criterion 2"],
|
||||
"constraints": ["constraint 1"]
|
||||
},
|
||||
"identity_prompt": "You are a helpful agent.",
|
||||
"conversation_mode": "continuous",
|
||||
"loop_config": {
|
||||
"max_iterations": 100,
|
||||
"max_tool_calls_per_turn": 30,
|
||||
"max_context_tokens": 32000
|
||||
},
|
||||
"mcp_servers": [
|
||||
{"name": "hive_tools"},
|
||||
{"name": "gcu-tools"}
|
||||
],
|
||||
"variables": {
|
||||
"spreadsheet_id": "1ZVx..."
|
||||
},
|
||||
"nodes": [...],
|
||||
"edges": [...],
|
||||
"entry_node": "process",
|
||||
"terminal_nodes": []
|
||||
}
|
||||
```
|
||||
- 3-5 success criteria, weights sum to 1.0
|
||||
- 1-5 constraints (hard/soft, categories: quality, accuracy, interaction, functional)
|
||||
|
||||
## NodeSpec Fields
|
||||
## Template Variables
|
||||
|
||||
Use `{{variable_name}}` in `system_prompt` and `identity_prompt`. Variables
|
||||
are defined in the top-level `variables` object:
|
||||
|
||||
```json
|
||||
{
|
||||
"variables": {"sheet_id": "1ZVx..."},
|
||||
"nodes": [{
|
||||
"id": "start",
|
||||
"system_prompt": "Use sheet: {{sheet_id}}"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
## Node Fields
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| id | str | required | kebab-case identifier |
|
||||
| name | str | required | Display name |
|
||||
| name | str | id | Display name |
|
||||
| description | str | required | What the node does |
|
||||
| node_type | str | required | `"event_loop"` or `"gcu"` (browser automation — see GCU Guide appendix) |
|
||||
| input_keys | list[str] | required | Memory keys this node reads |
|
||||
| output_keys | list[str] | required | Memory keys this node writes via set_output |
|
||||
| node_type | str | "event_loop" | `"event_loop"` |
|
||||
| input_keys | list | [] | Memory keys this node reads |
|
||||
| output_keys | list | [] | Memory keys this node writes via set_output |
|
||||
| system_prompt | str | "" | LLM instructions |
|
||||
| tools | list[str] | [] | Tool names from MCP servers |
|
||||
| client_facing | bool | False | Deprecated compatibility field. Queen interactivity is implicit; workers should escalate instead |
|
||||
| nullable_output_keys | list[str] | [] | Keys that may remain unset |
|
||||
| max_node_visits | int | 0 | 0=unlimited (default); >1 for one-shot feedback loops |
|
||||
| max_retries | int | 3 | Retries on failure |
|
||||
| tools | object | {} | Tool access policy (see below) |
|
||||
| nullable_output_keys | list | [] | Keys that may remain unset |
|
||||
| max_node_visits | int | 1 | 0=unlimited (for forever-alive agents) |
|
||||
| success_criteria | str | "" | Natural language for judge evaluation |
|
||||
| client_facing | bool | false | Whether output is shown to user |
|
||||
|
||||
## EdgeSpec Fields
|
||||
## Tool Access Policies
|
||||
|
||||
Each node declares its tools via a policy object:
|
||||
|
||||
```json
|
||||
{"tools": {"policy": "explicit", "allowed": ["web_search", "save_data"]}}
|
||||
{"tools": {"policy": "all"}}
|
||||
{"tools": {"policy": "none"}}
|
||||
```
|
||||
|
||||
- `explicit` (default): only named tools. Empty `allowed` = zero tools.
|
||||
- `all`: all tools from registry (e.g. for browser automation nodes).
|
||||
- `none`: no tools (for handoff/summary nodes).
|
||||
|
||||
## Edge Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| id | str | kebab-case identifier |
|
||||
| source | str | Source node ID |
|
||||
| target | str | Target node ID |
|
||||
| condition | EdgeCondition | ON_SUCCESS, ON_FAILURE, ALWAYS, CONDITIONAL |
|
||||
| condition_expr | str | Python expression evaluated against memory (for CONDITIONAL) |
|
||||
| priority | int | Positive=forward (evaluated first), negative=feedback (loop-back) |
|
||||
| from_node | str | Source node ID |
|
||||
| to_node | str | Target node ID |
|
||||
| condition | str | `on_success`, `on_failure`, `always`, `conditional` |
|
||||
| condition_expr | str | Python expression for conditional routing |
|
||||
| priority | int | Higher = evaluated first |
|
||||
|
||||
condition_expr examples:
|
||||
- `"needs_more_research == True"`
|
||||
- `"str(next_action).lower() == 'revise'"`
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### STEP 1/STEP 2 (Client-Facing Nodes)
|
||||
```
|
||||
**STEP 1 — Respond to the user (text only, NO tool calls):**
|
||||
[Present information, ask questions]
|
||||
|
||||
**STEP 2 — After the user responds, call set_output:**
|
||||
- set_output("key", "value based on user response")
|
||||
```
|
||||
This prevents premature set_output before user interaction.
|
||||
|
||||
### Fewer, Richer Nodes (CRITICAL)
|
||||
|
||||
**Hard limit: 3-6 nodes for most agents.** Never exceed 6 unless the user
|
||||
explicitly requests a complex multi-phase pipeline.
|
||||
**Hard limit: 3-6 nodes for most agents.** Each node boundary serializes
|
||||
outputs and destroys in-context information. Merge unless:
|
||||
1. Client-facing boundary (different interaction models)
|
||||
2. Disjoint tool sets
|
||||
3. Parallel execution (fan-out branches)
|
||||
|
||||
Each node boundary serializes outputs to the shared buffer and **destroys** all
|
||||
in-context information: tool call results, intermediate reasoning, conversation
|
||||
history. A research node that searches, fetches, and analyzes in ONE node keeps
|
||||
all source material in its conversation context. Split across 3 nodes, each
|
||||
downstream node only sees the serialized summary string.
|
||||
|
||||
**Decision framework — merge unless ANY of these apply:**
|
||||
1. **Client-facing boundary** — Autonomous and client-facing work MUST be
|
||||
separate nodes (different interaction models)
|
||||
2. **Disjoint tool sets** — If tools are fundamentally different (e.g., web
|
||||
search vs database), separate nodes make sense
|
||||
3. **Parallel execution** — Fan-out branches must be separate nodes
|
||||
|
||||
**Red flags that you have too many nodes:**
|
||||
- A node with 0 tools (pure LLM reasoning) → merge into predecessor/successor
|
||||
- A node that sets only 1 trivial output → collapse into predecessor
|
||||
- Multiple consecutive autonomous nodes → combine into one rich node
|
||||
- A "report" node that presents analysis → merge into the client-facing node
|
||||
- A "confirm" or "schedule" node that doesn't call any external service → remove
|
||||
|
||||
**Typical agent structure (2 nodes):**
|
||||
**Typical structure (2 nodes):**
|
||||
```
|
||||
process (autonomous) ←→ review (queen-mediated)
|
||||
```
|
||||
The queen owns intake — she gathers requirements from the user, then
|
||||
passes structured input via `run_agent_with_input(task)`. When building
|
||||
the agent, design the entry node's `input_keys` to match what the queen
|
||||
will provide at run time. Worker agents should NOT have a client-facing
|
||||
intake node. Mid-execution review/approval should happen through queen
|
||||
escalation rather than direct worker HITL.
|
||||
|
||||
For simpler agents, just 1 autonomous node:
|
||||
```
|
||||
process (autonomous) — loops back to itself
|
||||
process (autonomous) <-> review (queen-mediated)
|
||||
```
|
||||
|
||||
### nullable_output_keys
|
||||
For inputs that only arrive on certain edges:
|
||||
```python
|
||||
research_node = NodeSpec(
|
||||
input_keys=["brief", "feedback"],
|
||||
nullable_output_keys=["feedback"], # Only present on feedback edge
|
||||
max_node_visits=3,
|
||||
)
|
||||
```
|
||||
|
||||
### Mutually Exclusive Outputs
|
||||
For routing decisions:
|
||||
```python
|
||||
review_node = NodeSpec(
|
||||
output_keys=["approved", "feedback"],
|
||||
nullable_output_keys=["approved", "feedback"], # Node sets one or the other
|
||||
)
|
||||
```
|
||||
|
||||
### Continuous Loop Pattern
|
||||
Mark the primary event_loop node as terminal: `terminal_nodes=["process"]`.
|
||||
The node has `output_keys` and can complete when the agent finishes its work.
|
||||
Use `conversation_mode="continuous"` to preserve context across transitions.
|
||||
The queen owns intake. Worker agents should NOT have a client-facing intake
|
||||
node. Mid-execution review should happen through queen escalation.
|
||||
|
||||
### set_output
|
||||
- Synthetic tool injected by framework
|
||||
- Call separately from real tool calls (separate turn)
|
||||
- `set_output("key", "value")` stores to the shared buffer
|
||||
|
||||
## Edge Conditions
|
||||
|
||||
| Condition | When |
|
||||
|-----------|------|
|
||||
| ON_SUCCESS | Node completed successfully |
|
||||
| ON_FAILURE | Node failed |
|
||||
| ALWAYS | Unconditional |
|
||||
| CONDITIONAL | condition_expr evaluates to True against memory |
|
||||
|
||||
condition_expr examples:
|
||||
- `"needs_more_research == True"`
|
||||
- `"str(next_action).lower() == 'new_agent'"`
|
||||
- `"feedback is not None"`
|
||||
|
||||
## Graph Lifecycle
|
||||
### Graph Lifecycle
|
||||
|
||||
| Pattern | terminal_nodes | When |
|
||||
|---------|---------------|------|
|
||||
| **Continuous loop** | `["node-with-output-keys"]` | **DEFAULT for all agents** |
|
||||
| Continuous loop | `["node-with-output-keys"]` | DEFAULT for all agents |
|
||||
| Linear | `["last-node"]` | One-shot/batch agents |
|
||||
|
||||
**Every graph must have at least one terminal node.** Terminal nodes
|
||||
define where execution ends. For interactive agents that loop continuously,
|
||||
mark the primary event_loop node as terminal (it has `output_keys` and can
|
||||
complete at any point). The framework default for `max_node_visits` is 0
|
||||
(unbounded), so nodes work correctly in continuous loops without explicit
|
||||
override. Only set `max_node_visits > 0` in one-shot agents with feedback loops.
|
||||
Every node must have at least one outgoing edge — no dead ends.
|
||||
Every graph must have at least one terminal node.
|
||||
|
||||
## Continuous Conversation Mode
|
||||
### Continuous Conversation Mode
|
||||
|
||||
`conversation_mode` has ONLY two valid states:
|
||||
- `"continuous"` — recommended for interactive agents
|
||||
- Omit entirely — isolated per-node conversations (each node starts fresh)
|
||||
- `"continuous"` -- recommended (context carries across node transitions)
|
||||
- Omit entirely -- isolated per-node conversations
|
||||
|
||||
**INVALID values** (do NOT use): `"client_facing"`, `"interactive"`,
|
||||
`"adaptive"`, `"shared"`. These do not exist in the framework.
|
||||
|
||||
When `conversation_mode="continuous"`:
|
||||
- Same conversation thread carries across node transitions
|
||||
- Layered system prompts: identity (agent-level) + narrative + focus (per-node)
|
||||
- Transition markers inserted at boundaries
|
||||
- Compaction happens opportunistically at phase transitions
|
||||
**INVALID values:** `"client_facing"`, `"interactive"`, `"shared"`.
|
||||
|
||||
## loop_config
|
||||
|
||||
Only three valid keys:
|
||||
```python
|
||||
loop_config = {
|
||||
"max_iterations": 100, # Max LLM turns per node visit
|
||||
"max_tool_calls_per_turn": 20, # Max tool calls per LLM response
|
||||
"max_context_tokens": 32000, # Triggers conversation compaction
|
||||
```json
|
||||
{
|
||||
"max_iterations": 100,
|
||||
"max_tool_calls_per_turn": 20,
|
||||
"max_context_tokens": 32000
|
||||
}
|
||||
```
|
||||
**INVALID keys** (do NOT use): `"strategy"`, `"mode"`, `"timeout"`,
|
||||
`"temperature"`. These are silently ignored or cause errors.
|
||||
|
||||
## Data Tools (Spillover)
|
||||
|
||||
For large data that exceeds context:
|
||||
- `save_data(filename, data)` — Write to session data dir
|
||||
- `load_data(filename, offset, limit)` — Read with pagination
|
||||
- `list_data_files()` — List files
|
||||
- `serve_file_to_user(filename, label)` — Clickable file:// URI
|
||||
- `save_data(filename, data)` -- write to session data dir
|
||||
- `load_data(filename, offset, limit)` -- read with pagination
|
||||
- `list_data_files()` -- list files
|
||||
- `serve_file_to_user(filename, label)` -- clickable file URI
|
||||
|
||||
`data_dir` is auto-injected by framework — LLM never sees it.
|
||||
`data_dir` is auto-injected by framework.
|
||||
|
||||
## Fan-Out / Fan-In
|
||||
|
||||
Multiple ON_SUCCESS edges from same source → parallel execution via asyncio.gather().
|
||||
- Parallel nodes must have disjoint output_keys
|
||||
- Only one branch may have client_facing nodes
|
||||
- Fan-in node gets all outputs in the shared buffer
|
||||
Multiple `on_success` edges from same source = parallel execution.
|
||||
Parallel nodes must have disjoint output_keys.
|
||||
|
||||
## Judge System
|
||||
|
||||
- **Implicit** (default): ACCEPTs when LLM finishes with no tool calls and all required outputs set
|
||||
- **SchemaJudge**: Validates against Pydantic model
|
||||
- **Custom**: Implement `evaluate(context) -> JudgeVerdict`
|
||||
|
||||
Judge is the SOLE acceptance mechanism — no ad-hoc framework gating.
|
||||
|
||||
## Triggers (Timers, Webhooks)
|
||||
|
||||
For agents that react to external events, create a `triggers.json` file
|
||||
in the agent's export directory:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "daily-check",
|
||||
"name": "Daily Check",
|
||||
"trigger_type": "timer",
|
||||
"trigger_config": {"cron": "0 9 * * *"},
|
||||
"task": "Run the daily check process"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Key Fields
|
||||
- `trigger_type`: `"timer"` or `"webhook"`
|
||||
- `trigger_config`: `{"cron": "0 9 * * *"}` or `{"interval_minutes": 20}`
|
||||
- `task`: describes what the worker should do when the trigger fires
|
||||
- Triggers can also be created/removed at runtime via `set_trigger` / `remove_trigger` queen tools
|
||||
|
||||
## Tool Discovery
|
||||
|
||||
Do NOT rely on a static tool list — it will be outdated. Always call
|
||||
`list_agent_tools()` with NO arguments first to see ALL available tools.
|
||||
Only use `group=` or `output_schema=` as follow-up calls after seeing the
|
||||
full list.
|
||||
Always call `list_agent_tools()` first to see available tools.
|
||||
Do NOT rely on a static tool list.
|
||||
|
||||
```
|
||||
list_agent_tools() # ALWAYS call this first
|
||||
list_agent_tools(group="gmail", output_schema="full") # then drill into a category
|
||||
list_agent_tools("exports/my_agent/mcp_servers.json") # specific agent's tools
|
||||
list_agent_tools() # full summary
|
||||
list_agent_tools(group="gmail", output_schema="full") # drill into category
|
||||
```
|
||||
|
||||
After building, run `validate_agent_package("{name}")` to check everything at once.
|
||||
|
||||
Common tool categories (verify via list_agent_tools):
|
||||
- **Web**: search, scrape, PDF
|
||||
- **Data**: save/load/append/list data files, serve to user
|
||||
- **File**: view, write, replace, diff, list, grep
|
||||
- **Communication**: email, gmail, slack, telegram
|
||||
- **CRM**: hubspot, apollo, calcom
|
||||
- **GitHub**: stargazers, user profiles, repos
|
||||
- **Vision**: image analysis
|
||||
- **Time**: current time
|
||||
After building, run `validate_agent_package("{name}")` to check everything.
|
||||
|
||||
@@ -1,158 +1,78 @@
|
||||
# GCU Browser Automation Guide
|
||||
# Browser Automation Guide
|
||||
|
||||
## When to Use GCU Nodes
|
||||
## When to Use Browser Nodes
|
||||
|
||||
Use `node_type="gcu"` when:
|
||||
- The user's workflow requires **navigating real websites** (scraping, form-filling, social media interaction, testing web UIs)
|
||||
- The task involves **dynamic/JS-rendered pages** that `web_scrape` cannot handle (SPAs, infinite scroll, login-gated content)
|
||||
- The agent needs to **interact with a website** — clicking, typing, scrolling, selecting, uploading files
|
||||
Use browser nodes (with `tools: {policy: "all"}`) when:
|
||||
- The task requires interacting with web pages (clicking, typing, navigating)
|
||||
- No API is available for the target service
|
||||
- The user is already logged in to the target site
|
||||
|
||||
Do NOT use GCU for:
|
||||
- Static content that `web_scrape` handles fine
|
||||
- API-accessible data (use the API directly)
|
||||
- PDF/file processing
|
||||
- Anything that doesn't require a browser UI
|
||||
## What Browser Nodes Are
|
||||
|
||||
## What GCU Nodes Are
|
||||
- Regular `event_loop` nodes with browser tools from gcu-tools MCP server
|
||||
- Set `tools: {policy: "all"}` to give access to all browser tools
|
||||
- Wire into the graph with edges like any other node
|
||||
- No special node_type needed
|
||||
|
||||
- `node_type="gcu"` — a declarative enhancement over `event_loop`
|
||||
- Framework auto-prepends browser best-practices system prompt
|
||||
- Framework auto-includes all 31 browser tools from `gcu-tools` MCP server
|
||||
- Same underlying `EventLoopNode` class — no new imports needed
|
||||
- `tools=[]` is correct — tools are auto-populated at runtime
|
||||
## Available Browser Tools
|
||||
|
||||
## GCU Architecture Pattern
|
||||
All tools are prefixed with `browser_`:
|
||||
- `browser_start`, `browser_open`, `browser_navigate` — launch/navigate
|
||||
- `browser_click`, `browser_click_coordinate`, `browser_fill`, `browser_type` — interact
|
||||
- `browser_press` (with optional `modifiers=["ctrl"]` etc.) — keyboard shortcuts
|
||||
- `browser_snapshot` — compact accessibility-tree read (structured)
|
||||
- `browser_screenshot` — visual capture (annotated PNG)
|
||||
- `browser_shadow_query`, `browser_get_rect` — locate elements (shadow-piercing via `>>>`)
|
||||
- `browser_coords` — convert image pixels to CSS pixels (always use `css_x/y`, never `physical_x/y`)
|
||||
- `browser_scroll`, `browser_wait` — navigation helpers
|
||||
- `browser_evaluate` — run JavaScript
|
||||
- `browser_close`, `browser_close_finished` — tab cleanup
|
||||
|
||||
GCU nodes are **subagents** — invoked via `delegate_to_sub_agent()`, not connected via edges.
|
||||
## Pick the right reading tool
|
||||
|
||||
- Primary nodes (`event_loop`, client-facing) orchestrate; GCU nodes do browser work
|
||||
- Parent node declares `sub_agents=["gcu-node-id"]` and calls `delegate_to_sub_agent(agent_id="gcu-node-id", task="...")`
|
||||
- GCU nodes set `max_node_visits=1` (single execution per delegation), `client_facing=False`
|
||||
- GCU nodes use `output_keys=["result"]` and return structured JSON via `set_output("result", ...)`
|
||||
**`browser_snapshot`** — compact accessibility tree of interactive elements. Fast, cheap, good for static or form-heavy pages where the DOM matches what's visually rendered (documentation, simple dashboards, search results, settings pages).
|
||||
|
||||
## GCU Node Definition Template
|
||||
**`browser_screenshot`** — visual capture + metadata (`cssWidth`, `devicePixelRatio`, scale fields). **Use this on any complex SPA** — LinkedIn, Twitter/X, Reddit, Gmail, Notion, Slack, Discord, any site using shadow DOM, virtual scrolling, React reconciliation, or dynamic layout. On these pages, snapshot refs go stale in seconds, shadow contents aren't in the AX tree, and virtual-scrolled elements disappear from the tree entirely. Screenshot is the **only** reliable way to orient yourself.
|
||||
|
||||
```python
|
||||
gcu_browser_node = NodeSpec(
|
||||
id="gcu-browser-worker",
|
||||
name="Browser Worker",
|
||||
description="Browser subagent that does X.",
|
||||
node_type="gcu",
|
||||
client_facing=False,
|
||||
max_node_visits=1,
|
||||
input_keys=[],
|
||||
output_keys=["result"],
|
||||
tools=[], # Auto-populated with all browser tools
|
||||
system_prompt="""\
|
||||
You are a browser agent. Your job: [specific task].
|
||||
Neither tool is "preferred" universally — they're for different jobs. Default to snapshot on text-heavy static pages, screenshot on SPAs and anything shadow-DOM-heavy. Activate the `browser-automation` skill for the full decision tree.
|
||||
|
||||
## Workflow
|
||||
1. browser_start (only if no browser is running yet)
|
||||
2. browser_open(url=TARGET_URL) — note the returned targetId
|
||||
3. browser_snapshot to read the page
|
||||
4. [task-specific steps]
|
||||
5. set_output("result", JSON)
|
||||
## Coordinate rule: always CSS pixels
|
||||
|
||||
## Output format
|
||||
set_output("result", JSON) with:
|
||||
- [field]: [type and description]
|
||||
""",
|
||||
)
|
||||
Chrome DevTools Protocol `Input.dispatchMouseEvent` takes **CSS pixels**, not physical pixels. After a screenshot, use `browser_coords(image_x, image_y)` and feed the returned `css_x/y` (NOT `physical_x/y`) to `browser_click_coordinate`, `browser_hover_coordinate`, `browser_press_at`. Feeding physical pixels on a HiDPI display (DPR=1.6, 2, or 3) overshoots by `DPR×` and clicks land in the wrong place. `getBoundingClientRect()` already returns CSS pixels — pass through unchanged, no DPR multiplication.
|
||||
|
||||
## System prompt tips for browser nodes
|
||||
|
||||
```
|
||||
1. On LinkedIn / X / Reddit / Gmail / any SPA — use browser_screenshot to orient,
|
||||
not browser_snapshot. Shadow DOM and virtual scrolling make snapshots unreliable.
|
||||
2. For static pages (docs, forms, search results), browser_snapshot is fine.
|
||||
3. Before typing into a rich-text editor (X compose, LinkedIn DM, Gmail, Reddit),
|
||||
click the input area first with browser_click_coordinate so React / Draft.js /
|
||||
Lexical register a native focus event. Otherwise the send button stays disabled.
|
||||
4. Use browser_wait(seconds=2-3) after navigation for SPA hydration.
|
||||
5. If you hit an auth wall, call set_output with an error and move on.
|
||||
6. Keep tool calls per turn <= 10 for reliability.
|
||||
```
|
||||
|
||||
## Parent Node Template (orchestrating GCU subagents)
|
||||
|
||||
```python
|
||||
orchestrator_node = NodeSpec(
|
||||
id="orchestrator",
|
||||
...
|
||||
node_type="event_loop",
|
||||
sub_agents=["gcu-browser-worker"],
|
||||
system_prompt="""\
|
||||
...
|
||||
delegate_to_sub_agent(
|
||||
agent_id="gcu-browser-worker",
|
||||
task="Navigate to [URL]. Do [specific task]. Return JSON with [fields]."
|
||||
)
|
||||
...
|
||||
""",
|
||||
tools=[], # Orchestrator doesn't need browser tools
|
||||
)
|
||||
```
|
||||
|
||||
## mcp_servers.json with GCU
|
||||
## Example
|
||||
|
||||
```json
|
||||
{
|
||||
"hive-tools": { ... },
|
||||
"gcu-tools": {
|
||||
"transport": "stdio",
|
||||
"command": "uv",
|
||||
"args": ["run", "python", "-m", "gcu.server", "--stdio"],
|
||||
"cwd": "../../tools",
|
||||
"description": "GCU tools for browser automation"
|
||||
}
|
||||
"id": "scan-profiles",
|
||||
"name": "Scan LinkedIn Profiles",
|
||||
"description": "Navigate LinkedIn search results and collect profile data",
|
||||
"tools": {"policy": "all"},
|
||||
"input_keys": ["search_url"],
|
||||
"output_keys": ["profiles"],
|
||||
"system_prompt": "Navigate to the search URL via browser_navigate(wait_until='load', timeout_ms=20000). Wait 3s for SPA hydration. On LinkedIn, use browser_screenshot to see the page — browser_snapshot misses shadow-DOM and virtual-scrolled content. Paginate through results by scrolling and screenshotting; extract each profile card by reading its visible layout..."
|
||||
}
|
||||
```
|
||||
|
||||
Note: `gcu-tools` is auto-added if any node uses `node_type="gcu"`, but including it explicitly is fine.
|
||||
|
||||
## GCU System Prompt Best Practices
|
||||
|
||||
Key rules to bake into GCU node prompts:
|
||||
|
||||
- Prefer `browser_snapshot` over `browser_get_text("body")` — compact accessibility tree vs 100KB+ raw HTML
|
||||
- Always `browser_wait` after navigation
|
||||
- Use large scroll amounts (~2000-5000) for lazy-loaded content
|
||||
- For spillover files, use `run_command` with grep, not `read_file`
|
||||
- If auth wall detected, report immediately — don't attempt login
|
||||
- Keep tool calls per turn ≤10
|
||||
- Tab isolation: when browser is already running, use `browser_open(background=true)` and pass `target_id` to every call
|
||||
|
||||
## Multiple Concurrent GCU Subagents
|
||||
|
||||
When a task can be parallelized across multiple sites or profiles, declare a distinct GCU
|
||||
node for each and invoke them all in the same LLM turn. The framework batches all
|
||||
`delegate_to_sub_agent` calls made in one turn and runs them with `asyncio.gather`, so
|
||||
they execute concurrently — not sequentially.
|
||||
|
||||
**Each GCU subagent automatically gets its own isolated browser context** — no `profile=`
|
||||
argument is needed in tool calls. The framework derives a unique profile from the subagent's
|
||||
node ID and instance counter and injects it via an asyncio `ContextVar` before the subagent
|
||||
runs.
|
||||
|
||||
### Example: three sites in parallel
|
||||
|
||||
```python
|
||||
# Three distinct GCU nodes
|
||||
gcu_site_a = NodeSpec(id="gcu-site-a", node_type="gcu", ...)
|
||||
gcu_site_b = NodeSpec(id="gcu-site-b", node_type="gcu", ...)
|
||||
gcu_site_c = NodeSpec(id="gcu-site-c", node_type="gcu", ...)
|
||||
|
||||
orchestrator = NodeSpec(
|
||||
id="orchestrator",
|
||||
node_type="event_loop",
|
||||
sub_agents=["gcu-site-a", "gcu-site-b", "gcu-site-c"],
|
||||
system_prompt="""\
|
||||
Call all three subagents in a single response to run them in parallel:
|
||||
delegate_to_sub_agent(agent_id="gcu-site-a", task="Scrape prices from site A")
|
||||
delegate_to_sub_agent(agent_id="gcu-site-b", task="Scrape prices from site B")
|
||||
delegate_to_sub_agent(agent_id="gcu-site-c", task="Scrape prices from site C")
|
||||
""",
|
||||
)
|
||||
Connected via regular edges:
|
||||
```
|
||||
search-setup -> scan-profiles -> process-results
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Use distinct node IDs for each concurrent task — sharing an ID shares the browser context.
|
||||
- The GCU node prompts do not need to mention `profile=`; isolation is automatic.
|
||||
- Cleanup is automatic at session end, but GCU nodes can call `browser_stop()` explicitly
|
||||
if they want to release resources mid-run.
|
||||
## Further detail
|
||||
|
||||
## GCU Anti-Patterns
|
||||
|
||||
- Using `browser_screenshot` to read text (use `browser_snapshot` instead; screenshots are for visual context only)
|
||||
- Re-navigating after scrolling (resets scroll position)
|
||||
- Attempting login on auth walls
|
||||
- Forgetting `target_id` in multi-tab scenarios
|
||||
- Putting browser tools directly on `event_loop` nodes instead of using GCU subagent pattern
|
||||
- Making GCU nodes `client_facing=True` (they should be autonomous subagents)
|
||||
For rich-text editor quirks (Lexical, Draft.js, ProseMirror), shadow-DOM shortcuts, `beforeunload` dialog neutralization, Trusted Types CSP on LinkedIn, keyboard shortcut dispatch, and per-site selector tables — **activate the `browser-automation` skill**. That skill has the full verified guidance and is refreshed against real production sites.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,10 +22,10 @@ def mock_mode():
|
||||
|
||||
@pytest_asyncio.fixture(scope="session")
|
||||
async def runner(tmp_path_factory, mock_mode):
|
||||
from framework.runner.runner import AgentRunner
|
||||
from framework.loader.agent_loader import AgentLoader
|
||||
|
||||
storage = tmp_path_factory.mktemp("agent_storage")
|
||||
r = AgentRunner.load(AGENT_PATH, mock_mode=mock_mode, storage_path=storage)
|
||||
r = AgentLoader.load(AGENT_PATH, mock_mode=mock_mode, storage_path=storage)
|
||||
r._setup()
|
||||
yield r
|
||||
await r.cleanup_async()
|
||||
|
||||
+30
-54
@@ -2,17 +2,22 @@
|
||||
Command-line interface for Aden Hive.
|
||||
|
||||
Usage:
|
||||
hive run exports/my-agent --input '{"key": "value"}'
|
||||
hive info exports/my-agent
|
||||
hive validate exports/my-agent
|
||||
hive list exports/
|
||||
hive shell exports/my-agent
|
||||
hive serve Start the HTTP API server
|
||||
hive open Start the server and open the dashboard
|
||||
hive queen list List queen profiles
|
||||
hive queen show <queen_id> Inspect a queen profile
|
||||
hive queen sessions <queen_id> List a queen's sessions
|
||||
hive colony list List colonies on disk
|
||||
hive colony info <name> Inspect a colony
|
||||
hive colony delete <name> Delete a colony
|
||||
hive session list List live sessions (use --cold for on-disk)
|
||||
hive session stop <session_id> Stop a live session
|
||||
hive chat <session_id> "msg" Send a message to a live queen
|
||||
|
||||
Testing commands:
|
||||
hive test-run <agent_path> --goal <goal_id>
|
||||
hive test-debug <agent_path> <test_name>
|
||||
hive test-list <agent_path>
|
||||
hive test-stats <agent_path>
|
||||
Subsystems:
|
||||
hive skill ... Manage skills (~/.hive/skills/)
|
||||
hive mcp ... Manage MCP servers
|
||||
hive debugger LLM debug log viewer
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -20,86 +25,57 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _configure_paths():
|
||||
"""Auto-configure sys.path so agents in exports/ are discoverable.
|
||||
def _configure_paths() -> None:
|
||||
"""Auto-configure sys.path so the framework is importable from any cwd.
|
||||
|
||||
Resolves the project root by walking up from this file (framework/cli.py lives
|
||||
inside core/framework/) or from CWD, then adds the exports/ directory to sys.path
|
||||
if it exists. This eliminates the need for manual PYTHONPATH configuration.
|
||||
Walks up from this file to find the project root, then ensures
|
||||
`core/` is on sys.path so `framework.*` imports resolve when the
|
||||
package isn't installed via `pip install -e .`.
|
||||
"""
|
||||
# Strategy 1: resolve relative to this file (works when installed via pip install -e core/)
|
||||
framework_dir = Path(__file__).resolve().parent # core/framework/
|
||||
core_dir = framework_dir.parent # core/
|
||||
project_root = core_dir.parent # project root
|
||||
|
||||
# Strategy 2: if project_root doesn't look right, fall back to CWD
|
||||
if not (project_root / "exports").is_dir() and not (project_root / "core").is_dir():
|
||||
if not (project_root / "core").is_dir():
|
||||
project_root = Path.cwd()
|
||||
|
||||
# Add exports/ to sys.path so agents are importable as top-level packages
|
||||
exports_dir = project_root / "exports"
|
||||
if exports_dir.is_dir():
|
||||
exports_str = str(exports_dir)
|
||||
if exports_str not in sys.path:
|
||||
sys.path.insert(0, exports_str)
|
||||
|
||||
# Add examples/templates/ to sys.path so template agents are importable
|
||||
templates_dir = project_root / "examples" / "templates"
|
||||
if templates_dir.is_dir():
|
||||
templates_str = str(templates_dir)
|
||||
if templates_str not in sys.path:
|
||||
sys.path.insert(0, templates_str)
|
||||
|
||||
# Ensure core/ is also in sys.path (for non-editable-install scenarios)
|
||||
core_str = str(project_root / "core")
|
||||
if (project_root / "core").is_dir() and core_str not in sys.path:
|
||||
sys.path.insert(0, core_str)
|
||||
|
||||
# Add core/framework/agents/ so framework agents are importable as top-level packages
|
||||
framework_agents_dir = project_root / "core" / "framework" / "agents"
|
||||
if framework_agents_dir.is_dir():
|
||||
fa_str = str(framework_agents_dir)
|
||||
if fa_str not in sys.path:
|
||||
sys.path.insert(0, fa_str)
|
||||
|
||||
|
||||
def main():
|
||||
def main() -> None:
|
||||
_configure_paths()
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="hive",
|
||||
description="Aden Hive - Build and run goal-driven agents",
|
||||
description="Aden Hive — Queens, colonies, and live agent sessions",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
default="claude-haiku-4-5-20251001",
|
||||
help="Anthropic model to use",
|
||||
help="Default LLM model (Anthropic ID)",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# Register runner commands (run, info, validate, list, shell)
|
||||
from framework.runner.cli import register_commands
|
||||
# Core commands: serve, open, queen, colony, session, chat
|
||||
from framework.loader.cli import register_commands
|
||||
|
||||
register_commands(subparsers)
|
||||
|
||||
# Register testing commands (test-run, test-debug, test-list, test-stats)
|
||||
from framework.testing.cli import register_testing_commands
|
||||
|
||||
register_testing_commands(subparsers)
|
||||
|
||||
# Register skill commands (skill list, skill trust, ...)
|
||||
# Skill management (~/.hive/skills/)
|
||||
from framework.skills.cli import register_skill_commands
|
||||
|
||||
register_skill_commands(subparsers)
|
||||
|
||||
# Register debugger commands (debugger)
|
||||
# LLM debug log viewer
|
||||
from framework.debugger.cli import register_debugger_commands
|
||||
|
||||
register_debugger_commands(subparsers)
|
||||
|
||||
# Register MCP registry commands (mcp install, mcp add, ...)
|
||||
from framework.runner.mcp_registry_cli import register_mcp_commands
|
||||
# MCP server registry
|
||||
from framework.loader.mcp_registry_cli import register_mcp_commands
|
||||
|
||||
register_mcp_commands(subparsers)
|
||||
|
||||
|
||||
+115
-14
@@ -12,13 +12,47 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from framework.graph.edge import DEFAULT_MAX_TOKENS
|
||||
DEFAULT_MAX_TOKENS = 8192
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hive home directory structure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HIVE_HOME = Path.home() / ".hive"
|
||||
QUEENS_DIR = HIVE_HOME / "agents" / "queens"
|
||||
COLONIES_DIR = HIVE_HOME / "colonies"
|
||||
MEMORIES_DIR = HIVE_HOME / "memories"
|
||||
|
||||
|
||||
def queen_dir(queen_name: str = "default") -> Path:
|
||||
"""Return the storage directory for a named queen agent."""
|
||||
return QUEENS_DIR / queen_name
|
||||
|
||||
|
||||
def colony_dir(colony_name: str) -> Path:
|
||||
"""Return the directory for a named colony."""
|
||||
return COLONIES_DIR / colony_name
|
||||
|
||||
|
||||
def memory_dir(scope: str, name: str | None = None) -> Path:
|
||||
"""Return memory dir for a scope.
|
||||
|
||||
Examples::
|
||||
|
||||
memory_dir("global") -> ~/.hive/memories/global
|
||||
memory_dir("colonies", "my_agent") -> ~/.hive/memories/colonies/my_agent
|
||||
memory_dir("agents/queens", "default")-> ~/.hive/memories/agents/queens/default
|
||||
memory_dir("agents", "worker_name") -> ~/.hive/memories/agents/worker_name
|
||||
"""
|
||||
base = MEMORIES_DIR / scope
|
||||
return base / name if name else base
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Low-level config file access
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HIVE_CONFIG_FILE = Path.home() / ".hive" / "configuration.json"
|
||||
HIVE_CONFIG_FILE = HIVE_HOME / "configuration.json"
|
||||
|
||||
# Hive LLM router endpoint (Anthropic-compatible).
|
||||
# litellm's Anthropic handler appends /v1/messages, so this is just the base host.
|
||||
@@ -42,6 +76,48 @@ def get_hive_config() -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential store helpers (for BYOK keys)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Provider name → credential store ID mapping
|
||||
_PROVIDER_CRED_MAP: dict[str, str] = {
|
||||
"anthropic": "anthropic",
|
||||
"openai": "openai",
|
||||
"gemini": "gemini",
|
||||
"google": "gemini",
|
||||
"minimax": "minimax",
|
||||
"groq": "groq",
|
||||
"cerebras": "cerebras",
|
||||
"openrouter": "openrouter",
|
||||
"mistral": "mistral",
|
||||
"together": "together",
|
||||
"together_ai": "together",
|
||||
"deepseek": "deepseek",
|
||||
"kimi": "kimi",
|
||||
"hive": "hive",
|
||||
}
|
||||
|
||||
|
||||
def _get_api_key_from_credential_store(provider: str) -> str | None:
|
||||
"""Look up a BYOK API key from the encrypted credential store.
|
||||
|
||||
Returns None if no key is found or the credential store is unavailable.
|
||||
"""
|
||||
if not os.environ.get("HIVE_CREDENTIAL_KEY"):
|
||||
return None
|
||||
cred_id = _PROVIDER_CRED_MAP.get(provider.lower())
|
||||
if not cred_id:
|
||||
return None
|
||||
try:
|
||||
from framework.credentials import CredentialStore
|
||||
|
||||
store = CredentialStore.with_encrypted_storage()
|
||||
return store.get(cred_id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Derived helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -88,7 +164,7 @@ def get_worker_api_key() -> str | None:
|
||||
# Worker-specific subscription / env var
|
||||
if worker_llm.get("use_claude_code_subscription"):
|
||||
try:
|
||||
from framework.runner.runner import get_claude_code_token
|
||||
from framework.loader.agent_loader import get_claude_code_token
|
||||
|
||||
token = get_claude_code_token()
|
||||
if token:
|
||||
@@ -98,7 +174,7 @@ def get_worker_api_key() -> str | None:
|
||||
|
||||
if worker_llm.get("use_codex_subscription"):
|
||||
try:
|
||||
from framework.runner.runner import get_codex_token
|
||||
from framework.loader.agent_loader import get_codex_token
|
||||
|
||||
token = get_codex_token()
|
||||
if token:
|
||||
@@ -108,7 +184,7 @@ def get_worker_api_key() -> str | None:
|
||||
|
||||
if worker_llm.get("use_kimi_code_subscription"):
|
||||
try:
|
||||
from framework.runner.runner import get_kimi_code_token
|
||||
from framework.loader.agent_loader import get_kimi_code_token
|
||||
|
||||
token = get_kimi_code_token()
|
||||
if token:
|
||||
@@ -118,7 +194,7 @@ def get_worker_api_key() -> str | None:
|
||||
|
||||
if worker_llm.get("use_antigravity_subscription"):
|
||||
try:
|
||||
from framework.runner.runner import get_antigravity_token
|
||||
from framework.loader.agent_loader import get_antigravity_token
|
||||
|
||||
token = get_antigravity_token()
|
||||
if token:
|
||||
@@ -174,7 +250,7 @@ def get_worker_llm_extra_kwargs() -> dict[str, Any]:
|
||||
"User-Agent": "CodexBar",
|
||||
}
|
||||
try:
|
||||
from framework.runner.runner import get_codex_account_id
|
||||
from framework.loader.agent_loader import get_codex_account_id
|
||||
|
||||
account_id = get_codex_account_id()
|
||||
if account_id:
|
||||
@@ -221,22 +297,43 @@ def get_max_context_tokens() -> int:
|
||||
return get_hive_config().get("llm", {}).get("max_context_tokens", DEFAULT_MAX_CONTEXT_TOKENS)
|
||||
|
||||
|
||||
def get_api_keys() -> list[str] | None:
|
||||
"""Return a list of API keys if ``api_keys`` is configured, else ``None``.
|
||||
|
||||
This supports key-pool rotation: configure multiple keys in
|
||||
``~/.hive/configuration.json`` under ``llm.api_keys`` and the
|
||||
:class:`~framework.llm.key_pool.KeyPool` will rotate through them.
|
||||
"""
|
||||
llm = get_hive_config().get("llm", {})
|
||||
keys = llm.get("api_keys")
|
||||
if keys and isinstance(keys, list) and len(keys) > 0:
|
||||
return [k for k in keys if k] # filter empties
|
||||
return None
|
||||
|
||||
|
||||
def get_api_key() -> str | None:
|
||||
"""Return the API key, supporting env var, Claude Code subscription, Codex, and ZAI Code.
|
||||
|
||||
Priority:
|
||||
0. Explicit key pool (``api_keys`` list) -- returns first key for
|
||||
single-key callers; full pool available via :func:`get_api_keys`.
|
||||
1. Claude Code subscription (``use_claude_code_subscription: true``)
|
||||
reads the OAuth token from ``~/.claude/.credentials.json``.
|
||||
2. Codex subscription (``use_codex_subscription: true``)
|
||||
reads the OAuth token from macOS Keychain or ``~/.codex/auth.json``.
|
||||
3. Environment variable named in ``api_key_env_var``.
|
||||
"""
|
||||
# If an explicit key pool is configured, use the first key.
|
||||
pool_keys = get_api_keys()
|
||||
if pool_keys:
|
||||
return pool_keys[0]
|
||||
|
||||
llm = get_hive_config().get("llm", {})
|
||||
|
||||
# Claude Code subscription: read OAuth token directly
|
||||
if llm.get("use_claude_code_subscription"):
|
||||
try:
|
||||
from framework.runner.runner import get_claude_code_token
|
||||
from framework.loader.agent_loader import get_claude_code_token
|
||||
|
||||
token = get_claude_code_token()
|
||||
if token:
|
||||
@@ -247,7 +344,7 @@ def get_api_key() -> str | None:
|
||||
# Codex subscription: read OAuth token from Keychain / auth.json
|
||||
if llm.get("use_codex_subscription"):
|
||||
try:
|
||||
from framework.runner.runner import get_codex_token
|
||||
from framework.loader.agent_loader import get_codex_token
|
||||
|
||||
token = get_codex_token()
|
||||
if token:
|
||||
@@ -258,7 +355,7 @@ def get_api_key() -> str | None:
|
||||
# Kimi Code subscription: read API key from ~/.kimi/config.toml
|
||||
if llm.get("use_kimi_code_subscription"):
|
||||
try:
|
||||
from framework.runner.runner import get_kimi_code_token
|
||||
from framework.loader.agent_loader import get_kimi_code_token
|
||||
|
||||
token = get_kimi_code_token()
|
||||
if token:
|
||||
@@ -269,7 +366,7 @@ def get_api_key() -> str | None:
|
||||
# Antigravity subscription: read OAuth token from accounts JSON
|
||||
if llm.get("use_antigravity_subscription"):
|
||||
try:
|
||||
from framework.runner.runner import get_antigravity_token
|
||||
from framework.loader.agent_loader import get_antigravity_token
|
||||
|
||||
token = get_antigravity_token()
|
||||
if token:
|
||||
@@ -280,8 +377,12 @@ def get_api_key() -> str | None:
|
||||
# Standard env-var path (covers ZAI Code and all API-key providers)
|
||||
api_key_env_var = llm.get("api_key_env_var")
|
||||
if api_key_env_var:
|
||||
return os.environ.get(api_key_env_var)
|
||||
return None
|
||||
key = os.environ.get(api_key_env_var)
|
||||
if key:
|
||||
return key
|
||||
|
||||
# Credential store fallback — BYOK keys stored via the UI
|
||||
return _get_api_key_from_credential_store(llm.get("provider", ""))
|
||||
|
||||
|
||||
# OAuth credentials for Antigravity are fetched from the opencode-antigravity-auth project.
|
||||
@@ -422,7 +523,7 @@ def get_llm_extra_kwargs() -> dict[str, Any]:
|
||||
"User-Agent": "CodexBar",
|
||||
}
|
||||
try:
|
||||
from framework.runner.runner import get_codex_account_id
|
||||
from framework.loader.agent_loader import get_codex_account_id
|
||||
|
||||
account_id = get_codex_account_id()
|
||||
if account_id:
|
||||
|
||||
@@ -51,6 +51,7 @@ from .key_storage import (
|
||||
from .models import (
|
||||
CredentialDecryptionError,
|
||||
CredentialError,
|
||||
CredentialExpiredError,
|
||||
CredentialKey,
|
||||
CredentialKeyNotFoundError,
|
||||
CredentialNotFoundError,
|
||||
@@ -84,6 +85,7 @@ from .template import TemplateResolver
|
||||
from .validation import (
|
||||
CredentialStatus,
|
||||
CredentialValidationResult,
|
||||
compute_unavailable_tools,
|
||||
ensure_credential_key_env,
|
||||
validate_agent_credentials,
|
||||
)
|
||||
@@ -136,6 +138,7 @@ __all__ = [
|
||||
"CredentialNotFoundError",
|
||||
"CredentialKeyNotFoundError",
|
||||
"CredentialRefreshError",
|
||||
"CredentialExpiredError",
|
||||
"CredentialValidationError",
|
||||
"CredentialDecryptionError",
|
||||
# Key storage (bootstrap credentials)
|
||||
@@ -148,6 +151,7 @@ __all__ = [
|
||||
# Validation
|
||||
"ensure_credential_key_env",
|
||||
"validate_agent_credentials",
|
||||
"compute_unavailable_tools",
|
||||
"CredentialStatus",
|
||||
"CredentialValidationResult",
|
||||
# Interactive setup
|
||||
|
||||
@@ -199,6 +199,19 @@ class AdenCachedStorage(CredentialStorage):
|
||||
if local_cred is None:
|
||||
return None
|
||||
|
||||
# Skip Aden fetch for credentials not managed by Aden (BYOK credentials).
|
||||
# Only OAuth credentials synced from Aden are in the provider index.
|
||||
# BYOK credentials like anthropic, brave_search are local-only.
|
||||
# Also check the _aden_managed flag on the credential itself.
|
||||
is_aden_managed = (
|
||||
credential_id in self._provider_index
|
||||
or any(credential_id in ids for ids in self._provider_index.values())
|
||||
or (local_cred is not None and local_cred.keys.get("_aden_managed") is not None)
|
||||
)
|
||||
if not is_aden_managed:
|
||||
logger.debug(f"Credential '{credential_id}' is local-only, skipping Aden refresh")
|
||||
return local_cred
|
||||
|
||||
# Try to refresh stale local credential from Aden
|
||||
try:
|
||||
aden_cred = self._aden_provider.fetch_from_aden(credential_id)
|
||||
|
||||
@@ -333,6 +333,29 @@ class CredentialRefreshError(CredentialError):
|
||||
pass
|
||||
|
||||
|
||||
class CredentialExpiredError(CredentialError):
|
||||
"""Raised when a credential is expired and refresh has failed.
|
||||
|
||||
Carries the metadata an agent (or the tool runner) needs to surface a
|
||||
reauth request to the user without having to look anything else up.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
credential_id: str,
|
||||
message: str,
|
||||
*,
|
||||
provider: str | None = None,
|
||||
alias: str | None = None,
|
||||
help_url: str | None = None,
|
||||
):
|
||||
self.credential_id = credential_id
|
||||
self.provider = provider
|
||||
self.alias = alias
|
||||
self.help_url = help_url
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class CredentialValidationError(CredentialError):
|
||||
"""Raised when credential validation fails."""
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from framework.graph import NodeSpec
|
||||
from framework.orchestrator import NodeSpec
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -533,7 +533,9 @@ class CredentialSetupSession:
|
||||
|
||||
|
||||
def load_agent_nodes(agent_path: str | Path) -> list:
|
||||
"""Load NodeSpec list from an agent's agent.py or agent.json.
|
||||
"""Load NodeSpec list from an agent directory.
|
||||
|
||||
Checks agent.json (declarative) first, then agent.py (legacy).
|
||||
|
||||
Args:
|
||||
agent_path: Path to agent directory.
|
||||
@@ -542,16 +544,28 @@ def load_agent_nodes(agent_path: str | Path) -> list:
|
||||
List of NodeSpec objects (empty list if agent can't be loaded).
|
||||
"""
|
||||
agent_path = Path(agent_path)
|
||||
agent_json_file = agent_path / "agent.json"
|
||||
agent_py = agent_path / "agent.py"
|
||||
agent_json = agent_path / "agent.json"
|
||||
|
||||
if agent_py.exists():
|
||||
if agent_json_file.exists():
|
||||
return _load_nodes_from_json_declarative(agent_json_file)
|
||||
elif agent_py.exists():
|
||||
return _load_nodes_from_python_agent(agent_path)
|
||||
elif agent_json.exists():
|
||||
return _load_nodes_from_json_agent(agent_json)
|
||||
return []
|
||||
|
||||
|
||||
def _load_nodes_from_json_declarative(agent_json: Path) -> list:
|
||||
"""Load nodes from a declarative JSON agent."""
|
||||
try:
|
||||
from framework.loader.agent_loader import load_agent_config
|
||||
|
||||
data = json.loads(agent_json.read_text(encoding="utf-8"))
|
||||
graph, _ = load_agent_config(data)
|
||||
return list(graph.nodes)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _load_nodes_from_python_agent(agent_path: Path) -> list:
|
||||
"""Load nodes from a Python-based agent."""
|
||||
import importlib.util
|
||||
@@ -590,7 +604,7 @@ def _load_nodes_from_json_agent(agent_json: Path) -> list:
|
||||
with open(agent_json, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
|
||||
from framework.graph import NodeSpec
|
||||
from framework.orchestrator import NodeSpec
|
||||
|
||||
nodes_data = data.get("graph", {}).get("nodes", [])
|
||||
nodes = []
|
||||
|
||||
@@ -161,6 +161,14 @@ class EncryptedFileStorage(CredentialStorage):
|
||||
|
||||
self._fernet = Fernet(self._key)
|
||||
|
||||
# Rebuild the metadata index from disk if it's missing or older than
|
||||
# the current index schema. The index is a developer-readable JSON
|
||||
# snapshot of the encrypted store; the .enc files remain authoritative.
|
||||
try:
|
||||
self._maybe_rebuild_index()
|
||||
except Exception:
|
||||
logger.debug("Initial index rebuild failed (non-fatal)", exc_info=True)
|
||||
|
||||
def _ensure_dirs(self) -> None:
|
||||
"""Create directory structure."""
|
||||
(self.base_path / "credentials").mkdir(parents=True, exist_ok=True)
|
||||
@@ -186,8 +194,8 @@ class EncryptedFileStorage(CredentialStorage):
|
||||
with open(cred_path, "wb") as f:
|
||||
f.write(encrypted)
|
||||
|
||||
# Update index
|
||||
self._update_index(credential.id, "save", credential.credential_type.value)
|
||||
# Update developer-readable index
|
||||
self._index_upsert(credential)
|
||||
logger.debug(f"Saved encrypted credential '{credential.id}'")
|
||||
|
||||
def load(self, credential_id: str) -> CredentialObject | None:
|
||||
@@ -217,7 +225,7 @@ class EncryptedFileStorage(CredentialStorage):
|
||||
cred_path = self._cred_path(credential_id)
|
||||
if cred_path.exists():
|
||||
cred_path.unlink()
|
||||
self._update_index(credential_id, "delete")
|
||||
self._index_remove(credential_id)
|
||||
logger.debug(f"Deleted credential '{credential_id}'")
|
||||
return True
|
||||
return False
|
||||
@@ -258,33 +266,154 @@ class EncryptedFileStorage(CredentialStorage):
|
||||
|
||||
return CredentialObject.model_validate(data)
|
||||
|
||||
def _update_index(
|
||||
self,
|
||||
credential_id: str,
|
||||
operation: str,
|
||||
credential_type: str | None = None,
|
||||
) -> None:
|
||||
"""Update the metadata index."""
|
||||
index_path = self.base_path / "metadata" / "index.json"
|
||||
# ------------------------------------------------------------------
|
||||
# Developer-readable metadata index
|
||||
#
|
||||
# The index lives at ``<base_path>/metadata/index.json`` and mirrors what
|
||||
# is in the encrypted store at a glance: credential id, provider, alias,
|
||||
# identity, key names, timestamps, and earliest expiry. It contains NO
|
||||
# secret values and is safe to share when filing a bug report. The .enc
|
||||
# files remain authoritative — the index is purely for human inspection
|
||||
# and for cheap ``list_all()`` enumeration.
|
||||
#
|
||||
# Schema version is bumped whenever the entry shape changes; the store
|
||||
# rebuilds the index from the encrypted files on load when the on-disk
|
||||
# version is older.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
if index_path.exists():
|
||||
with open(index_path, encoding="utf-8-sig") as f:
|
||||
index = json.load(f)
|
||||
else:
|
||||
index = {"credentials": {}, "version": "1.0"}
|
||||
INDEX_VERSION = "2.0"
|
||||
INDEX_INTERNAL_KEY_NAMES = ("_alias", "_integration_type")
|
||||
|
||||
if operation == "save":
|
||||
index["credentials"][credential_id] = {
|
||||
"updated_at": datetime.now(UTC).isoformat(),
|
||||
"type": credential_type,
|
||||
}
|
||||
elif operation == "delete":
|
||||
index["credentials"].pop(credential_id, None)
|
||||
def _index_path(self) -> Path:
|
||||
return self.base_path / "metadata" / "index.json"
|
||||
|
||||
index["last_modified"] = datetime.now(UTC).isoformat()
|
||||
def _read_index(self) -> dict[str, Any]:
|
||||
"""Read the index from disk; return an empty skeleton if missing."""
|
||||
path = self._index_path()
|
||||
if not path.exists():
|
||||
return {"version": self.INDEX_VERSION, "credentials": {}}
|
||||
try:
|
||||
with open(path, encoding="utf-8-sig") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
logger.debug("Failed to read credential index, starting fresh", exc_info=True)
|
||||
return {"version": self.INDEX_VERSION, "credentials": {}}
|
||||
|
||||
with open(index_path, "w", encoding="utf-8") as f:
|
||||
json.dump(index, f, indent=2)
|
||||
def _write_index(self, index: dict[str, Any]) -> None:
|
||||
"""Write the index to disk with consistent envelope fields."""
|
||||
index["version"] = self.INDEX_VERSION
|
||||
index["store_path"] = str(self.base_path)
|
||||
index["generated_at"] = datetime.now(UTC).isoformat()
|
||||
path = self._index_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(index, f, indent=2, sort_keys=False, default=str)
|
||||
|
||||
def _index_entry_for(self, credential: CredentialObject) -> dict[str, Any]:
|
||||
"""Build a single index entry from a CredentialObject (no secrets)."""
|
||||
# Visible key names: drop internal markers like _alias / _integration_type
|
||||
# / _identity_* so the entry shows what's actually a credential key.
|
||||
visible_keys = [
|
||||
name
|
||||
for name in credential.keys.keys()
|
||||
if name not in self.INDEX_INTERNAL_KEY_NAMES
|
||||
and not name.startswith("_identity_")
|
||||
]
|
||||
|
||||
# Earliest expiry across all keys (most likely the access_token).
|
||||
earliest_expiry: datetime | None = None
|
||||
for key in credential.keys.values():
|
||||
if key.expires_at is None:
|
||||
continue
|
||||
if earliest_expiry is None or key.expires_at < earliest_expiry:
|
||||
earliest_expiry = key.expires_at
|
||||
|
||||
return {
|
||||
"credential_type": credential.credential_type.value,
|
||||
"provider": credential.provider_type,
|
||||
"alias": credential.alias,
|
||||
"identity": credential.identity.to_dict(),
|
||||
"key_names": sorted(visible_keys),
|
||||
"created_at": credential.created_at.isoformat() if credential.created_at else None,
|
||||
"updated_at": credential.updated_at.isoformat() if credential.updated_at else None,
|
||||
"last_refreshed": (
|
||||
credential.last_refreshed.isoformat() if credential.last_refreshed else None
|
||||
),
|
||||
"expires_at": earliest_expiry.isoformat() if earliest_expiry else None,
|
||||
"auto_refresh": credential.auto_refresh,
|
||||
"tags": list(credential.tags),
|
||||
}
|
||||
|
||||
def _index_upsert(self, credential: CredentialObject) -> None:
|
||||
"""Insert or update one credential entry in the index."""
|
||||
try:
|
||||
index = self._read_index()
|
||||
if index.get("version") != self.INDEX_VERSION:
|
||||
# Old schema — rebuild from disk so we don't blend formats.
|
||||
self._rebuild_index()
|
||||
return
|
||||
credentials = index.setdefault("credentials", {})
|
||||
credentials[credential.id] = self._index_entry_for(credential)
|
||||
self._write_index(index)
|
||||
except Exception:
|
||||
logger.debug("Index upsert failed (non-fatal)", exc_info=True)
|
||||
|
||||
def _index_remove(self, credential_id: str) -> None:
|
||||
"""Remove one credential entry from the index."""
|
||||
try:
|
||||
index = self._read_index()
|
||||
if index.get("version") != self.INDEX_VERSION:
|
||||
self._rebuild_index()
|
||||
return
|
||||
credentials = index.setdefault("credentials", {})
|
||||
credentials.pop(credential_id, None)
|
||||
self._write_index(index)
|
||||
except Exception:
|
||||
logger.debug("Index remove failed (non-fatal)", exc_info=True)
|
||||
|
||||
def _maybe_rebuild_index(self) -> None:
|
||||
"""Rebuild the index if it's missing, malformed, or on an old schema.
|
||||
|
||||
Called once at startup. The check is cheap — read the version field
|
||||
and bail out if it matches. Encrypted files remain authoritative; this
|
||||
only refreshes the developer-facing snapshot.
|
||||
"""
|
||||
path = self._index_path()
|
||||
if path.exists():
|
||||
try:
|
||||
with open(path, encoding="utf-8-sig") as f:
|
||||
index = json.load(f)
|
||||
if index.get("version") == self.INDEX_VERSION:
|
||||
return
|
||||
except Exception:
|
||||
pass # fall through to rebuild
|
||||
self._rebuild_index()
|
||||
|
||||
def _rebuild_index(self) -> None:
|
||||
"""Walk the encrypted credentials directory and rewrite a fresh index."""
|
||||
cred_dir = self.base_path / "credentials"
|
||||
if not cred_dir.is_dir():
|
||||
return
|
||||
|
||||
entries: dict[str, Any] = {}
|
||||
for cred_file in sorted(cred_dir.glob("*.enc")):
|
||||
credential_id = cred_file.stem
|
||||
try:
|
||||
cred = self.load(credential_id)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to load %s during index rebuild — skipping",
|
||||
credential_id,
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
if cred is None:
|
||||
continue
|
||||
entries[cred.id] = self._index_entry_for(cred)
|
||||
|
||||
index = {"credentials": entries}
|
||||
self._write_index(index)
|
||||
logger.info("Rebuilt credential index with %d entries", len(entries))
|
||||
|
||||
|
||||
class EnvVarStorage(CredentialStorage):
|
||||
|
||||
@@ -19,6 +19,7 @@ from typing import Any
|
||||
from pydantic import SecretStr
|
||||
|
||||
from .models import (
|
||||
CredentialExpiredError,
|
||||
CredentialKey,
|
||||
CredentialObject,
|
||||
CredentialRefreshError,
|
||||
@@ -177,6 +178,8 @@ class CredentialStore:
|
||||
self,
|
||||
credential_id: str,
|
||||
refresh_if_needed: bool = True,
|
||||
*,
|
||||
raise_on_refresh_failure: bool = False,
|
||||
) -> CredentialObject | None:
|
||||
"""
|
||||
Get a credential by ID.
|
||||
@@ -184,6 +187,11 @@ class CredentialStore:
|
||||
Args:
|
||||
credential_id: The credential identifier
|
||||
refresh_if_needed: If True, refresh expired credentials
|
||||
raise_on_refresh_failure: If True, raise ``CredentialExpiredError``
|
||||
when refresh fails instead of silently returning the stale
|
||||
credential. Tool-execution call sites should pass True so the
|
||||
agent gets a structured "reauth needed" signal rather than a
|
||||
later 401 from the provider.
|
||||
|
||||
Returns:
|
||||
CredentialObject or None if not found
|
||||
@@ -193,7 +201,9 @@ class CredentialStore:
|
||||
cached = self._get_from_cache(credential_id)
|
||||
if cached is not None:
|
||||
if refresh_if_needed and self._should_refresh(cached):
|
||||
return self._refresh_credential(cached)
|
||||
return self._refresh_credential(
|
||||
cached, raise_on_failure=raise_on_refresh_failure
|
||||
)
|
||||
return cached
|
||||
|
||||
# Load from storage
|
||||
@@ -203,30 +213,46 @@ class CredentialStore:
|
||||
|
||||
# Refresh if needed
|
||||
if refresh_if_needed and self._should_refresh(credential):
|
||||
credential = self._refresh_credential(credential)
|
||||
credential = self._refresh_credential(
|
||||
credential, raise_on_failure=raise_on_refresh_failure
|
||||
)
|
||||
|
||||
# Cache
|
||||
self._add_to_cache(credential)
|
||||
|
||||
return credential
|
||||
|
||||
def get_key(self, credential_id: str, key_name: str) -> str | None:
|
||||
def get_key(
|
||||
self,
|
||||
credential_id: str,
|
||||
key_name: str,
|
||||
*,
|
||||
raise_on_refresh_failure: bool = False,
|
||||
) -> str | None:
|
||||
"""
|
||||
Convenience method to get a specific key value.
|
||||
|
||||
Args:
|
||||
credential_id: The credential identifier
|
||||
key_name: The key within the credential
|
||||
raise_on_refresh_failure: See ``get_credential``.
|
||||
|
||||
Returns:
|
||||
The key value or None if not found
|
||||
"""
|
||||
credential = self.get_credential(credential_id)
|
||||
credential = self.get_credential(
|
||||
credential_id, raise_on_refresh_failure=raise_on_refresh_failure
|
||||
)
|
||||
if credential is None:
|
||||
return None
|
||||
return credential.get_key(key_name)
|
||||
|
||||
def get(self, credential_id: str) -> str | None:
|
||||
def get(
|
||||
self,
|
||||
credential_id: str,
|
||||
*,
|
||||
raise_on_refresh_failure: bool = False,
|
||||
) -> str | None:
|
||||
"""
|
||||
Legacy compatibility: get the primary key value.
|
||||
|
||||
@@ -235,11 +261,14 @@ class CredentialStore:
|
||||
|
||||
Args:
|
||||
credential_id: The credential identifier
|
||||
raise_on_refresh_failure: See ``get_credential``.
|
||||
|
||||
Returns:
|
||||
The primary key value or None
|
||||
"""
|
||||
credential = self.get_credential(credential_id)
|
||||
credential = self.get_credential(
|
||||
credential_id, raise_on_refresh_failure=raise_on_refresh_failure
|
||||
)
|
||||
if credential is None:
|
||||
return None
|
||||
return credential.get_default_key()
|
||||
@@ -510,8 +539,20 @@ class CredentialStore:
|
||||
|
||||
return provider.should_refresh(credential)
|
||||
|
||||
def _refresh_credential(self, credential: CredentialObject) -> CredentialObject:
|
||||
"""Refresh a credential using its provider."""
|
||||
def _refresh_credential(
|
||||
self,
|
||||
credential: CredentialObject,
|
||||
*,
|
||||
raise_on_failure: bool = False,
|
||||
) -> CredentialObject:
|
||||
"""Refresh a credential using its provider.
|
||||
|
||||
When ``raise_on_failure`` is True, a refresh failure raises
|
||||
``CredentialExpiredError`` carrying provider/alias/help_url metadata
|
||||
for the caller (typically the tool runner) to surface a reauth
|
||||
request. Otherwise, the stale credential is returned to preserve
|
||||
legacy best-effort behavior.
|
||||
"""
|
||||
provider = self.get_provider_for_credential(credential)
|
||||
if provider is None:
|
||||
logger.warning(f"No provider found for credential '{credential.id}'")
|
||||
@@ -530,6 +571,16 @@ class CredentialStore:
|
||||
|
||||
except CredentialRefreshError as e:
|
||||
logger.error(f"Failed to refresh credential '{credential.id}': {e}")
|
||||
if raise_on_failure:
|
||||
raise CredentialExpiredError(
|
||||
credential_id=credential.id,
|
||||
message=(
|
||||
f"OAuth token for '{credential.id}' is expired and "
|
||||
f"refresh failed: {e}. Reauthorization required."
|
||||
),
|
||||
provider=credential.provider_type,
|
||||
alias=credential.alias,
|
||||
) from e
|
||||
return credential
|
||||
|
||||
def refresh_credential(self, credential_id: str) -> CredentialObject | None:
|
||||
|
||||
@@ -236,6 +236,46 @@ def _presync_aden_tokens(credential_specs: dict, *, force: bool = False) -> None
|
||||
)
|
||||
|
||||
|
||||
def compute_unavailable_tools(nodes: list) -> tuple[set[str], list[str]]:
|
||||
"""Return (tool_names_to_drop, human_messages).
|
||||
|
||||
Runs credential validation *without* raising, collects every tool
|
||||
bound to a failed credential (missing / invalid / Aden-not-connected
|
||||
and no alternative provider available), and returns the set of tool
|
||||
names that should be silently dropped from the worker's effective
|
||||
tool list.
|
||||
|
||||
Use this at every worker-spawn preflight so missing credentials
|
||||
filter tools out of the graph instead of hard-failing the whole
|
||||
spawn. Only affects non-MCP tools — the MCP admission gate
|
||||
(``_build_mcp_admission_gate``) already handles MCP tools at
|
||||
registration time.
|
||||
"""
|
||||
try:
|
||||
result = validate_agent_credentials(nodes, verify=False, raise_on_error=False)
|
||||
except Exception as exc:
|
||||
logger.debug("compute_unavailable_tools: validation raised: %s", exc)
|
||||
return set(), []
|
||||
|
||||
drop: set[str] = set()
|
||||
messages: list[str] = []
|
||||
for status in result.failed:
|
||||
if not status.tools:
|
||||
continue
|
||||
drop.update(status.tools)
|
||||
reason = "missing"
|
||||
if status.aden_not_connected:
|
||||
reason = "aden_not_connected"
|
||||
elif status.available and status.valid is False:
|
||||
reason = "invalid"
|
||||
messages.append(
|
||||
f"{status.env_var} ({reason}) → drops {len(status.tools)} tool(s): "
|
||||
f"{', '.join(status.tools[:6])}"
|
||||
+ (f" +{len(status.tools) - 6} more" if len(status.tools) > 6 else "")
|
||||
)
|
||||
return drop, messages
|
||||
|
||||
|
||||
def validate_agent_credentials(
|
||||
nodes: list,
|
||||
quiet: bool = False,
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
"""Graph structures: Goals, Nodes, Edges, and Execution."""
|
||||
|
||||
from framework.graph.context import GraphContext
|
||||
from framework.graph.context_handoff import ContextHandoff, HandoffContext
|
||||
from framework.graph.conversation import ConversationStore, Message, NodeConversation
|
||||
from framework.graph.edge import DEFAULT_MAX_TOKENS, EdgeCondition, EdgeSpec, GraphSpec
|
||||
from framework.graph.event_loop_node import (
|
||||
EventLoopNode,
|
||||
JudgeProtocol,
|
||||
JudgeVerdict,
|
||||
LoopConfig,
|
||||
OutputAccumulator,
|
||||
)
|
||||
from framework.graph.executor import GraphExecutor
|
||||
from framework.graph.goal import Constraint, Goal, GoalStatus, SuccessCriterion
|
||||
from framework.graph.node import NodeContext, NodeProtocol, NodeResult, NodeSpec
|
||||
from framework.graph.worker_agent import (
|
||||
Activation,
|
||||
FanOutTag,
|
||||
FanOutTracker,
|
||||
WorkerAgent,
|
||||
WorkerCompletion,
|
||||
WorkerLifecycle,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Goal
|
||||
"Goal",
|
||||
"SuccessCriterion",
|
||||
"Constraint",
|
||||
"GoalStatus",
|
||||
# Node
|
||||
"NodeSpec",
|
||||
"NodeContext",
|
||||
"NodeResult",
|
||||
"NodeProtocol",
|
||||
# Edge
|
||||
"EdgeSpec",
|
||||
"EdgeCondition",
|
||||
"GraphSpec",
|
||||
"DEFAULT_MAX_TOKENS",
|
||||
# Executor
|
||||
"GraphExecutor",
|
||||
# Conversation
|
||||
"NodeConversation",
|
||||
"ConversationStore",
|
||||
"Message",
|
||||
# Event Loop
|
||||
"EventLoopNode",
|
||||
"LoopConfig",
|
||||
"OutputAccumulator",
|
||||
"JudgeProtocol",
|
||||
"JudgeVerdict",
|
||||
# Context Handoff
|
||||
"ContextHandoff",
|
||||
"HandoffContext",
|
||||
# Worker Agent
|
||||
"WorkerAgent",
|
||||
"WorkerLifecycle",
|
||||
"WorkerCompletion",
|
||||
"Activation",
|
||||
"FanOutTag",
|
||||
"FanOutTracker",
|
||||
"GraphContext",
|
||||
]
|
||||
@@ -1,6 +0,0 @@
|
||||
"""EventLoopNode subpackage — modular components of the event loop orchestrator.
|
||||
|
||||
All public symbols are re-exported by the parent ``event_loop_node.py`` for
|
||||
backward compatibility. Internal consumers may import directly from these
|
||||
submodules for clarity.
|
||||
"""
|
||||
@@ -1,381 +0,0 @@
|
||||
"""Subagent execution for the event loop.
|
||||
|
||||
Handles the full subagent lifecycle: validation, context setup, tool filtering,
|
||||
conversation store derivation, execution, and cleanup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from framework.graph.conversation import ConversationStore
|
||||
from framework.graph.event_loop.judge_pipeline import SubagentJudge
|
||||
from framework.graph.event_loop.types import LoopConfig, OutputAccumulator
|
||||
from framework.graph.node import DataBuffer, NodeContext
|
||||
from framework.llm.provider import ToolResult, ToolUse
|
||||
from framework.runtime.event_bus import EventBus
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from framework.graph.event_loop_node import EventLoopNode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def execute_subagent(
|
||||
ctx: NodeContext,
|
||||
agent_id: str,
|
||||
task: str,
|
||||
*,
|
||||
config: LoopConfig,
|
||||
event_loop_node_cls: type[EventLoopNode],
|
||||
escalation_receiver_cls: Callable[[], Any],
|
||||
accumulator: OutputAccumulator | None = None,
|
||||
event_bus: EventBus | None = None,
|
||||
tool_executor: Callable[[ToolUse], ToolResult | Awaitable[ToolResult]] | None = None,
|
||||
conversation_store: ConversationStore | None = None,
|
||||
subagent_instance_counter: dict[str, int] | None = None,
|
||||
) -> ToolResult:
|
||||
"""Execute a subagent and return the result as a ToolResult.
|
||||
|
||||
The subagent:
|
||||
- Gets a fresh conversation with just the task
|
||||
- Has read-only access to the parent's readable memory
|
||||
- Cannot delegate to its own subagents (prevents recursion)
|
||||
- Returns its output in structured JSON format
|
||||
|
||||
Args:
|
||||
ctx: Parent node's context (for memory, tools, LLM access).
|
||||
agent_id: The node ID of the subagent to invoke.
|
||||
task: The task description to give the subagent.
|
||||
accumulator: Parent's OutputAccumulator.
|
||||
event_bus: EventBus for lifecycle events.
|
||||
config: LoopConfig for iteration/tool limits.
|
||||
tool_executor: Tool executor callable.
|
||||
conversation_store: Parent conversation store (for deriving subagent store).
|
||||
subagent_instance_counter: Mutable counter dict for unique subagent paths.
|
||||
|
||||
Returns:
|
||||
ToolResult with structured JSON output.
|
||||
"""
|
||||
# Log subagent invocation start
|
||||
logger.info(
|
||||
"\n" + "=" * 60 + "\n"
|
||||
"🤖 SUBAGENT INVOCATION\n"
|
||||
"=" * 60 + "\n"
|
||||
"Parent Node: %s\n"
|
||||
"Subagent ID: %s\n"
|
||||
"Task: %s\n" + "=" * 60,
|
||||
ctx.node_id,
|
||||
agent_id,
|
||||
task[:500] + "..." if len(task) > 500 else task,
|
||||
)
|
||||
|
||||
# 1. Validate agent exists in registry
|
||||
if agent_id not in ctx.node_registry:
|
||||
return ToolResult(
|
||||
tool_use_id="",
|
||||
content=json.dumps(
|
||||
{
|
||||
"message": f"Sub-agent '{agent_id}' not found in registry",
|
||||
"data": None,
|
||||
"metadata": {"agent_id": agent_id, "success": False, "error": "not_found"},
|
||||
}
|
||||
),
|
||||
is_error=True,
|
||||
)
|
||||
|
||||
subagent_spec = ctx.node_registry[agent_id]
|
||||
|
||||
# 2. Create read-only memory snapshot
|
||||
parent_data = ctx.buffer.read_all()
|
||||
|
||||
# Merge in-flight outputs from the parent's accumulator.
|
||||
if accumulator:
|
||||
for key, value in accumulator.to_dict().items():
|
||||
if key not in parent_data:
|
||||
parent_data[key] = value
|
||||
|
||||
subagent_buffer = DataBuffer()
|
||||
for key, value in parent_data.items():
|
||||
subagent_buffer.write(key, value, validate=False)
|
||||
|
||||
read_keys = set(parent_data.keys()) | set(subagent_spec.input_keys or [])
|
||||
scoped_buffer = subagent_buffer.with_permissions(
|
||||
read_keys=list(read_keys),
|
||||
write_keys=[], # Read-only!
|
||||
)
|
||||
|
||||
# 2b. Compute instance counter early so the callback and child context
|
||||
# share the same stable node_id for this subagent invocation.
|
||||
if subagent_instance_counter is not None:
|
||||
subagent_instance_counter.setdefault(agent_id, 0)
|
||||
subagent_instance_counter[agent_id] += 1
|
||||
subagent_instance = str(subagent_instance_counter[agent_id])
|
||||
else:
|
||||
subagent_instance = "1"
|
||||
|
||||
if subagent_instance == "1":
|
||||
sa_node_id = f"{ctx.node_id}:subagent:{agent_id}"
|
||||
else:
|
||||
sa_node_id = f"{ctx.node_id}:subagent:{agent_id}:{subagent_instance}"
|
||||
|
||||
# 2c. Set up report callback (one-way channel to parent / event bus)
|
||||
subagent_reports: list[dict] = []
|
||||
|
||||
async def _report_callback(
|
||||
message: str,
|
||||
data: dict | None = None,
|
||||
*,
|
||||
wait_for_response: bool = False,
|
||||
) -> str | None:
|
||||
subagent_reports.append({"message": message, "data": data, "timestamp": time.time()})
|
||||
if event_bus:
|
||||
await event_bus.emit_subagent_report(
|
||||
stream_id=ctx.node_id,
|
||||
node_id=sa_node_id,
|
||||
subagent_id=agent_id,
|
||||
message=message,
|
||||
data=data,
|
||||
execution_id=ctx.execution_id,
|
||||
)
|
||||
|
||||
if not wait_for_response:
|
||||
return None
|
||||
|
||||
if not event_bus:
|
||||
logger.warning(
|
||||
"Subagent '%s' requested user response but no event_bus available",
|
||||
agent_id,
|
||||
)
|
||||
return None
|
||||
|
||||
# Create isolated receiver and register for input routing
|
||||
import uuid
|
||||
|
||||
escalation_id = f"{ctx.node_id}:escalation:{uuid.uuid4().hex[:8]}"
|
||||
receiver = escalation_receiver_cls()
|
||||
registry = ctx.shared_node_registry
|
||||
|
||||
registry[escalation_id] = receiver
|
||||
try:
|
||||
await event_bus.emit_escalation_requested(
|
||||
stream_id=ctx.stream_id or ctx.node_id,
|
||||
node_id=escalation_id,
|
||||
reason=f"Subagent report (wait_for_response) from {agent_id}",
|
||||
context=message,
|
||||
execution_id=ctx.execution_id,
|
||||
)
|
||||
# Block until queen responds
|
||||
return await receiver.wait()
|
||||
finally:
|
||||
registry.pop(escalation_id, None)
|
||||
|
||||
# 3. Filter tools for subagent
|
||||
subagent_tool_names = set(subagent_spec.tools or [])
|
||||
tool_source = ctx.all_tools if ctx.all_tools else ctx.available_tools
|
||||
|
||||
# GCU auto-population
|
||||
if subagent_spec.node_type == "gcu" and not subagent_tool_names:
|
||||
subagent_tools = [t for t in tool_source if t.name != "delegate_to_sub_agent"]
|
||||
else:
|
||||
subagent_tools = [
|
||||
t
|
||||
for t in tool_source
|
||||
if t.name in subagent_tool_names and t.name != "delegate_to_sub_agent"
|
||||
]
|
||||
|
||||
missing = subagent_tool_names - {t.name for t in subagent_tools}
|
||||
if missing:
|
||||
logger.warning(
|
||||
"Subagent '%s' requested tools not found in catalog: %s",
|
||||
agent_id,
|
||||
sorted(missing),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"📦 Subagent '%s' configuration:\n"
|
||||
" - System prompt: %s\n"
|
||||
" - Tools available (%d): %s\n"
|
||||
" - Memory keys inherited: %s",
|
||||
agent_id,
|
||||
(subagent_spec.system_prompt[:200] + "...")
|
||||
if subagent_spec.system_prompt and len(subagent_spec.system_prompt) > 200
|
||||
else subagent_spec.system_prompt,
|
||||
len(subagent_tools),
|
||||
[t.name for t in subagent_tools],
|
||||
list(parent_data.keys()),
|
||||
)
|
||||
|
||||
# 4. Build subagent context
|
||||
max_iter = min(config.max_iterations, 10)
|
||||
subagent_ctx = NodeContext(
|
||||
runtime=ctx.runtime,
|
||||
node_id=sa_node_id,
|
||||
node_spec=subagent_spec,
|
||||
buffer=scoped_buffer,
|
||||
input_data={"task": task, **parent_data},
|
||||
llm=ctx.llm,
|
||||
available_tools=subagent_tools,
|
||||
goal_context=(
|
||||
f"Your specific task: {task}\n\n"
|
||||
f"COMPLETION REQUIREMENTS:\n"
|
||||
f"When your task is done, you MUST call set_output() "
|
||||
f"for each required key: {subagent_spec.output_keys}\n"
|
||||
f"Alternatively, call report_to_parent(mark_complete=true) "
|
||||
f"with your findings in message/data.\n"
|
||||
f"You have a maximum of {max_iter} turns to complete this task."
|
||||
),
|
||||
goal=ctx.goal,
|
||||
max_tokens=ctx.max_tokens,
|
||||
runtime_logger=ctx.runtime_logger,
|
||||
is_subagent_mode=True, # Prevents nested delegation
|
||||
report_callback=_report_callback,
|
||||
node_registry={}, # Empty - no nested subagents
|
||||
shared_node_registry=ctx.shared_node_registry, # For escalation routing
|
||||
)
|
||||
|
||||
# 5. Create and execute subagent EventLoopNode
|
||||
subagent_conv_store = None
|
||||
if conversation_store is not None:
|
||||
from framework.storage.conversation_store import FileConversationStore
|
||||
|
||||
parent_base = getattr(conversation_store, "_base", None)
|
||||
if parent_base is not None:
|
||||
conversations_dir = parent_base.parent
|
||||
subagent_dir_name = f"{agent_id}-{subagent_instance}"
|
||||
subagent_store_path = conversations_dir / subagent_dir_name
|
||||
subagent_conv_store = FileConversationStore(base_path=subagent_store_path)
|
||||
|
||||
# Derive a subagent-scoped spillover dir
|
||||
subagent_spillover = None
|
||||
if config.spillover_dir:
|
||||
subagent_spillover = str(Path(config.spillover_dir) / agent_id / subagent_instance)
|
||||
|
||||
subagent_node = event_loop_node_cls(
|
||||
event_bus=event_bus,
|
||||
judge=SubagentJudge(task=task, max_iterations=max_iter),
|
||||
config=LoopConfig(
|
||||
max_iterations=max_iter,
|
||||
max_tool_calls_per_turn=config.max_tool_calls_per_turn,
|
||||
tool_call_overflow_margin=config.tool_call_overflow_margin,
|
||||
max_context_tokens=config.max_context_tokens,
|
||||
stall_detection_threshold=config.stall_detection_threshold,
|
||||
max_tool_result_chars=config.max_tool_result_chars,
|
||||
spillover_dir=subagent_spillover,
|
||||
),
|
||||
tool_executor=tool_executor,
|
||||
conversation_store=subagent_conv_store,
|
||||
)
|
||||
|
||||
# Each subagent instance gets its own unique browser profile so concurrent
|
||||
# subagents don't share tab groups. The profile is injected into every
|
||||
# browser_* tool call by wrapping the tool executor.
|
||||
_gcu_profile = f"{agent_id}:{subagent_instance}"
|
||||
_original_tool_executor = None
|
||||
|
||||
if tool_executor is not None:
|
||||
_original_tool_executor = tool_executor
|
||||
|
||||
async def _gcu_profile_injecting_executor(
|
||||
tool_use: ToolUse,
|
||||
) -> ToolResult | Awaitable[ToolResult]:
|
||||
if tool_use.name.startswith("browser_") and "profile" not in (tool_use.input or {}):
|
||||
from dataclasses import replace
|
||||
|
||||
tool_use = replace(tool_use, input={**(tool_use.input or {}), "profile": _gcu_profile})
|
||||
result = _original_tool_executor(tool_use)
|
||||
if asyncio.isfuture(result) or asyncio.iscoroutine(result):
|
||||
return await result
|
||||
return result
|
||||
|
||||
tool_executor = _gcu_profile_injecting_executor
|
||||
|
||||
try:
|
||||
logger.info("🚀 Starting subagent '%s' execution...", agent_id)
|
||||
start_time = time.time()
|
||||
result = await subagent_node.execute(subagent_ctx)
|
||||
latency_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
separator = "-" * 60
|
||||
logger.info(
|
||||
"\n%s\n"
|
||||
"✅ SUBAGENT '%s' COMPLETED\n"
|
||||
"%s\n"
|
||||
"Success: %s\n"
|
||||
"Latency: %dms\n"
|
||||
"Tokens used: %s\n"
|
||||
"Output keys: %s\n"
|
||||
"%s",
|
||||
separator,
|
||||
agent_id,
|
||||
separator,
|
||||
result.success,
|
||||
latency_ms,
|
||||
result.tokens_used,
|
||||
list(result.output.keys()) if result.output else [],
|
||||
separator,
|
||||
)
|
||||
|
||||
result_json = {
|
||||
"message": (
|
||||
f"Sub-agent '{agent_id}' completed successfully"
|
||||
if result.success
|
||||
else f"Sub-agent '{agent_id}' failed: {result.error}"
|
||||
),
|
||||
"data": result.output,
|
||||
"reports": subagent_reports if subagent_reports else None,
|
||||
"metadata": {
|
||||
"agent_id": agent_id,
|
||||
"success": result.success,
|
||||
"tokens_used": result.tokens_used,
|
||||
"latency_ms": latency_ms,
|
||||
"report_count": len(subagent_reports),
|
||||
},
|
||||
}
|
||||
|
||||
return ToolResult(
|
||||
tool_use_id="",
|
||||
content=json.dumps(result_json, indent=2, default=str),
|
||||
is_error=not result.success,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"\n" + "!" * 60 + "\n❌ SUBAGENT '%s' FAILED\nError: %s\n" + "!" * 60,
|
||||
agent_id,
|
||||
str(e),
|
||||
)
|
||||
result_json = {
|
||||
"message": f"Sub-agent '{agent_id}' raised exception: {e}",
|
||||
"data": None,
|
||||
"metadata": {
|
||||
"agent_id": agent_id,
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
},
|
||||
}
|
||||
return ToolResult(
|
||||
tool_use_id="",
|
||||
content=json.dumps(result_json, indent=2),
|
||||
is_error=True,
|
||||
)
|
||||
finally:
|
||||
# Close the tab group this subagent created, if any.
|
||||
if _original_tool_executor is not None:
|
||||
try:
|
||||
stop_call = ToolUse(
|
||||
id="__subagent_cleanup__",
|
||||
name="browser_stop",
|
||||
input={"profile": _gcu_profile},
|
||||
)
|
||||
result = _original_tool_executor(stop_call)
|
||||
if asyncio.isfuture(result) or asyncio.iscoroutine(result):
|
||||
await result
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,151 +0,0 @@
|
||||
"""Streaming XML tag filter for thinking tags.
|
||||
|
||||
Strips configured XML tags (e.g. ``<situation>``, ``<monologue>``) from
|
||||
a chunked text stream while preserving the full text for conversation
|
||||
storage. The filter is stateful — it handles chunks that split mid-tag.
|
||||
|
||||
Only touches text content. Tool calls flow through a completely separate
|
||||
code path and are never affected by this filter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
|
||||
class ThinkingTagFilter:
|
||||
"""Strips XML thinking tags from a streaming text output.
|
||||
|
||||
Buffers content inside configured tags and yields only the visible
|
||||
content outside those tags. Handles chunks that split across tag
|
||||
boundaries (e.g. a chunk ending with ``"<mono"``).
|
||||
|
||||
Args:
|
||||
tag_names: Tag names to strip (e.g. ``["situation", "monologue"]``).
|
||||
"""
|
||||
|
||||
def __init__(self, tag_names: Sequence[str]) -> None:
|
||||
self._tag_names: set[str] = set(tag_names)
|
||||
# Pre-compute all opening and closing tag strings for matching.
|
||||
self._open_tags: dict[str, str] = {name: f"<{name}>" for name in tag_names}
|
||||
self._close_tags: dict[str, str] = {name: f"</{name}>" for name in tag_names}
|
||||
# All possible tag prefixes for partial-match detection.
|
||||
self._all_tag_strings: list[str] = sorted(
|
||||
list(self._open_tags.values()) + list(self._close_tags.values()),
|
||||
key=len,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
self._inside_tag: str | None = None # Which tag we're inside, or None.
|
||||
self._pending: str = "" # Chars that might be a partial tag.
|
||||
self._visible_text: str = "" # Accumulated visible snapshot.
|
||||
|
||||
def feed(self, chunk: str) -> str:
|
||||
"""Feed a text chunk and return the visible portion.
|
||||
|
||||
Characters inside thinking tags are suppressed. Characters that
|
||||
*might* be the start of a tag are buffered until the next chunk
|
||||
resolves the ambiguity.
|
||||
|
||||
Returns:
|
||||
The portion of text that should be shown to the user.
|
||||
"""
|
||||
buf = self._pending + chunk
|
||||
self._pending = ""
|
||||
visible = self._process(buf)
|
||||
self._visible_text += visible
|
||||
return visible
|
||||
|
||||
@property
|
||||
def visible_snapshot(self) -> str:
|
||||
"""Accumulated visible text so far (for the snapshot field)."""
|
||||
return self._visible_text
|
||||
|
||||
def flush(self) -> str:
|
||||
"""Flush any pending partial tag as visible text.
|
||||
|
||||
Called at end-of-stream. If characters were buffered because they
|
||||
looked like the start of a tag but the stream ended before the tag
|
||||
completed, they are emitted as visible text (graceful degradation).
|
||||
"""
|
||||
result = ""
|
||||
if self._pending:
|
||||
if self._inside_tag is None:
|
||||
result = self._pending
|
||||
# If inside a tag, discard pending (unclosed tag content).
|
||||
self._pending = ""
|
||||
self._visible_text += result
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal processing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _process(self, buf: str) -> str:
|
||||
"""Process a buffer, returning visible text and updating state."""
|
||||
visible_parts: list[str] = []
|
||||
i = 0
|
||||
n = len(buf)
|
||||
|
||||
while i < n:
|
||||
if self._inside_tag is not None:
|
||||
# Inside a tag — look for the closing tag.
|
||||
close = self._close_tags[self._inside_tag]
|
||||
close_pos = buf.find(close, i)
|
||||
if close_pos == -1:
|
||||
# Closing tag might be split across chunks.
|
||||
# Check if the tail of buf is a prefix of the close tag.
|
||||
tail_len = min(len(close) - 1, n - i)
|
||||
for tl in range(tail_len, 0, -1):
|
||||
if close.startswith(buf[n - tl :]):
|
||||
self._pending = buf[n - tl :]
|
||||
i = n
|
||||
break
|
||||
else:
|
||||
# No partial match — discard everything (inside tag).
|
||||
i = n
|
||||
break
|
||||
else:
|
||||
# Found closing tag — skip past it and exit tag.
|
||||
i = close_pos + len(close)
|
||||
self._inside_tag = None
|
||||
else:
|
||||
# Outside any tag — look for '<'.
|
||||
lt_pos = buf.find("<", i)
|
||||
if lt_pos == -1:
|
||||
# No '<' — everything is visible.
|
||||
visible_parts.append(buf[i:])
|
||||
i = n
|
||||
else:
|
||||
# Emit text before the '<'.
|
||||
if lt_pos > i:
|
||||
visible_parts.append(buf[i:lt_pos])
|
||||
# Try to match an opening tag at this position.
|
||||
remainder = buf[lt_pos:]
|
||||
matched = False
|
||||
for name, open_tag in self._open_tags.items():
|
||||
if remainder.startswith(open_tag):
|
||||
# Full opening tag found — enter tag.
|
||||
self._inside_tag = name
|
||||
i = lt_pos + len(open_tag)
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
# Check if remainder could be a partial tag prefix.
|
||||
if self._is_partial_tag_prefix(remainder):
|
||||
# Buffer and wait for next chunk.
|
||||
self._pending = remainder
|
||||
i = n
|
||||
else:
|
||||
# Not a known tag — '<' is visible text.
|
||||
visible_parts.append("<")
|
||||
i = lt_pos + 1
|
||||
|
||||
return "".join(visible_parts)
|
||||
|
||||
def _is_partial_tag_prefix(self, text: str) -> bool:
|
||||
"""Check if text could be the start of a known tag string."""
|
||||
for tag_str in self._all_tag_strings:
|
||||
if tag_str.startswith(text) and len(text) < len(tag_str):
|
||||
return True
|
||||
return False
|
||||
@@ -1,129 +0,0 @@
|
||||
"""GCU (browser automation) node type constants.
|
||||
|
||||
A ``gcu`` node is an ``event_loop`` node with two automatic enhancements:
|
||||
1. A canonical browser best-practices system prompt is prepended.
|
||||
2. All tools from the GCU MCP server are auto-included.
|
||||
|
||||
No new ``NodeProtocol`` subclass — the ``gcu`` type is purely a declarative
|
||||
signal processed by the runner and executor at setup time.
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP server identity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
GCU_SERVER_NAME = "gcu-tools"
|
||||
"""Name used to identify the GCU MCP server in ``mcp_servers.json``."""
|
||||
|
||||
GCU_MCP_SERVER_CONFIG: dict = {
|
||||
"name": GCU_SERVER_NAME,
|
||||
"transport": "stdio",
|
||||
"command": "uv",
|
||||
"args": ["run", "python", "-m", "gcu.server", "--stdio"],
|
||||
"cwd": "../../tools",
|
||||
"description": "GCU tools for browser automation",
|
||||
}
|
||||
"""Default stdio config for the GCU MCP server (relative to exports/<agent>/)."""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Browser best-practices system prompt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
GCU_BROWSER_SYSTEM_PROMPT = """\
|
||||
# Browser Automation Best Practices
|
||||
|
||||
Follow these rules for reliable, efficient browser interaction.
|
||||
|
||||
## Reading Pages
|
||||
- ALWAYS prefer `browser_snapshot` over `browser_get_text("body")`
|
||||
— it returns a compact ~1-5 KB accessibility tree vs 100+ KB of raw HTML.
|
||||
- Interaction tools (`browser_click`, `browser_type`, `browser_fill`,
|
||||
`browser_scroll`, etc.) return a page snapshot automatically in their
|
||||
result. Use it to decide your next action — do NOT call
|
||||
`browser_snapshot` separately after every action.
|
||||
Only call `browser_snapshot` when you need a fresh view without
|
||||
performing an action, or after setting `auto_snapshot=false`.
|
||||
- Do NOT use `browser_screenshot` to read text — use
|
||||
`browser_snapshot` for that (compact, searchable, fast).
|
||||
- DO use `browser_screenshot` when you need visual context:
|
||||
charts, images, canvas elements, layout verification, or when
|
||||
the snapshot doesn't capture what you need.
|
||||
- Only fall back to `browser_get_text` for extracting specific
|
||||
small elements by CSS selector.
|
||||
|
||||
## Navigation & Waiting
|
||||
- `browser_navigate` and `browser_open` already wait for the page to
|
||||
load (`domcontentloaded`). Do NOT call `browser_wait` with no
|
||||
arguments after navigation — it wastes time.
|
||||
Only use `browser_wait` when you need a *specific element* or *text*
|
||||
to appear (pass `selector` or `text`).
|
||||
- NEVER re-navigate to the same URL after scrolling
|
||||
— this resets your scroll position and loses loaded content.
|
||||
|
||||
## Scrolling
|
||||
- Use large scroll amounts ~2000 when loading more content
|
||||
— sites like twitter and linkedin have lazy loading for paging.
|
||||
- The scroll result includes a snapshot automatically — no need to call
|
||||
`browser_snapshot` separately.
|
||||
|
||||
## Batching Actions
|
||||
- You can call multiple tools in a single turn — they execute in parallel.
|
||||
ALWAYS batch independent actions together. Examples:
|
||||
- Fill multiple form fields in one turn.
|
||||
- Navigate + snapshot in one turn.
|
||||
- Click + scroll if targeting different elements.
|
||||
- When batching, set `auto_snapshot=false` on all but the last action
|
||||
to avoid redundant snapshots.
|
||||
- Aim for 3-5 tool calls per turn minimum. One tool call per turn is
|
||||
wasteful.
|
||||
|
||||
## Error Recovery
|
||||
- If a tool fails, retry once with the same approach.
|
||||
- If it fails a second time, STOP retrying and switch approach.
|
||||
- If `browser_snapshot` fails → try `browser_get_text` with a
|
||||
specific small selector as fallback.
|
||||
- If `browser_open` fails or page seems stale → `browser_stop`,
|
||||
then `browser_start`, then retry.
|
||||
|
||||
## Tab Management
|
||||
|
||||
**Close tabs as soon as you are done with them** — not only at the end of the task.
|
||||
After reading or extracting data from a tab, close it immediately.
|
||||
|
||||
**Decision rules:**
|
||||
- Finished reading/extracting from a tab? → `browser_close(target_id=...)`
|
||||
- Completed a multi-tab workflow? → `browser_close_finished()` to clean up all your tabs
|
||||
- More than 3 tabs open? → stop and close finished ones before opening more
|
||||
- Popup appeared that you didn't need? → close it immediately
|
||||
|
||||
**Origin awareness:** `browser_tabs` returns an `origin` field for each tab:
|
||||
- `"agent"` — you opened it; you own it; close it when done
|
||||
- `"popup"` — opened by a link or script; close after extracting what you need
|
||||
- `"startup"` or `"user"` — leave these alone unless the task requires it
|
||||
|
||||
**Cleanup tools:**
|
||||
- `browser_close(target_id=...)` — close one specific tab
|
||||
- `browser_close_finished()` — close all your agent/popup tabs (safe: leaves startup/user tabs)
|
||||
- `browser_close_all()` — close everything except the active tab (use only for full reset)
|
||||
|
||||
**Multi-tab workflow pattern:**
|
||||
1. Open background tabs with `browser_open(url=..., background=true)` to stay on current tab
|
||||
2. Process each tab and close it with `browser_close` when done
|
||||
3. When the full workflow completes, call `browser_close_finished()` to confirm cleanup
|
||||
4. Check `browser_tabs` at any point — it shows `origin` and `age_seconds` per tab
|
||||
|
||||
Never accumulate tabs. Treat every tab you open as a resource you must free.
|
||||
|
||||
## Login & Auth Walls
|
||||
- If you see a "Log in" or "Sign up" prompt instead of expected
|
||||
content, report the auth wall immediately — do NOT attempt to log in.
|
||||
- Check for cookie consent banners and dismiss them if they block content.
|
||||
|
||||
## Efficiency
|
||||
- Minimize tool calls — combine actions where possible.
|
||||
- When a snapshot result is saved to a spillover file, use
|
||||
`run_command` with grep to extract specific data rather than
|
||||
re-reading the full file.
|
||||
- Call `set_output` in the same turn as your last browser action
|
||||
when possible — don't waste a turn.
|
||||
"""
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Host layer -- how agents are triggered and hosted."""
|
||||
|
||||
from framework.host.colony_runtime import ( # noqa: F401
|
||||
ColonyConfig,
|
||||
ColonyRuntime,
|
||||
StreamEventBus,
|
||||
TriggerSpec,
|
||||
)
|
||||
from framework.host.event_bus import AgentEvent, EventBus, EventType # noqa: F401
|
||||
from framework.host.worker import ( # noqa: F401
|
||||
Worker,
|
||||
WorkerInfo,
|
||||
WorkerResult,
|
||||
WorkerStatus,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -108,14 +108,10 @@ class EventType(StrEnum):
|
||||
# Judge decisions (implicit judge in event loop nodes)
|
||||
JUDGE_VERDICT = "judge_verdict"
|
||||
|
||||
# Output tracking
|
||||
OUTPUT_KEY_SET = "output_key_set"
|
||||
|
||||
# Retry / edge tracking
|
||||
# Retry tracking
|
||||
NODE_RETRY = "node_retry"
|
||||
EDGE_TRAVERSED = "edge_traversed"
|
||||
|
||||
# Worker agent lifecycle (event-driven graph execution)
|
||||
# Worker agent lifecycle
|
||||
WORKER_COMPLETED = "worker_completed"
|
||||
WORKER_FAILED = "worker_failed"
|
||||
|
||||
@@ -135,21 +131,19 @@ class EventType(StrEnum):
|
||||
# Execution resurrection (auto-restart on non-fatal failure)
|
||||
EXECUTION_RESURRECTED = "execution_resurrected"
|
||||
|
||||
# Graph lifecycle (session manager → frontend)
|
||||
WORKER_GRAPH_LOADED = "worker_graph_loaded"
|
||||
# Colony lifecycle (session manager → frontend)
|
||||
WORKER_COLONY_LOADED = "worker_colony_loaded"
|
||||
# Queen create_colony tool finished forking; carries colony_name +
|
||||
# path so the frontend can render a system message linking to the
|
||||
# new colony page at /colony/{colony_name}.
|
||||
COLONY_CREATED = "colony_created"
|
||||
CREDENTIALS_REQUIRED = "credentials_required"
|
||||
|
||||
# Draft graph (planning phase — lightweight graph preview)
|
||||
DRAFT_GRAPH_UPDATED = "draft_graph_updated"
|
||||
|
||||
# Flowchart map updated (after reconciliation with runtime graph)
|
||||
FLOWCHART_MAP_UPDATED = "flowchart_map_updated"
|
||||
|
||||
# Queen phase changes (building <-> staging <-> running)
|
||||
# Queen phase changes (working <-> reviewing)
|
||||
QUEEN_PHASE_CHANGED = "queen_phase_changed"
|
||||
|
||||
# Queen thinking hook — persona selected for the current building session
|
||||
QUEEN_PERSONA_SELECTED = "queen_persona_selected"
|
||||
# Queen identity — which queen profile was selected for this session
|
||||
QUEEN_IDENTITY_SELECTED = "queen_identity_selected"
|
||||
|
||||
# Subagent reports (one-way progress updates from sub-agents)
|
||||
SUBAGENT_REPORT = "subagent_report"
|
||||
@@ -174,7 +168,7 @@ class AgentEvent:
|
||||
data: dict[str, Any] = field(default_factory=dict)
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
correlation_id: str | None = None # For tracking related events
|
||||
graph_id: str | None = None # Which graph emitted this event (multi-graph sessions)
|
||||
colony_id: str | None = None # Which colony emitted this event
|
||||
run_id: str | None = None # Unique ID per trigger() invocation — used for run dividers
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
@@ -187,7 +181,7 @@ class AgentEvent:
|
||||
"data": self.data,
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
"correlation_id": self.correlation_id,
|
||||
"graph_id": self.graph_id,
|
||||
"colony_id": self.colony_id,
|
||||
}
|
||||
if self.run_id is not None:
|
||||
d["run_id"] = self.run_id
|
||||
@@ -208,7 +202,7 @@ class Subscription:
|
||||
filter_stream: str | None = None # Only receive events from this stream
|
||||
filter_node: str | None = None # Only receive events from this node
|
||||
filter_execution: str | None = None # Only receive events from this execution
|
||||
filter_graph: str | None = None # Only receive events from this graph
|
||||
filter_colony: str | None = None # Only receive events from this colony
|
||||
|
||||
|
||||
class EventBus:
|
||||
@@ -390,7 +384,7 @@ class EventBus:
|
||||
filter_stream: str | None = None,
|
||||
filter_node: str | None = None,
|
||||
filter_execution: str | None = None,
|
||||
filter_graph: str | None = None,
|
||||
filter_colony: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Subscribe to events.
|
||||
@@ -401,7 +395,7 @@ class EventBus:
|
||||
filter_stream: Only receive events from this stream
|
||||
filter_node: Only receive events from this node
|
||||
filter_execution: Only receive events from this execution
|
||||
filter_graph: Only receive events from this graph
|
||||
filter_colony: Only receive events from this colony
|
||||
|
||||
Returns:
|
||||
Subscription ID (use to unsubscribe)
|
||||
@@ -416,7 +410,7 @@ class EventBus:
|
||||
filter_stream=filter_stream,
|
||||
filter_node=filter_node,
|
||||
filter_execution=filter_execution,
|
||||
filter_graph=filter_graph,
|
||||
filter_colony=filter_colony,
|
||||
)
|
||||
|
||||
self._subscriptions[sub_id] = subscription
|
||||
@@ -518,23 +512,41 @@ class EventBus:
|
||||
if subscription.filter_execution and subscription.filter_execution != event.execution_id:
|
||||
return False
|
||||
|
||||
# Check graph filter
|
||||
if subscription.filter_graph and subscription.filter_graph != event.graph_id:
|
||||
# Check colony filter
|
||||
if subscription.filter_colony and subscription.filter_colony != event.colony_id:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
# Per-handler wall-clock timeout. A subscriber that deadlocks or
|
||||
# blocks on slow I/O would otherwise freeze the publisher (and via
|
||||
# ``await publish(...)`` any coroutine that emits events) indefinitely.
|
||||
# 15 s is generous for legitimate handlers and cheap to tune later.
|
||||
_HANDLER_TIMEOUT_SECONDS: float = 15.0
|
||||
|
||||
async def _execute_handlers(
|
||||
self,
|
||||
event: AgentEvent,
|
||||
handlers: list[EventHandler],
|
||||
) -> None:
|
||||
"""Execute handlers concurrently with rate limiting."""
|
||||
"""Execute handlers concurrently with rate limiting + hard timeout."""
|
||||
|
||||
async def run_handler(handler: EventHandler) -> None:
|
||||
async with self._semaphore:
|
||||
try:
|
||||
await handler(event)
|
||||
await asyncio.wait_for(
|
||||
handler(event),
|
||||
timeout=self._HANDLER_TIMEOUT_SECONDS,
|
||||
)
|
||||
except TimeoutError:
|
||||
handler_name = getattr(handler, "__qualname__", repr(handler))
|
||||
logger.error(
|
||||
"EventBus handler %s exceeded %.0fs on event %s — dropping; "
|
||||
"fix the handler or the publisher will stall",
|
||||
handler_name,
|
||||
self._HANDLER_TIMEOUT_SECONDS,
|
||||
getattr(event.type, "name", event.type),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(f"Handler error for {event.type}")
|
||||
|
||||
@@ -1029,24 +1041,6 @@ class EventBus:
|
||||
)
|
||||
)
|
||||
|
||||
async def emit_output_key_set(
|
||||
self,
|
||||
stream_id: str,
|
||||
node_id: str,
|
||||
key: str,
|
||||
execution_id: str | None = None,
|
||||
) -> None:
|
||||
"""Emit output key set event."""
|
||||
await self.publish(
|
||||
AgentEvent(
|
||||
type=EventType.OUTPUT_KEY_SET,
|
||||
stream_id=stream_id,
|
||||
node_id=node_id,
|
||||
execution_id=execution_id,
|
||||
data={"key": key},
|
||||
)
|
||||
)
|
||||
|
||||
async def emit_node_retry(
|
||||
self,
|
||||
stream_id: str,
|
||||
@@ -1071,29 +1065,6 @@ class EventBus:
|
||||
)
|
||||
)
|
||||
|
||||
async def emit_edge_traversed(
|
||||
self,
|
||||
stream_id: str,
|
||||
source_node: str,
|
||||
target_node: str,
|
||||
edge_condition: str = "",
|
||||
execution_id: str | None = None,
|
||||
) -> None:
|
||||
"""Emit edge traversed event."""
|
||||
await self.publish(
|
||||
AgentEvent(
|
||||
type=EventType.EDGE_TRAVERSED,
|
||||
stream_id=stream_id,
|
||||
node_id=source_node,
|
||||
execution_id=execution_id,
|
||||
data={
|
||||
"source_node": source_node,
|
||||
"target_node": target_node,
|
||||
"edge_condition": edge_condition,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def emit_worker_completed(
|
||||
self,
|
||||
stream_id: str,
|
||||
@@ -1208,15 +1179,25 @@ class EventBus:
|
||||
reason: str = "",
|
||||
context: str = "",
|
||||
execution_id: str | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> None:
|
||||
"""Emit escalation requested event (agent wants queen)."""
|
||||
"""Emit escalation requested event (agent wants queen).
|
||||
|
||||
``request_id`` is a caller-supplied handle used by the queen to
|
||||
address its reply back to the specific escalation. When omitted the
|
||||
event still fires but the queen cannot route a targeted reply.
|
||||
"""
|
||||
await self.publish(
|
||||
AgentEvent(
|
||||
type=EventType.ESCALATION_REQUESTED,
|
||||
stream_id=stream_id,
|
||||
node_id=node_id,
|
||||
execution_id=execution_id,
|
||||
data={"reason": reason, "context": context},
|
||||
data={
|
||||
"request_id": request_id,
|
||||
"reason": reason,
|
||||
"context": context,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1297,7 +1278,7 @@ class EventBus:
|
||||
stream_id: str | None = None,
|
||||
node_id: str | None = None,
|
||||
execution_id: str | None = None,
|
||||
graph_id: str | None = None,
|
||||
colony_id: str | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> AgentEvent | None:
|
||||
"""
|
||||
@@ -1308,7 +1289,7 @@ class EventBus:
|
||||
stream_id: Filter by stream
|
||||
node_id: Filter by node
|
||||
execution_id: Filter by execution
|
||||
graph_id: Filter by graph
|
||||
colony_id: Filter by colony
|
||||
timeout: Maximum time to wait (seconds)
|
||||
|
||||
Returns:
|
||||
@@ -1329,7 +1310,7 @@ class EventBus:
|
||||
filter_stream=stream_id,
|
||||
filter_node=node_id,
|
||||
filter_execution=execution_id,
|
||||
filter_graph=graph_id,
|
||||
filter_colony=colony_id,
|
||||
)
|
||||
|
||||
try:
|
||||
+20
-39
@@ -18,18 +18,18 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from framework.graph.checkpoint_config import CheckpointConfig
|
||||
from framework.graph.executor import ExecutionResult, GraphExecutor
|
||||
from framework.runtime.event_bus import EventBus
|
||||
from framework.runtime.shared_state import IsolationLevel, SharedBufferManager
|
||||
from framework.runtime.stream_runtime import StreamRuntime, StreamRuntimeAdapter
|
||||
from framework.host.event_bus import EventBus
|
||||
from framework.host.shared_state import IsolationLevel, SharedBufferManager
|
||||
from framework.host.stream_runtime import StreamDecisionTracker, StreamRuntimeAdapter
|
||||
from framework.orchestrator.checkpoint_config import CheckpointConfig
|
||||
from framework.orchestrator.orchestrator import ExecutionResult, Orchestrator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from framework.graph.edge import GraphSpec
|
||||
from framework.graph.goal import Goal
|
||||
from framework.host.event_bus import AgentEvent
|
||||
from framework.host.outcome_aggregator import OutcomeAggregator
|
||||
from framework.llm.provider import LLMProvider, Tool
|
||||
from framework.runtime.event_bus import AgentEvent
|
||||
from framework.runtime.outcome_aggregator import OutcomeAggregator
|
||||
from framework.orchestrator.edge import GraphSpec
|
||||
from framework.orchestrator.goal import Goal
|
||||
from framework.storage.concurrent import ConcurrentStorage
|
||||
from framework.storage.session_store import SessionStore
|
||||
|
||||
@@ -133,7 +133,7 @@ class ExecutionContext:
|
||||
status: str = "pending" # pending, running, completed, failed, paused
|
||||
|
||||
|
||||
class ExecutionStream:
|
||||
class ExecutionManager:
|
||||
"""
|
||||
Manages concurrent executions for a single entry point.
|
||||
|
||||
@@ -172,7 +172,7 @@ class ExecutionStream:
|
||||
goal: "Goal",
|
||||
state_manager: SharedBufferManager,
|
||||
storage: "ConcurrentStorage",
|
||||
outcome_aggregator: "OutcomeAggregator",
|
||||
outcome_aggregator: "OutcomeAggregator | None" = None,
|
||||
event_bus: "EventBus | None" = None,
|
||||
llm: "LLMProvider | None" = None,
|
||||
tools: list["Tool"] | None = None,
|
||||
@@ -192,10 +192,6 @@ class ExecutionStream:
|
||||
context_warn_ratio: float | None = None,
|
||||
batch_init_nudge: str | None = None,
|
||||
dynamic_memory_provider_factory: Callable[[str], Callable[[], str] | None] | None = None,
|
||||
colony_memory_dir: Any = None,
|
||||
colony_worker_sessions_dir: Any = None,
|
||||
colony_recall_cache: dict[str, str] | None = None,
|
||||
colony_reflect_llm: Any = None,
|
||||
):
|
||||
"""
|
||||
Initialize execution stream.
|
||||
@@ -251,10 +247,6 @@ class ExecutionStream:
|
||||
self._context_warn_ratio: float | None = context_warn_ratio
|
||||
self._batch_init_nudge: str | None = batch_init_nudge
|
||||
self._dynamic_memory_provider_factory = dynamic_memory_provider_factory
|
||||
self._colony_memory_dir = colony_memory_dir
|
||||
self._colony_worker_sessions_dir = colony_worker_sessions_dir
|
||||
self._colony_recall_cache = colony_recall_cache
|
||||
self._colony_reflect_llm = colony_reflect_llm
|
||||
|
||||
_es_logger = logging.getLogger(__name__)
|
||||
if protocols_prompt:
|
||||
@@ -270,16 +262,15 @@ class ExecutionStream:
|
||||
)
|
||||
|
||||
# Create stream-scoped runtime
|
||||
self._runtime = StreamRuntime(
|
||||
self._runtime = StreamDecisionTracker(
|
||||
stream_id=stream_id,
|
||||
storage=storage,
|
||||
outcome_aggregator=outcome_aggregator,
|
||||
)
|
||||
|
||||
# Execution tracking
|
||||
self._active_executions: dict[str, ExecutionContext] = {}
|
||||
self._execution_tasks: dict[str, asyncio.Task] = {}
|
||||
self._active_executors: dict[str, GraphExecutor] = {}
|
||||
self._active_executors: dict[str, Orchestrator] = {}
|
||||
self._cancel_reasons: dict[str, str] = {}
|
||||
self._execution_results: OrderedDict[str, ExecutionResult] = OrderedDict()
|
||||
self._execution_result_times: dict[str, float] = {}
|
||||
@@ -309,7 +300,7 @@ class ExecutionStream:
|
||||
|
||||
# Emit stream started event
|
||||
if self._scoped_event_bus:
|
||||
from framework.runtime.event_bus import AgentEvent, EventType
|
||||
from framework.host.event_bus import AgentEvent, EventType
|
||||
|
||||
await self._scoped_event_bus.publish(
|
||||
AgentEvent(
|
||||
@@ -434,7 +425,7 @@ class ExecutionStream:
|
||||
|
||||
# Emit stream stopped event
|
||||
if self._scoped_event_bus:
|
||||
from framework.runtime.event_bus import AgentEvent, EventType
|
||||
from framework.host.event_bus import AgentEvent, EventType
|
||||
|
||||
await self._scoped_event_bus.publish(
|
||||
AgentEvent(
|
||||
@@ -676,7 +667,7 @@ class ExecutionStream:
|
||||
# Create per-execution runtime logger
|
||||
runtime_logger = None
|
||||
if self._runtime_log_store:
|
||||
from framework.runtime.runtime_logger import RuntimeLogger
|
||||
from framework.tracker.runtime_logger import RuntimeLogger
|
||||
|
||||
runtime_logger = RuntimeLogger(
|
||||
store=self._runtime_log_store, agent_id=self.graph.id
|
||||
@@ -705,12 +696,7 @@ class ExecutionStream:
|
||||
# forward so the next attempt resumes at the failed node.
|
||||
while True:
|
||||
# Create executor for this execution.
|
||||
# Each execution gets its own storage under sessions/{exec_id}/
|
||||
# so conversations, spillover, and data files are all scoped
|
||||
# to this execution. The executor sets data_dir via execution
|
||||
# context (contextvars) so data tools and spillover share the
|
||||
# same session-scoped directory.
|
||||
executor = GraphExecutor(
|
||||
executor = Orchestrator(
|
||||
runtime=runtime_adapter,
|
||||
llm=self._llm,
|
||||
tools=self._tools,
|
||||
@@ -735,10 +721,6 @@ class ExecutionStream:
|
||||
if self._dynamic_memory_provider_factory is not None
|
||||
else None
|
||||
),
|
||||
colony_memory_dir=self._colony_memory_dir,
|
||||
colony_worker_sessions_dir=self._colony_worker_sessions_dir,
|
||||
colony_recall_cache=self._colony_recall_cache,
|
||||
colony_reflect_llm=self._colony_reflect_llm,
|
||||
)
|
||||
# Track executor so inject_input() can reach EventLoopNode instances
|
||||
self._active_executors[execution_id] = executor
|
||||
@@ -775,7 +757,7 @@ class ExecutionStream:
|
||||
|
||||
# Emit resurrection event
|
||||
if self._scoped_event_bus:
|
||||
from framework.runtime.event_bus import AgentEvent, EventType
|
||||
from framework.host.event_bus import AgentEvent, EventType
|
||||
|
||||
await self._scoped_event_bus.publish(
|
||||
AgentEvent(
|
||||
@@ -1131,7 +1113,7 @@ class ExecutionStream:
|
||||
Each stream only executes from its own entry_node, but the full
|
||||
graph must validate with all entry points accounted for.
|
||||
"""
|
||||
from framework.graph.edge import GraphSpec
|
||||
from framework.orchestrator.edge import GraphSpec
|
||||
|
||||
# Merge entry points: this stream's entry + original graph's primary
|
||||
# entry + any other entry points. This ensures all nodes are
|
||||
@@ -1236,8 +1218,7 @@ class ExecutionStream:
|
||||
# The task will continue winding down in the background and its
|
||||
# finally block will harmlessly pop already-removed keys.
|
||||
logger.warning(
|
||||
"Execution %s did not finish within cancel timeout; "
|
||||
"force-cleaning bookkeeping",
|
||||
"Execution %s did not finish within cancel timeout; force-cleaning bookkeeping",
|
||||
execution_id,
|
||||
)
|
||||
async with self._lock:
|
||||
@@ -0,0 +1,9 @@
|
||||
"""State isolation level enum."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class IsolationLevel(StrEnum):
|
||||
ISOLATED = "isolated"
|
||||
SHARED = "shared"
|
||||
SYNCHRONIZED = "synchronized"
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Stub — outcome aggregator removed in colony refactor."""
|
||||
|
||||
from framework.schemas.goal import Goal
|
||||
|
||||
|
||||
class OutcomeAggregator:
|
||||
def __init__(self, goal: Goal, event_bus=None):
|
||||
self._goal = goal
|
||||
self._event_bus = event_bus
|
||||
|
||||
def record_decision(self, **kwargs):
|
||||
pass
|
||||
|
||||
def record_outcome(self, **kwargs):
|
||||
pass
|
||||
|
||||
def evaluate_goal_progress(self):
|
||||
return {"progress": 0.0, "criteria_status": {}}
|
||||
|
||||
def get_stats(self):
|
||||
return {"total_decisions": 0, "total_outcomes": 0}
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Stub — shared state removed in colony refactor."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IsolationLevel(StrEnum):
|
||||
ISOLATED = "isolated"
|
||||
SHARED = "shared"
|
||||
SYNCHRONIZED = "synchronized"
|
||||
|
||||
|
||||
class StateScope(StrEnum):
|
||||
EXECUTION = "execution"
|
||||
STREAM = "stream"
|
||||
GLOBAL = "global"
|
||||
|
||||
|
||||
class SharedBufferManager:
|
||||
def __init__(self):
|
||||
self._global_state: dict[str, Any] = {}
|
||||
self._stream_states: dict[str, dict[str, Any]] = {}
|
||||
self._execution_states: dict[str, dict[str, Any]] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def create_buffer(
|
||||
self,
|
||||
execution_id: str,
|
||||
stream_id: str = "",
|
||||
isolation: IsolationLevel = IsolationLevel.ISOLATED,
|
||||
):
|
||||
execution_key = f"{stream_id}:{execution_id}"
|
||||
if execution_key not in self._execution_states:
|
||||
self._execution_states[execution_key] = {}
|
||||
return self._execution_states[execution_key]
|
||||
|
||||
def get_stream_state(self, stream_id: str) -> dict[str, Any]:
|
||||
return self._stream_states.setdefault(stream_id, {})
|
||||
|
||||
def get_global_state(self) -> dict[str, Any]:
|
||||
return self._global_state
|
||||
|
||||
def cleanup_execution(self, execution_id: str, stream_id: str = "") -> None:
|
||||
"""Drop the per-execution state bucket.
|
||||
|
||||
No-op when the key is absent. Called from
|
||||
``ExecutionManager._run_execution``'s finally block. Before this
|
||||
stub existed, the call raised ``AttributeError`` on every
|
||||
execution teardown because the SharedBufferManager stub had no
|
||||
such method.
|
||||
"""
|
||||
execution_key = f"{stream_id}:{execution_id}"
|
||||
self._execution_states.pop(execution_key, None)
|
||||
|
||||
def get_recent_changes(self, limit: int = 10) -> list[dict[str, Any]]:
|
||||
"""Compat stub — returns empty list. Shared buffer was removed."""
|
||||
return []
|
||||
@@ -10,20 +10,17 @@ import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
from framework.observability import set_trace_context
|
||||
from framework.schemas.decision import Decision, DecisionType, Option, Outcome
|
||||
from framework.schemas.run import Run, RunStatus
|
||||
from framework.storage.concurrent import ConcurrentStorage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from framework.runtime.outcome_aggregator import OutcomeAggregator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StreamRuntime:
|
||||
class StreamDecisionTracker:
|
||||
"""
|
||||
Thread-safe runtime for a single execution stream.
|
||||
|
||||
@@ -75,7 +72,6 @@ class StreamRuntime:
|
||||
self,
|
||||
stream_id: str,
|
||||
storage: ConcurrentStorage,
|
||||
outcome_aggregator: "OutcomeAggregator | None" = None,
|
||||
):
|
||||
"""
|
||||
Initialize stream runtime.
|
||||
@@ -83,11 +79,9 @@ class StreamRuntime:
|
||||
Args:
|
||||
stream_id: Unique identifier for this stream
|
||||
storage: Concurrent storage backend
|
||||
outcome_aggregator: Optional aggregator for cross-stream evaluation
|
||||
"""
|
||||
self.stream_id = stream_id
|
||||
self._storage = storage
|
||||
self._outcome_aggregator = outcome_aggregator
|
||||
|
||||
# Track runs by execution_id (thread-safe via lock)
|
||||
self._runs: dict[str, Run] = {}
|
||||
@@ -268,14 +262,6 @@ class StreamRuntime:
|
||||
|
||||
run.add_decision(decision)
|
||||
|
||||
# Report to outcome aggregator if available
|
||||
if self._outcome_aggregator:
|
||||
self._outcome_aggregator.record_decision(
|
||||
stream_id=self.stream_id,
|
||||
execution_id=execution_id,
|
||||
decision=decision,
|
||||
)
|
||||
|
||||
return decision_id
|
||||
|
||||
def record_outcome(
|
||||
@@ -321,15 +307,6 @@ class StreamRuntime:
|
||||
|
||||
run.record_outcome(decision_id, outcome)
|
||||
|
||||
# Report to outcome aggregator if available
|
||||
if self._outcome_aggregator:
|
||||
self._outcome_aggregator.record_outcome(
|
||||
stream_id=self.stream_id,
|
||||
execution_id=execution_id,
|
||||
decision_id=decision_id,
|
||||
outcome=outcome,
|
||||
)
|
||||
|
||||
# === PROBLEM RECORDING ===
|
||||
|
||||
def report_problem(
|
||||
@@ -431,7 +408,7 @@ class StreamRuntimeAdapter:
|
||||
by providing the same API as Runtime but routing to a specific execution.
|
||||
"""
|
||||
|
||||
def __init__(self, stream_runtime: StreamRuntime, execution_id: str):
|
||||
def __init__(self, stream_runtime: StreamDecisionTracker, execution_id: str):
|
||||
"""
|
||||
Create adapter for a specific execution.
|
||||
|
||||
@@ -13,7 +13,7 @@ from dataclasses import dataclass
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from framework.runtime.event_bus import EventBus
|
||||
from framework.host.event_bus import EventBus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
"""Worker — a single autonomous AgentLoop clone in a colony.
|
||||
|
||||
Two modes:
|
||||
|
||||
**Ephemeral (default)**: runs a single AgentLoop execution with a task,
|
||||
emits a `SUBAGENT_REPORT` event on termination (success, partial, or
|
||||
failed), and terminates. Used for parallel fan-out from the overseer.
|
||||
|
||||
**Persistent (``persistent=True``)**: runs an initial AgentLoop execution
|
||||
(usually idle, no task) and then loops forever, receiving user chat via
|
||||
``inject(message)`` and pumping each message into the already-running
|
||||
agent loop via ``inject_event``. Used for the colony's long-running
|
||||
client-facing overseer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorkerStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
STOPPED = "stopped"
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkerResult:
|
||||
output: dict[str, Any] = field(default_factory=dict)
|
||||
error: str | None = None
|
||||
tokens_used: int = 0
|
||||
duration_seconds: float = 0.0
|
||||
# New: structured report fields. Populated by report_to_parent tool or
|
||||
# synthesised from AgentResult on termination.
|
||||
status: str = "success" # "success" | "partial" | "failed" | "timeout" | "stopped"
|
||||
summary: str = ""
|
||||
data: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkerInfo:
|
||||
id: str
|
||||
task: str
|
||||
status: WorkerStatus
|
||||
started_at: float = 0.0
|
||||
result: WorkerResult | None = None
|
||||
|
||||
|
||||
class Worker:
|
||||
"""A single autonomous clone in a colony.
|
||||
|
||||
Ephemeral mode (default):
|
||||
- PENDING → RUNNING → COMPLETED/FAILED/STOPPED, one shot, terminates.
|
||||
|
||||
Persistent mode (``persistent=True``, used by the overseer):
|
||||
- PENDING → RUNNING (never transitions out by itself).
|
||||
- Receives user chat via ``inject(message)``.
|
||||
- Each injected message is pumped into the running AgentLoop via
|
||||
``inject_event``, triggering another turn.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
worker_id: str,
|
||||
task: str,
|
||||
agent_loop: Any,
|
||||
context: Any,
|
||||
event_bus: Any = None,
|
||||
colony_id: str = "",
|
||||
persistent: bool = False,
|
||||
storage_path: Path | None = None,
|
||||
):
|
||||
self.id = worker_id
|
||||
self.task = task
|
||||
self.status = WorkerStatus.PENDING
|
||||
self._agent_loop = agent_loop
|
||||
self._context = context
|
||||
self._event_bus = event_bus
|
||||
self._colony_id = colony_id
|
||||
self._persistent = persistent
|
||||
# Canonical on-disk home for this worker (conversations, events,
|
||||
# result.json, data). Required when seed_conversation() is used —
|
||||
# we deliberately do NOT fall back to CWD, which previously caused
|
||||
# conversation parts to leak into the process working directory.
|
||||
self._storage_path: Path | None = (
|
||||
Path(storage_path) if storage_path is not None else None
|
||||
)
|
||||
self._task_handle: asyncio.Task | None = None
|
||||
self._started_at: float = 0.0
|
||||
self._result: WorkerResult | None = None
|
||||
self._input_queue: asyncio.Queue[str | None] = asyncio.Queue()
|
||||
# Set by AgentLoop when the worker's LLM calls ``report_to_parent``.
|
||||
# Takes precedence over the synthesised report from AgentResult.
|
||||
self._explicit_report: dict[str, Any] | None = None
|
||||
# Back-reference so AgentLoop's report_to_parent handler can call
|
||||
# record_explicit_report on the owning Worker. The agent_loop's
|
||||
# _owner_worker attribute is set here during construction.
|
||||
if agent_loop is not None:
|
||||
agent_loop._owner_worker = self
|
||||
|
||||
@property
|
||||
def info(self) -> WorkerInfo:
|
||||
return WorkerInfo(
|
||||
id=self.id,
|
||||
task=self.task,
|
||||
status=self.status,
|
||||
started_at=self._started_at,
|
||||
result=self._result,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return self.status in (WorkerStatus.PENDING, WorkerStatus.RUNNING)
|
||||
|
||||
@property
|
||||
def is_persistent(self) -> bool:
|
||||
return self._persistent
|
||||
|
||||
@property
|
||||
def agent_loop(self) -> Any:
|
||||
"""The wrapped AgentLoop. Used by the SessionManager chat path."""
|
||||
return self._agent_loop
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def run(self) -> WorkerResult:
|
||||
"""Entry point for the worker's background task.
|
||||
|
||||
Ephemeral workers run ``AgentLoop.execute`` once and terminate,
|
||||
emitting a ``SUBAGENT_REPORT`` event.
|
||||
|
||||
Persistent workers run the initial execute then loop forever
|
||||
processing injected user messages.
|
||||
"""
|
||||
self.status = WorkerStatus.RUNNING
|
||||
self._started_at = time.monotonic()
|
||||
|
||||
try:
|
||||
result = await self._agent_loop.execute(self._context)
|
||||
duration = time.monotonic() - self._started_at
|
||||
|
||||
if result.success:
|
||||
self.status = WorkerStatus.COMPLETED
|
||||
self._result = self._build_result(
|
||||
result, duration, default_status="success"
|
||||
)
|
||||
else:
|
||||
self.status = WorkerStatus.FAILED
|
||||
self._result = self._build_result(
|
||||
result, duration, default_status="failed"
|
||||
)
|
||||
|
||||
await self._emit_terminal_events(result)
|
||||
|
||||
if self._persistent:
|
||||
# Persistent worker: keep the loop alive, pump injected
|
||||
# messages forever. Status stays RUNNING; info reflects
|
||||
# current progress.
|
||||
self.status = WorkerStatus.RUNNING
|
||||
await self._persistent_input_loop()
|
||||
|
||||
return self._result # type: ignore[return-value]
|
||||
|
||||
except asyncio.CancelledError:
|
||||
self.status = WorkerStatus.STOPPED
|
||||
duration = time.monotonic() - self._started_at
|
||||
self._result = WorkerResult(
|
||||
error="Worker stopped by queen",
|
||||
duration_seconds=duration,
|
||||
status="stopped",
|
||||
summary="Worker was cancelled before completion.",
|
||||
)
|
||||
await self._emit_terminal_events(None, force_status="stopped")
|
||||
return self._result
|
||||
|
||||
except Exception as exc:
|
||||
self.status = WorkerStatus.FAILED
|
||||
duration = time.monotonic() - self._started_at
|
||||
self._result = WorkerResult(
|
||||
error=str(exc),
|
||||
duration_seconds=duration,
|
||||
status="failed",
|
||||
summary=f"Worker crashed: {exc}",
|
||||
)
|
||||
logger.error("Worker %s failed: %s", self.id, exc, exc_info=True)
|
||||
await self._emit_terminal_events(None, force_status="failed")
|
||||
return self._result
|
||||
|
||||
async def _persistent_input_loop(self) -> None:
|
||||
"""Pump injected messages into the running AgentLoop forever.
|
||||
|
||||
Each ``inject(msg)`` call puts a string on ``_input_queue``. This
|
||||
loop awaits it and calls ``agent_loop.inject_event(msg)`` which
|
||||
wakes the loop's pending user-input gate.
|
||||
"""
|
||||
while True:
|
||||
msg = await self._input_queue.get()
|
||||
if msg is None:
|
||||
# Sentinel: shutdown
|
||||
return
|
||||
try:
|
||||
await self._agent_loop.inject_event(msg, is_client_input=True)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Overseer %s: inject_event failed for injected message",
|
||||
self.id,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reporting
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def record_explicit_report(
|
||||
self,
|
||||
status: str,
|
||||
summary: str,
|
||||
data: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Called by AgentLoop when the worker's LLM invokes ``report_to_parent``.
|
||||
|
||||
Stores the report so that when ``run()`` reaches the termination
|
||||
block, the explicit report wins over a synthesised one.
|
||||
"""
|
||||
self._explicit_report = {
|
||||
"status": status,
|
||||
"summary": summary,
|
||||
"data": data or {},
|
||||
}
|
||||
|
||||
def _build_result(
|
||||
self,
|
||||
agent_result: Any,
|
||||
duration: float,
|
||||
default_status: str,
|
||||
) -> WorkerResult:
|
||||
"""Construct a WorkerResult from AgentResult + optional explicit report."""
|
||||
explicit = self._explicit_report
|
||||
if explicit is not None:
|
||||
return WorkerResult(
|
||||
output=dict(agent_result.output or {}),
|
||||
error=agent_result.error,
|
||||
tokens_used=getattr(agent_result, "tokens_used", 0),
|
||||
duration_seconds=duration,
|
||||
status=explicit["status"],
|
||||
summary=explicit["summary"],
|
||||
data=explicit["data"],
|
||||
)
|
||||
# Synthesise a minimal report from AgentResult
|
||||
if agent_result.success:
|
||||
summary = f"Completed task '{self.task[:80]}' with {len(agent_result.output or {})} outputs."
|
||||
data = dict(agent_result.output or {})
|
||||
else:
|
||||
summary = f"Task '{self.task[:80]}' failed: {agent_result.error or 'unknown'}"
|
||||
data = {}
|
||||
return WorkerResult(
|
||||
output=dict(agent_result.output or {}),
|
||||
error=agent_result.error,
|
||||
tokens_used=getattr(agent_result, "tokens_used", 0),
|
||||
duration_seconds=duration,
|
||||
status=default_status,
|
||||
summary=summary,
|
||||
data=data,
|
||||
)
|
||||
|
||||
async def _emit_terminal_events(
|
||||
self,
|
||||
agent_result: Any,
|
||||
force_status: str | None = None,
|
||||
) -> None:
|
||||
"""Emit EXECUTION_COMPLETED/FAILED AND SUBAGENT_REPORT on termination.
|
||||
|
||||
Both events are published so that consumers that listen for
|
||||
either shape keep working. The SUBAGENT_REPORT carries the
|
||||
structured summary the overseer actually cares about.
|
||||
"""
|
||||
if self._event_bus is None:
|
||||
return
|
||||
|
||||
from framework.host.event_bus import AgentEvent, EventType
|
||||
|
||||
# EXECUTION_COMPLETED / EXECUTION_FAILED (backwards-compat)
|
||||
if agent_result is not None:
|
||||
lifecycle_type = (
|
||||
EventType.EXECUTION_COMPLETED
|
||||
if agent_result.success
|
||||
else EventType.EXECUTION_FAILED
|
||||
)
|
||||
await self._event_bus.publish(
|
||||
AgentEvent(
|
||||
type=lifecycle_type,
|
||||
stream_id=self._context.stream_id or self.id,
|
||||
node_id=self.id,
|
||||
execution_id=self._context.execution_id or self.id,
|
||||
data={
|
||||
"worker_id": self.id,
|
||||
"colony_id": self._colony_id,
|
||||
"task": self.task,
|
||||
"success": agent_result.success,
|
||||
"error": agent_result.error,
|
||||
"output_keys": (
|
||||
list(agent_result.output.keys())
|
||||
if agent_result.output
|
||||
else []
|
||||
),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# SUBAGENT_REPORT — the structured channel the overseer awaits
|
||||
result = self._result
|
||||
if result is None:
|
||||
return
|
||||
await self._event_bus.publish(
|
||||
AgentEvent(
|
||||
type=EventType.SUBAGENT_REPORT,
|
||||
stream_id=self._context.stream_id or self.id,
|
||||
node_id=self.id,
|
||||
execution_id=self._context.execution_id or self.id,
|
||||
data={
|
||||
"worker_id": self.id,
|
||||
"colony_id": self._colony_id,
|
||||
"task": self.task,
|
||||
"status": force_status or result.status,
|
||||
"summary": result.summary,
|
||||
"data": result.data,
|
||||
"error": result.error,
|
||||
"duration_seconds": result.duration_seconds,
|
||||
"tokens_used": result.tokens_used,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# External control
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def start_background(self) -> None:
|
||||
"""Spawn the worker's run() as an asyncio background task."""
|
||||
self._task_handle = asyncio.create_task(
|
||||
self.run(), name=f"worker:{self.id}"
|
||||
)
|
||||
# Surface any exception that escapes run(); without this callback
|
||||
# a crash here only becomes visible when stop() eventually awaits
|
||||
# the handle (and is silently lost if stop() is never called).
|
||||
self._task_handle.add_done_callback(self._on_task_done)
|
||||
|
||||
def _on_task_done(self, task: asyncio.Task) -> None:
|
||||
if task.cancelled():
|
||||
return
|
||||
exc = task.exception()
|
||||
if exc is not None:
|
||||
logger.error(
|
||||
"Worker '%s' background task crashed: %s",
|
||||
self.id,
|
||||
exc,
|
||||
exc_info=exc,
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Cancel the worker's background task, if any."""
|
||||
if self._persistent:
|
||||
# Signal the input loop to exit cleanly first
|
||||
await self._input_queue.put(None)
|
||||
if self._task_handle and not self._task_handle.done():
|
||||
self._task_handle.cancel()
|
||||
try:
|
||||
await self._task_handle
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def inject(self, message: str) -> None:
|
||||
"""Pump a user message into the worker.
|
||||
|
||||
For ephemeral workers this is rarely used (they don't take
|
||||
follow-up input). For persistent overseers this is the chat
|
||||
injection path.
|
||||
"""
|
||||
await self._input_queue.put(message)
|
||||
|
||||
async def seed_conversation(self, messages: list[dict[str, Any]]) -> None:
|
||||
"""Pre-populate the worker's ConversationStore before starting.
|
||||
|
||||
Used when forking a queen DM into a colony: the DM's prior
|
||||
conversation becomes the colony overseer's starting point so the
|
||||
overseer resumes mid-thought instead of greeting the user fresh.
|
||||
|
||||
``messages`` is a list of dicts matching the ConversationStore's
|
||||
part format: ``{seq, role, content, tool_calls, tool_use_id,
|
||||
created_at, phase}``. The caller is responsible for rewriting
|
||||
``agent_id`` to match the new worker, and for numbering ``seq``
|
||||
monotonically from 0.
|
||||
|
||||
Must be called BEFORE ``start_background``.
|
||||
"""
|
||||
if self.status != WorkerStatus.PENDING:
|
||||
raise RuntimeError(
|
||||
f"seed_conversation must be called before start_background "
|
||||
f"(worker {self.id} is {self.status})"
|
||||
)
|
||||
|
||||
# Write parts directly to the worker's on-disk conversation store
|
||||
# so that the AgentLoop's FileConversationStore picks them up when
|
||||
# NodeConversation loads from disk. We require an explicit
|
||||
# storage_path — falling back to CWD previously caused part files
|
||||
# to leak into the process working directory.
|
||||
if self._storage_path is None:
|
||||
raise RuntimeError(
|
||||
f"seed_conversation requires storage_path to be set on "
|
||||
f"Worker {self.id}; construct Worker with storage_path=..."
|
||||
)
|
||||
|
||||
parts_dir = self._storage_path / "conversations" / "parts"
|
||||
parts_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
import json
|
||||
|
||||
for i, msg in enumerate(messages):
|
||||
msg = dict(msg) # copy
|
||||
msg.setdefault("seq", i)
|
||||
msg.setdefault("agent_id", self.id)
|
||||
part_file = parts_dir / f"{msg['seq']:010d}.json"
|
||||
part_file.write_text(json.dumps(msg), encoding="utf-8")
|
||||
|
||||
logger.info(
|
||||
"Worker %s: seeded %d messages into %s",
|
||||
self.id,
|
||||
len(messages),
|
||||
parts_dir,
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Thread-safe API key pool with round-robin rotation and health tracking.
|
||||
|
||||
When multiple API keys are configured, the pool rotates through them on each
|
||||
request. Keys that hit rate limits are temporarily cooled-down so the next
|
||||
call automatically uses a healthy key -- no sleep required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeyHealth:
|
||||
"""Per-key health counters."""
|
||||
|
||||
rate_limited_until: float = 0.0 # monotonic timestamp
|
||||
consecutive_errors: int = 0
|
||||
total_requests: int = 0
|
||||
total_successes: int = 0
|
||||
|
||||
|
||||
class KeyPool:
|
||||
"""Round-robin key pool with health tracking.
|
||||
|
||||
Thread-safe: all mutations protected by a lock so concurrent LLM calls
|
||||
(e.g. parallel tool execution in EventLoopNode) don't race.
|
||||
"""
|
||||
|
||||
def __init__(self, keys: list[str]) -> None:
|
||||
if not keys:
|
||||
raise ValueError("KeyPool requires at least one key")
|
||||
self._keys = list(keys)
|
||||
self._index = 0
|
||||
self._health: dict[str, KeyHealth] = {k: KeyHealth() for k in keys}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return len(self._keys)
|
||||
|
||||
def get_key(self) -> str:
|
||||
"""Return the next healthy key (round-robin).
|
||||
|
||||
If every key is currently rate-limited, returns the one whose cooldown
|
||||
expires soonest so the caller can proceed with minimal delay.
|
||||
"""
|
||||
with self._lock:
|
||||
now = time.monotonic()
|
||||
for _ in range(len(self._keys)):
|
||||
key = self._keys[self._index]
|
||||
self._index = (self._index + 1) % len(self._keys)
|
||||
health = self._health[key]
|
||||
if health.rate_limited_until <= now:
|
||||
health.total_requests += 1
|
||||
return key
|
||||
# All rate-limited -- pick the one that expires soonest.
|
||||
soonest = min(self._keys, key=lambda k: self._health[k].rate_limited_until)
|
||||
self._health[soonest].total_requests += 1
|
||||
return soonest
|
||||
|
||||
def mark_rate_limited(self, key: str, retry_after: float = 60.0) -> None:
|
||||
"""Mark *key* as rate-limited for *retry_after* seconds."""
|
||||
with self._lock:
|
||||
health = self._health.get(key)
|
||||
if health:
|
||||
health.rate_limited_until = time.monotonic() + retry_after
|
||||
health.consecutive_errors += 1
|
||||
logger.info(
|
||||
"[key-pool] Key ...%s rate-limited for %.0fs (errors=%d)",
|
||||
key[-6:],
|
||||
retry_after,
|
||||
health.consecutive_errors,
|
||||
)
|
||||
|
||||
def mark_success(self, key: str) -> None:
|
||||
"""Record a successful call on *key*."""
|
||||
with self._lock:
|
||||
health = self._health.get(key)
|
||||
if health:
|
||||
health.consecutive_errors = 0
|
||||
health.total_successes += 1
|
||||
|
||||
def get_stats(self) -> dict[str, dict]:
|
||||
"""Return health stats keyed by the last 6 chars of each key."""
|
||||
with self._lock:
|
||||
now = time.monotonic()
|
||||
return {
|
||||
f"...{k[-6:]}": {
|
||||
"healthy": self._health[k].rate_limited_until <= now,
|
||||
"requests": self._health[k].total_requests,
|
||||
"successes": self._health[k].total_successes,
|
||||
"consecutive_errors": self._health[k].consecutive_errors,
|
||||
}
|
||||
for k in self._keys
|
||||
}
|
||||
+424
-31
@@ -7,6 +7,8 @@ Groq, and local models.
|
||||
See: https://docs.litellm.ai/docs/providers
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import hashlib
|
||||
@@ -18,7 +20,10 @@ import time
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from framework.llm.key_pool import KeyPool
|
||||
|
||||
try:
|
||||
import litellm
|
||||
@@ -33,6 +38,10 @@ from framework.llm.stream_events import StreamEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logging.getLogger("openai._base_client").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
def _patch_litellm_anthropic_oauth() -> None:
|
||||
"""Patch litellm's Anthropic header construction to fix OAuth token handling.
|
||||
@@ -272,6 +281,10 @@ OPENROUTER_TOOL_COMPAT_CACHE_TTL_SECONDS = 3600
|
||||
# OpenRouter routing can change over time, so tool-compat caching must expire.
|
||||
OPENROUTER_TOOL_COMPAT_MODEL_CACHE: dict[str, float] = {}
|
||||
|
||||
# Transient stream errors (network blips, timeouts) use a separate cap
|
||||
# from rate-limit retries — 3 retries is sufficient for connection failures.
|
||||
STREAM_TRANSIENT_MAX_RETRIES = 3
|
||||
|
||||
# Directory for dumping failed requests
|
||||
FAILED_REQUESTS_DIR = Path.home() / ".hive" / "failed_requests"
|
||||
|
||||
@@ -338,34 +351,145 @@ def _dump_failed_request(
|
||||
attempt: int,
|
||||
) -> str:
|
||||
"""Dump failed request to a file for debugging. Returns the file path."""
|
||||
FAILED_REQUESTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
FAILED_REQUESTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||
filename = f"{error_type}_{model.replace('/', '_')}_{timestamp}.json"
|
||||
filepath = FAILED_REQUESTS_DIR / filename
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||
filename = f"{error_type}_{model.replace('/', '_')}_{timestamp}.json"
|
||||
filepath = FAILED_REQUESTS_DIR / filename
|
||||
|
||||
# Build dump data
|
||||
messages = kwargs.get("messages", [])
|
||||
dump_data = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"model": model,
|
||||
"error_type": error_type,
|
||||
"attempt": attempt,
|
||||
"estimated_tokens": _estimate_tokens(model, messages),
|
||||
"num_messages": len(messages),
|
||||
"messages": messages,
|
||||
"tools": kwargs.get("tools"),
|
||||
"max_tokens": kwargs.get("max_tokens"),
|
||||
"temperature": kwargs.get("temperature"),
|
||||
# Build dump data
|
||||
messages = kwargs.get("messages", [])
|
||||
dump_data = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"model": model,
|
||||
"error_type": error_type,
|
||||
"attempt": attempt,
|
||||
"estimated_tokens": _estimate_tokens(model, messages),
|
||||
"num_messages": len(messages),
|
||||
"api_base": kwargs.get("api_base"),
|
||||
"request_keys": sorted(kwargs.keys()),
|
||||
"messages": messages,
|
||||
"tools": kwargs.get("tools"),
|
||||
"max_tokens": kwargs.get("max_tokens"),
|
||||
"temperature": kwargs.get("temperature"),
|
||||
"stream": kwargs.get("stream"),
|
||||
"tool_choice": kwargs.get("tool_choice"),
|
||||
"response_format": kwargs.get("response_format"),
|
||||
}
|
||||
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(dump_data, f, indent=2, default=str)
|
||||
|
||||
# Prune old dumps to prevent unbounded disk growth
|
||||
_prune_failed_request_dumps()
|
||||
|
||||
return str(filepath)
|
||||
except OSError as e:
|
||||
logger.warning(f"Failed to dump request debug log to {FAILED_REQUESTS_DIR}: {e}")
|
||||
return "log_write_failed"
|
||||
|
||||
|
||||
def _summarize_message_content(content: Any) -> dict[str, Any]:
|
||||
"""Return a structural summary of one message content payload."""
|
||||
if isinstance(content, str):
|
||||
return {
|
||||
"content_kind": "string",
|
||||
"text_chars": len(content),
|
||||
}
|
||||
|
||||
if isinstance(content, list):
|
||||
block_types: list[str] = []
|
||||
text_chars = 0
|
||||
for block in content:
|
||||
if isinstance(block, dict):
|
||||
block_type = str(block.get("type", "unknown"))
|
||||
block_types.append(block_type)
|
||||
if block_type == "text":
|
||||
text_chars += len(str(block.get("text", "")))
|
||||
elif block_type == "tool_result":
|
||||
block_content = block.get("content")
|
||||
if isinstance(block_content, str):
|
||||
text_chars += len(block_content)
|
||||
elif isinstance(block_content, list):
|
||||
for inner in block_content:
|
||||
if isinstance(inner, dict) and inner.get("type") == "text":
|
||||
text_chars += len(str(inner.get("text", "")))
|
||||
else:
|
||||
block_types.append(type(block).__name__)
|
||||
return {
|
||||
"content_kind": "list",
|
||||
"blocks": len(content),
|
||||
"block_types": block_types,
|
||||
"text_chars": text_chars,
|
||||
}
|
||||
|
||||
return {
|
||||
"content_kind": type(content).__name__,
|
||||
}
|
||||
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(dump_data, f, indent=2, default=str)
|
||||
|
||||
# Prune old dumps to prevent unbounded disk growth
|
||||
_prune_failed_request_dumps()
|
||||
def _summarize_messages_for_log(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Build a high-signal, no-secret summary of the outgoing messages payload."""
|
||||
summary: list[dict[str, Any]] = []
|
||||
for idx, message in enumerate(messages):
|
||||
item: dict[str, Any] = {
|
||||
"idx": idx,
|
||||
"role": message.get("role"),
|
||||
"keys": sorted(message.keys()),
|
||||
}
|
||||
item.update(_summarize_message_content(message.get("content")))
|
||||
tool_calls = message.get("tool_calls")
|
||||
if isinstance(tool_calls, list):
|
||||
item["tool_calls"] = len(tool_calls)
|
||||
tool_names = []
|
||||
for tc in tool_calls:
|
||||
if isinstance(tc, dict):
|
||||
fn = tc.get("function")
|
||||
if isinstance(fn, dict) and fn.get("name"):
|
||||
tool_names.append(str(fn["name"]))
|
||||
if tool_names:
|
||||
item["tool_call_names"] = tool_names
|
||||
if message.get("cache_control"):
|
||||
item["cache_control"] = True
|
||||
if message.get("tool_call_id"):
|
||||
item["tool_call_id"] = str(message.get("tool_call_id"))
|
||||
summary.append(item)
|
||||
return summary
|
||||
|
||||
return str(filepath)
|
||||
|
||||
def _summarize_request_for_log(kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return a compact structural summary of a LiteLLM request payload."""
|
||||
tools = kwargs.get("tools")
|
||||
tool_names: list[str] = []
|
||||
if isinstance(tools, list):
|
||||
for tool in tools:
|
||||
if isinstance(tool, dict):
|
||||
fn = tool.get("function")
|
||||
if isinstance(fn, dict) and fn.get("name"):
|
||||
tool_names.append(str(fn["name"]))
|
||||
|
||||
messages = kwargs.get("messages", [])
|
||||
if isinstance(messages, list):
|
||||
non_system_roles = [m.get("role") for m in messages if m.get("role") != "system"]
|
||||
else:
|
||||
non_system_roles = []
|
||||
return {
|
||||
"model": kwargs.get("model"),
|
||||
"api_base": kwargs.get("api_base"),
|
||||
"stream": kwargs.get("stream"),
|
||||
"max_tokens": kwargs.get("max_tokens"),
|
||||
"tool_count": len(tools) if isinstance(tools, list) else 0,
|
||||
"tool_names": tool_names,
|
||||
"tool_choice": kwargs.get("tool_choice"),
|
||||
"response_format": bool(kwargs.get("response_format")),
|
||||
"message_count": len(messages) if isinstance(messages, list) else 0,
|
||||
"non_system_message_count": len(non_system_roles),
|
||||
"first_non_system_role": non_system_roles[0] if non_system_roles else None,
|
||||
"last_non_system_role": non_system_roles[-1] if non_system_roles else None,
|
||||
"system_only": bool(messages) and not non_system_roles,
|
||||
"messages": _summarize_messages_for_log(messages if isinstance(messages, list) else []),
|
||||
}
|
||||
|
||||
|
||||
def _compute_retry_delay(
|
||||
@@ -458,6 +582,59 @@ def _is_stream_transient_error(exc: BaseException) -> bool:
|
||||
return isinstance(exc, transient_types)
|
||||
|
||||
|
||||
def _extract_text_tool_calls(
|
||||
text: str,
|
||||
) -> tuple[list, str]:
|
||||
"""Extract hallucinated tool calls from ``<tool_code>`` blocks in LLM text.
|
||||
|
||||
Some models (notably Gemini) emit tool invocations as text instead of using
|
||||
the structured function-calling API. This function parses those blocks and
|
||||
returns ``(tool_call_events, cleaned_text)`` where *cleaned_text* has the
|
||||
``<tool_code>`` blocks removed.
|
||||
|
||||
Expected format::
|
||||
|
||||
<tool_code>
|
||||
{
|
||||
"tool_name": { ...args }
|
||||
}
|
||||
</tool_code>
|
||||
"""
|
||||
from framework.llm.stream_events import ToolCallEvent
|
||||
|
||||
pattern = re.compile(r"<tool_code>\s*(.*?)\s*</tool_code>", re.DOTALL)
|
||||
events: list[ToolCallEvent] = []
|
||||
cleaned = text
|
||||
|
||||
for match in pattern.finditer(text):
|
||||
raw = match.group(1).strip()
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("[_extract_text_tool_calls] failed to parse JSON: %s", raw[:200])
|
||||
continue
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
|
||||
for tool_name, tool_args in payload.items():
|
||||
key = f"{tool_name}:{json.dumps(tool_args, sort_keys=True)}"
|
||||
digest = hashlib.md5(key.encode()).hexdigest()[:12]
|
||||
call_id = f"synth_{digest}"
|
||||
events.append(
|
||||
ToolCallEvent(
|
||||
tool_use_id=call_id,
|
||||
tool_name=tool_name,
|
||||
tool_input=tool_args if isinstance(tool_args, dict) else {},
|
||||
)
|
||||
)
|
||||
|
||||
if events:
|
||||
cleaned = pattern.sub("", text).strip()
|
||||
|
||||
return events, cleaned
|
||||
|
||||
|
||||
class LiteLLMProvider(LLMProvider):
|
||||
"""
|
||||
LiteLLM-based LLM provider for multi-provider support.
|
||||
@@ -500,6 +677,7 @@ class LiteLLMProvider(LLMProvider):
|
||||
model: str = "gpt-4o-mini",
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
api_keys: list[str] | None = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
@@ -512,6 +690,9 @@ class LiteLLMProvider(LLMProvider):
|
||||
look for the appropriate env var (OPENAI_API_KEY,
|
||||
ANTHROPIC_API_KEY, etc.)
|
||||
api_base: Custom API base URL (for proxies or local deployments)
|
||||
api_keys: Optional list of API keys for key-pool rotation. When
|
||||
provided with 2+ keys, a :class:`KeyPool` is created and
|
||||
keys are rotated on rate-limit errors.
|
||||
**kwargs: Additional arguments passed to litellm.completion()
|
||||
"""
|
||||
# Kimi For Coding exposes an Anthropic-compatible endpoint at
|
||||
@@ -533,11 +714,24 @@ class LiteLLMProvider(LLMProvider):
|
||||
if api_base and api_base.rstrip("/").endswith("/v1"):
|
||||
api_base = api_base.rstrip("/")[:-3]
|
||||
self.model = model
|
||||
self.api_key = api_key
|
||||
# Key pool: when multiple keys are provided, enable rotation.
|
||||
self._key_pool: KeyPool | None = None
|
||||
if api_keys and len(api_keys) > 1:
|
||||
from framework.llm.key_pool import KeyPool
|
||||
|
||||
self._key_pool = KeyPool(api_keys)
|
||||
self.api_key = api_keys[0] # default for OAuth detection below
|
||||
logger.info(
|
||||
"[litellm] Key pool enabled with %d keys for model %s",
|
||||
len(api_keys),
|
||||
model,
|
||||
)
|
||||
else:
|
||||
self.api_key = api_key or (api_keys[0] if api_keys else None)
|
||||
self.api_base = api_base or self._default_api_base_for_model(_original_model)
|
||||
self.extra_kwargs = kwargs
|
||||
# Detect Claude Code OAuth subscription by checking the api_key prefix.
|
||||
self._claude_code_oauth = bool(api_key and api_key.startswith("sk-ant-oat"))
|
||||
self._claude_code_oauth = bool(self.api_key and self.api_key.startswith("sk-ant-oat"))
|
||||
if self._claude_code_oauth:
|
||||
# Anthropic requires a specific User-Agent for OAuth requests.
|
||||
eh = self.extra_kwargs.setdefault("extra_headers", {})
|
||||
@@ -555,6 +749,38 @@ class LiteLLMProvider(LLMProvider):
|
||||
"LiteLLM is not installed. Please install it with: uv pip install litellm"
|
||||
)
|
||||
|
||||
def reconfigure(
|
||||
self, model: str, api_key: str | None = None, api_base: str | None = None
|
||||
) -> None:
|
||||
"""Hot-swap the model, API key, and/or base URL on this provider instance.
|
||||
|
||||
Since the same LiteLLMProvider object is shared by reference across the
|
||||
session, queen runner, agent runtime, and execution streams, mutating
|
||||
these attributes in-place propagates to all callers on the next LLM call.
|
||||
"""
|
||||
_original_model = model
|
||||
if _is_ollama_model(model):
|
||||
model = _ensure_ollama_chat_prefix(model)
|
||||
elif model.lower().startswith("kimi/"):
|
||||
model = "anthropic/" + model[len("kimi/") :]
|
||||
if api_base and api_base.rstrip("/").endswith("/v1"):
|
||||
api_base = api_base.rstrip("/")[:-3]
|
||||
elif model.lower().startswith("hive/"):
|
||||
model = "anthropic/" + model[len("hive/") :]
|
||||
if api_base and api_base.rstrip("/").endswith("/v1"):
|
||||
api_base = api_base.rstrip("/")[:-3]
|
||||
self.model = model
|
||||
self.api_key = api_key
|
||||
self.api_base = api_base or self._default_api_base_for_model(_original_model)
|
||||
self._claude_code_oauth = bool(api_key and api_key.startswith("sk-ant-oat"))
|
||||
if self._claude_code_oauth:
|
||||
eh = self.extra_kwargs.setdefault("extra_headers", {})
|
||||
eh.setdefault("user-agent", CLAUDE_CODE_USER_AGENT)
|
||||
self._codex_backend = bool(
|
||||
self.api_base and "chatgpt.com/backend-api/codex" in self.api_base
|
||||
)
|
||||
self._antigravity = bool(self.api_base and "localhost:8069" in self.api_base)
|
||||
|
||||
# Note: The Codex ChatGPT backend is a Responses API endpoint at
|
||||
# chatgpt.com/backend-api/codex/responses. LiteLLM's model registry
|
||||
# correctly marks codex models with mode="responses", so we do NOT
|
||||
@@ -578,10 +804,20 @@ class LiteLLMProvider(LLMProvider):
|
||||
def _completion_with_rate_limit_retry(
|
||||
self, max_retries: int | None = None, **kwargs: Any
|
||||
) -> Any:
|
||||
"""Call litellm.completion with retry on 429 rate limit errors and empty responses."""
|
||||
"""Call litellm.completion with retry on 429 rate limit errors and empty responses.
|
||||
|
||||
When a :class:`KeyPool` is configured, rate-limited keys are rotated
|
||||
automatically so the next attempt uses a different key -- no sleep
|
||||
needed between attempts.
|
||||
"""
|
||||
model = kwargs.get("model", self.model)
|
||||
retries = max_retries if max_retries is not None else RATE_LIMIT_MAX_RETRIES
|
||||
for attempt in range(retries + 1):
|
||||
# Rotate key from pool when available.
|
||||
current_key: str | None = None
|
||||
if self._key_pool:
|
||||
current_key = self._key_pool.get_key()
|
||||
kwargs["api_key"] = current_key
|
||||
try:
|
||||
response = litellm.completion(**kwargs) # type: ignore[union-attr]
|
||||
|
||||
@@ -656,8 +892,22 @@ class LiteLLMProvider(LLMProvider):
|
||||
time.sleep(wait)
|
||||
continue
|
||||
|
||||
if self._key_pool and current_key:
|
||||
self._key_pool.mark_success(current_key)
|
||||
return response
|
||||
except RateLimitError as e:
|
||||
# Key pool: mark the offending key and rotate immediately.
|
||||
if self._key_pool and current_key:
|
||||
self._key_pool.mark_rate_limited(current_key, retry_after=60.0)
|
||||
# When we have other healthy keys, skip the sleep -- the
|
||||
# next iteration will pick a different key automatically.
|
||||
if attempt < retries:
|
||||
logger.info(
|
||||
"[retry] Key pool rotating away from ...%s on 429",
|
||||
current_key[-6:],
|
||||
)
|
||||
continue
|
||||
|
||||
# Dump full request to file for debugging
|
||||
messages = kwargs.get("messages", [])
|
||||
token_count, token_method = _estimate_tokens(model, messages)
|
||||
@@ -670,7 +920,7 @@ class LiteLLMProvider(LLMProvider):
|
||||
if attempt == retries:
|
||||
logger.error(
|
||||
f"[retry] GAVE UP on {model} after {retries + 1} "
|
||||
f"attempts — rate limit error: {e!s}. "
|
||||
f"attempts -- rate limit error: {e!s}. "
|
||||
f"~{token_count} tokens ({token_method}). "
|
||||
f"Full request dumped to: {dump_path}"
|
||||
)
|
||||
@@ -789,10 +1039,16 @@ class LiteLLMProvider(LLMProvider):
|
||||
"""Async version of _completion_with_rate_limit_retry.
|
||||
|
||||
Uses litellm.acompletion and asyncio.sleep instead of blocking calls.
|
||||
When a :class:`KeyPool` is configured, rate-limited keys are rotated.
|
||||
"""
|
||||
model = kwargs.get("model", self.model)
|
||||
retries = max_retries if max_retries is not None else RATE_LIMIT_MAX_RETRIES
|
||||
for attempt in range(retries + 1):
|
||||
# Rotate key from pool when available.
|
||||
current_key: str | None = None
|
||||
if self._key_pool:
|
||||
current_key = self._key_pool.get_key()
|
||||
kwargs["api_key"] = current_key
|
||||
try:
|
||||
response = await litellm.acompletion(**kwargs) # type: ignore[union-attr]
|
||||
|
||||
@@ -861,8 +1117,20 @@ class LiteLLMProvider(LLMProvider):
|
||||
await asyncio.sleep(wait)
|
||||
continue
|
||||
|
||||
if self._key_pool and current_key:
|
||||
self._key_pool.mark_success(current_key)
|
||||
return response
|
||||
except RateLimitError as e:
|
||||
# Key pool: mark the offending key and rotate immediately.
|
||||
if self._key_pool and current_key:
|
||||
self._key_pool.mark_rate_limited(current_key, retry_after=60.0)
|
||||
if attempt < retries:
|
||||
logger.info(
|
||||
"[async-retry] Key pool rotating away from ...%s on 429",
|
||||
current_key[-6:],
|
||||
)
|
||||
continue
|
||||
|
||||
messages = kwargs.get("messages", [])
|
||||
token_count, token_method = _estimate_tokens(model, messages)
|
||||
dump_path = _dump_failed_request(
|
||||
@@ -874,7 +1142,7 @@ class LiteLLMProvider(LLMProvider):
|
||||
if attempt == retries:
|
||||
logger.error(
|
||||
f"[async-retry] GAVE UP on {model} after {retries + 1} "
|
||||
f"attempts — rate limit error: {e!s}. "
|
||||
f"attempts -- rate limit error: {e!s}. "
|
||||
f"~{token_count} tokens ({token_method}). "
|
||||
f"Full request dumped to: {dump_path}"
|
||||
)
|
||||
@@ -1001,6 +1269,12 @@ class LiteLLMProvider(LLMProvider):
|
||||
api_base = (self.api_base or "").lower()
|
||||
return "openrouter.ai/api/v1" in api_base
|
||||
|
||||
def _is_zai_openai_backend(self) -> bool:
|
||||
"""Return True when using Z-AI's OpenAI-compatible chat endpoint."""
|
||||
model = (self.model or "").lower()
|
||||
api_base = (self.api_base or "").lower()
|
||||
return "api.z.ai" in api_base or model.startswith("openai/glm-") or model == "glm-5"
|
||||
|
||||
def _should_use_openrouter_tool_compat(
|
||||
self,
|
||||
error: BaseException,
|
||||
@@ -1608,6 +1882,40 @@ class LiteLLMProvider(LLMProvider):
|
||||
full_messages.append(sys_msg)
|
||||
full_messages.extend(messages)
|
||||
|
||||
if logger.isEnabledFor(logging.DEBUG) and full_messages:
|
||||
import json as _json
|
||||
from pathlib import Path as _Path
|
||||
from datetime import datetime as _dt
|
||||
|
||||
_debug_dir = _Path.home() / ".hive" / "debug_logs"
|
||||
_debug_dir.mkdir(parents=True, exist_ok=True)
|
||||
_ts = _dt.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||
_dump_file = _debug_dir / f"llm_request_{_ts}.json"
|
||||
_summary = []
|
||||
for _mi, _m in enumerate(full_messages):
|
||||
_role = _m.get("role", "?")
|
||||
_c = _m.get("content")
|
||||
_tc = _m.get("tool_calls")
|
||||
_tcid = _m.get("tool_call_id")
|
||||
_summary.append(
|
||||
{
|
||||
"idx": _mi,
|
||||
"role": _role,
|
||||
"content_length": len(str(_c)) if _c else 0,
|
||||
"content_preview": str(_c)[:200] if _c else repr(_c),
|
||||
"has_tool_calls": bool(_tc),
|
||||
"tool_call_count": len(_tc) if _tc else 0,
|
||||
"tool_call_id": _tcid,
|
||||
}
|
||||
)
|
||||
try:
|
||||
_dump_file.write_text(
|
||||
_json.dumps(_summary, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
logger.debug("[LLM-MSG] %d messages dumped to %s", len(full_messages), _dump_file)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Codex Responses API requires an `instructions` field (system prompt).
|
||||
# Inject a minimal one when callers don't provide a system message.
|
||||
if self._codex_backend and not any(m["role"] == "system" for m in full_messages):
|
||||
@@ -1661,6 +1969,33 @@ class LiteLLMProvider(LLMProvider):
|
||||
kwargs.pop("max_tokens", None)
|
||||
kwargs.pop("stream_options", None)
|
||||
|
||||
request_summary = _summarize_request_for_log(kwargs)
|
||||
logger.debug(
|
||||
"[stream] prepared request: %s",
|
||||
json.dumps(request_summary, default=str),
|
||||
)
|
||||
if request_summary["system_only"]:
|
||||
logger.warning(
|
||||
"[stream] %s request has no non-system chat messages "
|
||||
"(api_base=%s tools=%d system_chars=%d). "
|
||||
"Some chat-completions backends reject system-only payloads.",
|
||||
self.model,
|
||||
self.api_base,
|
||||
request_summary["tool_count"],
|
||||
sum(
|
||||
message.get("text_chars", 0)
|
||||
for message in request_summary["messages"]
|
||||
if message.get("role") == "system"
|
||||
),
|
||||
)
|
||||
if self._is_zai_openai_backend():
|
||||
logger.warning(
|
||||
"[stream] %s appears to be using Z-AI/GLM's OpenAI-compatible backend. "
|
||||
"This backend has rejected system-only payloads with "
|
||||
"'The messages parameter is illegal.' in prior requests.",
|
||||
self.model,
|
||||
)
|
||||
|
||||
for attempt in range(RATE_LIMIT_MAX_RETRIES + 1):
|
||||
# Post-stream events (ToolCall, TextEnd, Finish) are buffered
|
||||
# because they depend on the full stream. TextDeltaEvents are
|
||||
@@ -1751,6 +2086,10 @@ class LiteLLMProvider(LLMProvider):
|
||||
|
||||
# --- Finish ---
|
||||
if choice.finish_reason:
|
||||
# Kimi's 'pause_turn' means the model emitted tool
|
||||
# calls and expects results — equivalent to 'tool_calls'.
|
||||
if choice.finish_reason == "pause_turn":
|
||||
choice.finish_reason = "tool_calls" if tool_calls_acc else "stop"
|
||||
stream_finish_reason = choice.finish_reason
|
||||
for _idx, tc_data in sorted(tool_calls_acc.items()):
|
||||
parsed_args = self._parse_tool_call_arguments(
|
||||
@@ -1918,6 +2257,39 @@ class LiteLLMProvider(LLMProvider):
|
||||
f"(last_role={last_role}). Returning empty result."
|
||||
)
|
||||
|
||||
# Gemini sometimes outputs tool calls as text in
|
||||
# <tool_code>{"name": {...args}}</tool_code> blocks
|
||||
# instead of using the function-calling API. Extract
|
||||
# these as real ToolCallEvents and strip them from the
|
||||
# text so the rest of the system treats them normally.
|
||||
if accumulated_text and "<tool_code>" in accumulated_text:
|
||||
extracted, cleaned = _extract_text_tool_calls(accumulated_text)
|
||||
if extracted:
|
||||
tool_names = [tc.tool_name for tc in extracted]
|
||||
logger.info(
|
||||
"[stream] Model emitted %d tool call(s) as <tool_code> text "
|
||||
"instead of structured function calls; converting to "
|
||||
"synthetic ToolCallEvents: %s",
|
||||
len(extracted),
|
||||
tool_names,
|
||||
)
|
||||
accumulated_text = cleaned
|
||||
# Emit a corrected TextDeltaEvent so the caller's
|
||||
# accumulated_text is overwritten with the cleaned text.
|
||||
yield TextDeltaEvent(content="", snapshot=cleaned)
|
||||
# Insert synthetic ToolCallEvents before FinishEvent.
|
||||
finish_idx = next(
|
||||
(i for i, ev in enumerate(tail_events) if isinstance(ev, FinishEvent)),
|
||||
len(tail_events),
|
||||
)
|
||||
for tc_ev in reversed(extracted):
|
||||
tail_events.insert(finish_idx, tc_ev)
|
||||
# Update TextEndEvent if present.
|
||||
for _i, _ev in enumerate(tail_events):
|
||||
if isinstance(_ev, TextEndEvent):
|
||||
tail_events[_i] = TextEndEvent(full_text=cleaned)
|
||||
break
|
||||
|
||||
# Success (or empty after exhausted retries) — flush events.
|
||||
for event in tail_events:
|
||||
yield event
|
||||
@@ -1944,8 +2316,15 @@ class LiteLLMProvider(LLMProvider):
|
||||
# tail_events before the error, the stream was successful —
|
||||
# yield what we have instead of discarding it.
|
||||
if (accumulated_text or tool_calls_acc) and tail_events:
|
||||
# LiteLLM may wrap the original ValidationError in an
|
||||
# APIError with a different message. Check the full
|
||||
# exception chain (str(e) + str(__cause__)).
|
||||
_err_chain = f"{e} {e.__cause__}" if e.__cause__ else str(e)
|
||||
_is_finish_reason_err = (
|
||||
"finish_reason" in str(e) and "validation error" in str(e).lower()
|
||||
"finish_reason" in _err_chain and "validation error" in _err_chain.lower()
|
||||
) or (
|
||||
# Fallback: the APIError wrapper message for chunk-building failures
|
||||
"building chunks" in str(e).lower() and (accumulated_text or tool_calls_acc)
|
||||
)
|
||||
if _is_finish_reason_err:
|
||||
logger.warning(
|
||||
@@ -1970,16 +2349,30 @@ class LiteLLMProvider(LLMProvider):
|
||||
):
|
||||
yield event
|
||||
return
|
||||
if _is_stream_transient_error(e) and attempt < RATE_LIMIT_MAX_RETRIES:
|
||||
if _is_stream_transient_error(e) and attempt < STREAM_TRANSIENT_MAX_RETRIES:
|
||||
wait = _compute_retry_delay(attempt, exception=e)
|
||||
logger.warning(
|
||||
f"[stream-retry] {self.model} transient error "
|
||||
f"({type(e).__name__}): {e!s}. "
|
||||
f"Retrying in {wait:.1f}s "
|
||||
f"(attempt {attempt + 1}/{RATE_LIMIT_MAX_RETRIES})"
|
||||
f"(attempt {attempt + 1}/{STREAM_TRANSIENT_MAX_RETRIES})"
|
||||
)
|
||||
await asyncio.sleep(wait)
|
||||
continue
|
||||
dump_path = _dump_failed_request(
|
||||
model=self.model,
|
||||
kwargs=kwargs,
|
||||
error_type=f"stream_exception_{type(e).__name__.lower()}",
|
||||
attempt=attempt,
|
||||
)
|
||||
logger.error(
|
||||
"[stream] %s request failed with %s: %s | request=%s | dump=%s",
|
||||
self.model,
|
||||
type(e).__name__,
|
||||
e,
|
||||
json.dumps(_summarize_request_for_log(kwargs), default=str),
|
||||
dump_path,
|
||||
)
|
||||
recoverable = _is_stream_transient_error(e)
|
||||
yield StreamErrorEvent(error=str(e), recoverable=recoverable)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"default_model": "claude-haiku-4-5-20251001",
|
||||
"models": [
|
||||
{
|
||||
"id": "claude-haiku-4-5-20251001",
|
||||
"label": "Haiku 4.5 - Fast + cheap",
|
||||
"recommended": false,
|
||||
"max_tokens": 64000,
|
||||
"max_context_tokens": 136000
|
||||
},
|
||||
{
|
||||
"id": "claude-sonnet-4-5-20250929",
|
||||
"label": "Sonnet 4.5 - Best balance",
|
||||
"recommended": false,
|
||||
"max_tokens": 64000,
|
||||
"max_context_tokens": 136000
|
||||
},
|
||||
{
|
||||
"id": "claude-opus-4-6",
|
||||
"label": "Opus 4.6 - Most capable",
|
||||
"recommended": true,
|
||||
"max_tokens": 128000,
|
||||
"max_context_tokens": 872000
|
||||
}
|
||||
]
|
||||
},
|
||||
"openai": {
|
||||
"default_model": "gpt-5.4",
|
||||
"models": [
|
||||
{
|
||||
"id": "gpt-5.4",
|
||||
"label": "GPT-5.4 - Best intelligence",
|
||||
"recommended": true,
|
||||
"max_tokens": 128000,
|
||||
"max_context_tokens": 960000
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.4-mini",
|
||||
"label": "GPT-5.4 Mini - Faster + cheaper",
|
||||
"recommended": false,
|
||||
"max_tokens": 128000,
|
||||
"max_context_tokens": 400000
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.4-nano",
|
||||
"label": "GPT-5.4 Nano - Cheapest high-volume",
|
||||
"recommended": false,
|
||||
"max_tokens": 128000,
|
||||
"max_context_tokens": 400000
|
||||
}
|
||||
]
|
||||
},
|
||||
"gemini": {
|
||||
"default_model": "gemini-3-flash-preview",
|
||||
"models": [
|
||||
{
|
||||
"id": "gemini-3-flash-preview",
|
||||
"label": "Gemini 3 Flash - Fast",
|
||||
"recommended": false,
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 900000
|
||||
},
|
||||
{
|
||||
"id": "gemini-3.1-pro-preview",
|
||||
"label": "Gemini 3.1 Pro - Best quality",
|
||||
"recommended": true,
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 900000
|
||||
}
|
||||
]
|
||||
},
|
||||
"groq": {
|
||||
"default_model": "openai/gpt-oss-120b",
|
||||
"models": [
|
||||
{
|
||||
"id": "openai/gpt-oss-120b",
|
||||
"label": "GPT-OSS 120B - Best reasoning",
|
||||
"recommended": true,
|
||||
"max_tokens": 65536,
|
||||
"max_context_tokens": 131072
|
||||
},
|
||||
{
|
||||
"id": "openai/gpt-oss-20b",
|
||||
"label": "GPT-OSS 20B - Fast + cheaper",
|
||||
"recommended": false,
|
||||
"max_tokens": 65536,
|
||||
"max_context_tokens": 131072
|
||||
},
|
||||
{
|
||||
"id": "llama-3.3-70b-versatile",
|
||||
"label": "Llama 3.3 70B - General purpose",
|
||||
"recommended": false,
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 131072
|
||||
},
|
||||
{
|
||||
"id": "llama-3.1-8b-instant",
|
||||
"label": "Llama 3.1 8B - Fastest",
|
||||
"recommended": false,
|
||||
"max_tokens": 131072,
|
||||
"max_context_tokens": 131072
|
||||
}
|
||||
]
|
||||
},
|
||||
"cerebras": {
|
||||
"default_model": "gpt-oss-120b",
|
||||
"models": [
|
||||
{
|
||||
"id": "gpt-oss-120b",
|
||||
"label": "GPT-OSS 120B - Best production reasoning",
|
||||
"recommended": true,
|
||||
"max_tokens": 40960,
|
||||
"max_context_tokens": 131072
|
||||
},
|
||||
{
|
||||
"id": "llama3.1-8b",
|
||||
"label": "Llama 3.1 8B - Fastest production",
|
||||
"recommended": false,
|
||||
"max_tokens": 8192,
|
||||
"max_context_tokens": 32768
|
||||
},
|
||||
{
|
||||
"id": "zai-glm-4.7",
|
||||
"label": "Z.ai GLM 4.7 - Strong coding preview",
|
||||
"recommended": true,
|
||||
"max_tokens": 40960,
|
||||
"max_context_tokens": 131072
|
||||
},
|
||||
{
|
||||
"id": "qwen-3-235b-a22b-instruct-2507",
|
||||
"label": "Qwen 3 235B Instruct - Frontier preview",
|
||||
"recommended": false,
|
||||
"max_tokens": 40960,
|
||||
"max_context_tokens": 131072
|
||||
}
|
||||
]
|
||||
},
|
||||
"minimax": {
|
||||
"default_model": "MiniMax-M2.7",
|
||||
"models": [
|
||||
{
|
||||
"id": "MiniMax-M2.7",
|
||||
"label": "MiniMax M2.7 - Best coding quality",
|
||||
"recommended": true,
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 204800
|
||||
},
|
||||
{
|
||||
"id": "MiniMax-M2.5",
|
||||
"label": "MiniMax M2.5 - Strong value",
|
||||
"recommended": false,
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 204800
|
||||
}
|
||||
]
|
||||
},
|
||||
"mistral": {
|
||||
"default_model": "mistral-large-2512",
|
||||
"models": [
|
||||
{
|
||||
"id": "mistral-large-2512",
|
||||
"label": "Mistral Large 3 - Best quality",
|
||||
"recommended": true,
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 256000
|
||||
},
|
||||
{
|
||||
"id": "mistral-medium-2508",
|
||||
"label": "Mistral Medium 3.1 - Balanced",
|
||||
"recommended": false,
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 128000
|
||||
},
|
||||
{
|
||||
"id": "mistral-small-2603",
|
||||
"label": "Mistral Small 4 - Fast + capable",
|
||||
"recommended": false,
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 256000
|
||||
},
|
||||
{
|
||||
"id": "codestral-2508",
|
||||
"label": "Codestral - Coding specialist",
|
||||
"recommended": false,
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 128000
|
||||
}
|
||||
]
|
||||
},
|
||||
"together": {
|
||||
"default_model": "deepseek-ai/DeepSeek-V3.1",
|
||||
"models": [
|
||||
{
|
||||
"id": "deepseek-ai/DeepSeek-V3.1",
|
||||
"label": "DeepSeek V3.1 - Best general coding",
|
||||
"recommended": true,
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 128000
|
||||
},
|
||||
{
|
||||
"id": "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8",
|
||||
"label": "Qwen3 Coder 480B - Advanced coding",
|
||||
"recommended": false,
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 262144
|
||||
},
|
||||
{
|
||||
"id": "openai/gpt-oss-120b",
|
||||
"label": "GPT-OSS 120B - Strong reasoning",
|
||||
"recommended": false,
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 128000
|
||||
},
|
||||
{
|
||||
"id": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
||||
"label": "Llama 3.3 70B Turbo - Fast baseline",
|
||||
"recommended": false,
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 131072
|
||||
}
|
||||
]
|
||||
},
|
||||
"deepseek": {
|
||||
"default_model": "deepseek-chat",
|
||||
"models": [
|
||||
{
|
||||
"id": "deepseek-chat",
|
||||
"label": "DeepSeek Chat - Fast default",
|
||||
"recommended": true,
|
||||
"max_tokens": 8192,
|
||||
"max_context_tokens": 128000
|
||||
},
|
||||
{
|
||||
"id": "deepseek-reasoner",
|
||||
"label": "DeepSeek Reasoner - Deep thinking",
|
||||
"recommended": false,
|
||||
"max_tokens": 64000,
|
||||
"max_context_tokens": 128000
|
||||
}
|
||||
]
|
||||
},
|
||||
"kimi": {
|
||||
"default_model": "kimi-k2.5",
|
||||
"models": [
|
||||
{
|
||||
"id": "kimi-k2.5",
|
||||
"label": "Kimi K2.5 - Best coding",
|
||||
"recommended": true,
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 200000
|
||||
}
|
||||
]
|
||||
},
|
||||
"hive": {
|
||||
"default_model": "queen",
|
||||
"models": [
|
||||
{
|
||||
"id": "queen",
|
||||
"label": "Queen - Hive native",
|
||||
"recommended": true,
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 180000
|
||||
},
|
||||
{
|
||||
"id": "kimi-2.5",
|
||||
"label": "Kimi 2.5 - Via Hive",
|
||||
"recommended": false,
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 240000
|
||||
},
|
||||
{
|
||||
"id": "GLM-5",
|
||||
"label": "GLM-5 - Via Hive",
|
||||
"recommended": false,
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 180000
|
||||
}
|
||||
]
|
||||
},
|
||||
"openrouter": {
|
||||
"default_model": "openai/gpt-5.4",
|
||||
"models": [
|
||||
{
|
||||
"id": "openai/gpt-5.4",
|
||||
"label": "GPT-5.4 - Best overall",
|
||||
"recommended": true,
|
||||
"max_tokens": 128000,
|
||||
"max_context_tokens": 922000
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-sonnet-4.6",
|
||||
"label": "Claude Sonnet 4.6 - Best coding balance",
|
||||
"recommended": false,
|
||||
"max_tokens": 64000,
|
||||
"max_context_tokens": 936000
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-opus-4.6",
|
||||
"label": "Claude Opus 4.6 - Most capable",
|
||||
"recommended": false,
|
||||
"max_tokens": 128000,
|
||||
"max_context_tokens": 872000
|
||||
},
|
||||
{
|
||||
"id": "google/gemini-3.1-pro-preview",
|
||||
"label": "Gemini 3.1 Pro Preview - Long-context reasoning",
|
||||
"recommended": false,
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 1048576
|
||||
},
|
||||
{
|
||||
"id": "deepseek/deepseek-v3.2",
|
||||
"label": "DeepSeek V3.2 - Best value",
|
||||
"recommended": false,
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 163840
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"presets": {
|
||||
"claude_code": {
|
||||
"provider": "anthropic",
|
||||
"model": "claude-opus-4-6",
|
||||
"max_tokens": 128000,
|
||||
"max_context_tokens": 872000
|
||||
},
|
||||
"zai_code": {
|
||||
"provider": "openai",
|
||||
"api_key_env_var": "ZAI_API_KEY",
|
||||
"model": "glm-5",
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 180000,
|
||||
"api_base": "https://api.z.ai/api/coding/paas/v4"
|
||||
},
|
||||
"codex": {
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.3-codex",
|
||||
"max_tokens": 16384,
|
||||
"max_context_tokens": 120000,
|
||||
"api_base": "https://chatgpt.com/backend-api/codex"
|
||||
},
|
||||
"minimax_code": {
|
||||
"provider": "minimax",
|
||||
"api_key_env_var": "MINIMAX_API_KEY",
|
||||
"model": "MiniMax-M2.7",
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 204800,
|
||||
"api_base": "https://api.minimax.io/v1"
|
||||
},
|
||||
"kimi_code": {
|
||||
"provider": "kimi",
|
||||
"api_key_env_var": "KIMI_API_KEY",
|
||||
"model": "kimi-k2.5",
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 240000,
|
||||
"api_base": "https://api.kimi.com/coding"
|
||||
},
|
||||
"hive_llm": {
|
||||
"provider": "hive",
|
||||
"api_key_env_var": "HIVE_API_KEY",
|
||||
"model": "queen",
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 180000,
|
||||
"api_base": "https://api.adenhq.com",
|
||||
"model_choices": [
|
||||
{
|
||||
"id": "queen",
|
||||
"label": "queen",
|
||||
"recommended": true
|
||||
},
|
||||
{
|
||||
"id": "kimi-2.5",
|
||||
"label": "kimi-2.5",
|
||||
"recommended": false
|
||||
},
|
||||
{
|
||||
"id": "GLM-5",
|
||||
"label": "GLM-5",
|
||||
"recommended": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"antigravity": {
|
||||
"provider": "openai",
|
||||
"model": "gemini-3-flash",
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 1000000
|
||||
},
|
||||
"ollama_local": {
|
||||
"provider": "ollama",
|
||||
"max_tokens": 8192,
|
||||
"max_context_tokens": 16384,
|
||||
"api_base": "http://localhost:11434"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Shared curated model metadata loaded from ``model_catalog.json``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
MODEL_CATALOG_PATH = Path(__file__).with_name("model_catalog.json")
|
||||
|
||||
|
||||
class ModelCatalogError(RuntimeError):
|
||||
"""Raised when the curated model catalogue is missing or malformed."""
|
||||
|
||||
|
||||
def _require_mapping(value: Any, path: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise ModelCatalogError(f"{path} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _require_list(value: Any, path: str) -> list[Any]:
|
||||
if not isinstance(value, list):
|
||||
raise ModelCatalogError(f"{path} must be an array")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_model_catalog(data: dict[str, Any]) -> dict[str, Any]:
|
||||
providers = _require_mapping(data.get("providers"), "providers")
|
||||
|
||||
for provider_id, provider_info in providers.items():
|
||||
provider_path = f"providers.{provider_id}"
|
||||
provider_map = _require_mapping(provider_info, provider_path)
|
||||
default_model = provider_map.get("default_model")
|
||||
if not isinstance(default_model, str) or not default_model.strip():
|
||||
raise ModelCatalogError(f"{provider_path}.default_model must be a non-empty string")
|
||||
|
||||
models = _require_list(provider_map.get("models"), f"{provider_path}.models")
|
||||
if not models:
|
||||
raise ModelCatalogError(f"{provider_path}.models must not be empty")
|
||||
|
||||
seen_model_ids: set[str] = set()
|
||||
default_found = False
|
||||
for idx, model in enumerate(models):
|
||||
model_path = f"{provider_path}.models[{idx}]"
|
||||
model_map = _require_mapping(model, model_path)
|
||||
model_id = model_map.get("id")
|
||||
if not isinstance(model_id, str) or not model_id.strip():
|
||||
raise ModelCatalogError(f"{model_path}.id must be a non-empty string")
|
||||
if model_id in seen_model_ids:
|
||||
raise ModelCatalogError(
|
||||
f"Duplicate model id {model_id!r} in {provider_path}.models"
|
||||
)
|
||||
seen_model_ids.add(model_id)
|
||||
|
||||
if model_id == default_model:
|
||||
default_found = True
|
||||
|
||||
label = model_map.get("label")
|
||||
if not isinstance(label, str) or not label.strip():
|
||||
raise ModelCatalogError(f"{model_path}.label must be a non-empty string")
|
||||
|
||||
recommended = model_map.get("recommended")
|
||||
if not isinstance(recommended, bool):
|
||||
raise ModelCatalogError(f"{model_path}.recommended must be a boolean")
|
||||
|
||||
for key in ("max_tokens", "max_context_tokens"):
|
||||
value = model_map.get(key)
|
||||
if not isinstance(value, int) or value <= 0:
|
||||
raise ModelCatalogError(f"{model_path}.{key} must be a positive integer")
|
||||
|
||||
if not default_found:
|
||||
raise ModelCatalogError(
|
||||
f"{provider_path}.default_model={default_model!r} is not present in {provider_path}.models"
|
||||
)
|
||||
|
||||
presets = _require_mapping(data.get("presets"), "presets")
|
||||
for preset_id, preset_info in presets.items():
|
||||
preset_path = f"presets.{preset_id}"
|
||||
preset_map = _require_mapping(preset_info, preset_path)
|
||||
|
||||
provider = preset_map.get("provider")
|
||||
if not isinstance(provider, str) or not provider.strip():
|
||||
raise ModelCatalogError(f"{preset_path}.provider must be a non-empty string")
|
||||
|
||||
model = preset_map.get("model")
|
||||
if model is not None and (not isinstance(model, str) or not model.strip()):
|
||||
raise ModelCatalogError(f"{preset_path}.model must be a non-empty string when present")
|
||||
|
||||
api_base = preset_map.get("api_base")
|
||||
if api_base is not None and (not isinstance(api_base, str) or not api_base.strip()):
|
||||
raise ModelCatalogError(
|
||||
f"{preset_path}.api_base must be a non-empty string when present"
|
||||
)
|
||||
|
||||
api_key_env_var = preset_map.get("api_key_env_var")
|
||||
if api_key_env_var is not None and (
|
||||
not isinstance(api_key_env_var, str) or not api_key_env_var.strip()
|
||||
):
|
||||
raise ModelCatalogError(
|
||||
f"{preset_path}.api_key_env_var must be a non-empty string when present"
|
||||
)
|
||||
|
||||
for key in ("max_tokens", "max_context_tokens"):
|
||||
value = preset_map.get(key)
|
||||
if not isinstance(value, int) or value <= 0:
|
||||
raise ModelCatalogError(f"{preset_path}.{key} must be a positive integer")
|
||||
|
||||
model_choices = preset_map.get("model_choices")
|
||||
if model_choices is not None:
|
||||
for idx, choice in enumerate(
|
||||
_require_list(model_choices, f"{preset_path}.model_choices")
|
||||
):
|
||||
choice_path = f"{preset_path}.model_choices[{idx}]"
|
||||
choice_map = _require_mapping(choice, choice_path)
|
||||
choice_id = choice_map.get("id")
|
||||
if not isinstance(choice_id, str) or not choice_id.strip():
|
||||
raise ModelCatalogError(f"{choice_path}.id must be a non-empty string")
|
||||
label = choice_map.get("label")
|
||||
if not isinstance(label, str) or not label.strip():
|
||||
raise ModelCatalogError(f"{choice_path}.label must be a non-empty string")
|
||||
recommended = choice_map.get("recommended")
|
||||
if not isinstance(recommended, bool):
|
||||
raise ModelCatalogError(f"{choice_path}.recommended must be a boolean")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_model_catalog() -> dict[str, Any]:
|
||||
"""Load and validate the curated model catalogue."""
|
||||
try:
|
||||
raw = json.loads(MODEL_CATALOG_PATH.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError as exc:
|
||||
raise ModelCatalogError(f"Model catalogue not found: {MODEL_CATALOG_PATH}") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ModelCatalogError(f"Model catalogue JSON is invalid: {exc}") from exc
|
||||
|
||||
return _validate_model_catalog(_require_mapping(raw, "root"))
|
||||
|
||||
|
||||
def get_models_catalogue() -> dict[str, list[dict[str, Any]]]:
|
||||
"""Return provider -> model list."""
|
||||
providers = load_model_catalog()["providers"]
|
||||
return {
|
||||
provider_id: copy.deepcopy(provider_info["models"])
|
||||
for provider_id, provider_info in providers.items()
|
||||
}
|
||||
|
||||
|
||||
def get_default_models() -> dict[str, str]:
|
||||
"""Return provider -> default model id."""
|
||||
providers = load_model_catalog()["providers"]
|
||||
return {
|
||||
provider_id: str(provider_info["default_model"])
|
||||
for provider_id, provider_info in providers.items()
|
||||
}
|
||||
|
||||
|
||||
def get_provider_models(provider: str) -> list[dict[str, Any]]:
|
||||
"""Return the curated models for one provider."""
|
||||
provider_info = load_model_catalog()["providers"].get(provider)
|
||||
if not provider_info:
|
||||
return []
|
||||
return copy.deepcopy(provider_info["models"])
|
||||
|
||||
|
||||
def get_default_model(provider: str) -> str | None:
|
||||
"""Return the curated default model id for one provider."""
|
||||
provider_info = load_model_catalog()["providers"].get(provider)
|
||||
if not provider_info:
|
||||
return None
|
||||
return str(provider_info["default_model"])
|
||||
|
||||
|
||||
def find_model(provider: str, model_id: str) -> dict[str, Any] | None:
|
||||
"""Return one model entry for a provider, if present."""
|
||||
for model in load_model_catalog()["providers"].get(provider, {}).get("models", []):
|
||||
if model["id"] == model_id:
|
||||
return copy.deepcopy(model)
|
||||
return None
|
||||
|
||||
|
||||
def find_model_any_provider(model_id: str) -> tuple[str, dict[str, Any]] | None:
|
||||
"""Return the first curated provider/model entry matching a model id."""
|
||||
for provider_id, provider_info in load_model_catalog()["providers"].items():
|
||||
for model in provider_info["models"]:
|
||||
if model["id"] == model_id:
|
||||
return provider_id, copy.deepcopy(model)
|
||||
return None
|
||||
|
||||
|
||||
def get_model_limits(provider: str, model_id: str) -> tuple[int, int] | None:
|
||||
"""Return ``(max_tokens, max_context_tokens)`` for one provider/model pair."""
|
||||
model = find_model(provider, model_id)
|
||||
if not model:
|
||||
return None
|
||||
return int(model["max_tokens"]), int(model["max_context_tokens"])
|
||||
|
||||
|
||||
def get_preset(preset_id: str) -> dict[str, Any] | None:
|
||||
"""Return one preset entry."""
|
||||
preset = load_model_catalog()["presets"].get(preset_id)
|
||||
if not preset:
|
||||
return None
|
||||
return copy.deepcopy(preset)
|
||||
|
||||
|
||||
def get_presets() -> dict[str, dict[str, Any]]:
|
||||
"""Return all preset entries."""
|
||||
return copy.deepcopy(load_model_catalog()["presets"])
|
||||
@@ -27,6 +27,12 @@ class Tool:
|
||||
name: str
|
||||
description: str
|
||||
parameters: dict[str, Any] = field(default_factory=dict)
|
||||
# If True, this tool performs no filesystem/process/network writes and is
|
||||
# safe to run concurrently with other safe-flagged tools inside the same
|
||||
# assistant turn. Unsafe tools (writes, shell, browser actions) are always
|
||||
# serialized after the safe batch. Default False - the conservative choice
|
||||
# when a tool's behavior isn't explicitly vetted.
|
||||
concurrency_safe: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Loader layer -- agent loading from disk (JSON config, MCP, credentials)."""
|
||||
|
||||
from framework.loader.agent_loader import AgentLoader # noqa: F401
|
||||
from framework.loader.tool_registry import ToolRegistry # noqa: F401
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,814 @@
|
||||
"""CLI commands for Hive — queens, colonies, sessions.
|
||||
|
||||
The new architecture has no exported agents, no graph execution.
|
||||
Everything runs through the AgentLoop driven by SessionManager.
|
||||
|
||||
Commands:
|
||||
serve Start the HTTP API server (the runtime hub)
|
||||
open Start the server and open the dashboard
|
||||
queen Manage queen profiles (list, show, sessions)
|
||||
colony Manage colonies (list, info, delete)
|
||||
session Manage live + cold sessions (list, stop)
|
||||
chat Send a message to a live queen via the HTTP API
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib import error as urlerror, parse as urlparse, request as urlrequest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def register_commands(subparsers: argparse._SubParsersAction) -> None:
|
||||
"""Register all runner commands with the main CLI parser."""
|
||||
_register_serve(subparsers)
|
||||
_register_open(subparsers)
|
||||
_register_queen(subparsers)
|
||||
_register_colony(subparsers)
|
||||
_register_session(subparsers)
|
||||
_register_chat(subparsers)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# serve / open
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _register_serve(subparsers: argparse._SubParsersAction) -> None:
|
||||
p = subparsers.add_parser(
|
||||
"serve",
|
||||
help="Start the HTTP API server",
|
||||
description="Start the aiohttp server exposing REST + SSE for queens, colonies, and sessions.",
|
||||
)
|
||||
p.add_argument("--host", type=str, default="127.0.0.1", help="Host to bind (default: 127.0.0.1)")
|
||||
p.add_argument("--port", "-p", type=int, default=8787, help="Port to listen on (default: 8787)")
|
||||
p.add_argument(
|
||||
"--colony",
|
||||
"-c",
|
||||
type=str,
|
||||
action="append",
|
||||
default=[],
|
||||
help="Colony path or name to preload (repeatable)",
|
||||
)
|
||||
p.add_argument("--model", "-m", type=str, default=None, help="LLM model for preloaded colonies")
|
||||
p.add_argument("--open", action="store_true", help="Open dashboard in browser after start")
|
||||
p.add_argument("--verbose", "-v", action="store_true", help="Enable INFO log level")
|
||||
p.add_argument("--debug", action="store_true", help="Enable DEBUG log level")
|
||||
p.set_defaults(func=cmd_serve)
|
||||
|
||||
|
||||
def _register_open(subparsers: argparse._SubParsersAction) -> None:
|
||||
p = subparsers.add_parser(
|
||||
"open",
|
||||
help="Start the server and open the dashboard",
|
||||
description="Shortcut for 'hive serve --open'.",
|
||||
)
|
||||
p.add_argument("--host", type=str, default="127.0.0.1")
|
||||
p.add_argument("--port", "-p", type=int, default=8787)
|
||||
p.add_argument("--colony", "-c", type=str, action="append", default=[])
|
||||
p.add_argument("--model", "-m", type=str, default=None)
|
||||
p.add_argument("--verbose", "-v", action="store_true")
|
||||
p.add_argument("--debug", action="store_true")
|
||||
p.set_defaults(func=cmd_open)
|
||||
|
||||
|
||||
def cmd_serve(args: argparse.Namespace) -> int:
|
||||
"""Start the HTTP API server (the runtime hub)."""
|
||||
import atexit
|
||||
import logging
|
||||
import signal
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
_build_frontend()
|
||||
|
||||
from framework.observability import configure_logging
|
||||
from framework.server.app import create_app
|
||||
|
||||
if getattr(args, "debug", False):
|
||||
configure_logging(level="DEBUG")
|
||||
else:
|
||||
configure_logging(level="INFO")
|
||||
|
||||
# Last-resort MCP cleanup. Runs on any process exit path, including
|
||||
# crashes — so hung MCP subprocesses don't outlive the server. The
|
||||
# graceful shutdown path below also disconnects clients; atexit is
|
||||
# belt-and-braces and no-ops if already cleaned.
|
||||
def _atexit_cleanup_mcp() -> None:
|
||||
try:
|
||||
from framework.loader.mcp_connection_manager import MCPConnectionManager
|
||||
|
||||
MCPConnectionManager.get_instance().cleanup_all()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logging.getLogger(__name__).debug("atexit MCP cleanup failed: %s", exc)
|
||||
|
||||
atexit.register(_atexit_cleanup_mcp)
|
||||
|
||||
model = getattr(args, "model", None)
|
||||
app = create_app(model=model)
|
||||
|
||||
async def run_server() -> None:
|
||||
manager = app["manager"]
|
||||
shutdown_event = asyncio.Event()
|
||||
signal_count = {"n": 0}
|
||||
|
||||
def _request_shutdown(signame: str) -> None:
|
||||
signal_count["n"] += 1
|
||||
if signal_count["n"] == 1:
|
||||
print(
|
||||
f"\nReceived {signame}, shutting down gracefully… "
|
||||
"(press Ctrl+C again to force quit)"
|
||||
)
|
||||
shutdown_event.set()
|
||||
else:
|
||||
# Second Ctrl+C (or SIGTERM) — the user is done waiting.
|
||||
# Skip the graceful teardown and exit immediately. os._exit
|
||||
# bypasses atexit handlers, so fire the MCP cleanup manually
|
||||
# first to avoid leaking subprocesses.
|
||||
print(f"\nReceived {signame} again — force quitting.")
|
||||
try:
|
||||
from framework.loader.mcp_connection_manager import (
|
||||
MCPConnectionManager,
|
||||
)
|
||||
|
||||
MCPConnectionManager.get_instance().cleanup_all()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
os._exit(130)
|
||||
|
||||
# Register SIGTERM (and explicit SIGINT) so container orchestrators
|
||||
# and plain Ctrl-C both route through the same graceful path —
|
||||
# manager.shutdown_all() flushes state and disconnects MCP clients.
|
||||
loop = asyncio.get_running_loop()
|
||||
for signame in ("SIGINT", "SIGTERM"):
|
||||
try:
|
||||
loop.add_signal_handler(
|
||||
getattr(signal, signame),
|
||||
_request_shutdown,
|
||||
signame,
|
||||
)
|
||||
except (NotImplementedError, AttributeError):
|
||||
# Windows / restricted environments — fall back to default
|
||||
# handlers (KeyboardInterrupt for SIGINT; SIGTERM kills).
|
||||
pass
|
||||
|
||||
# Preload colonies specified via --colony
|
||||
for colony_arg in getattr(args, "colony", []) or []:
|
||||
colony_path = _resolve_colony_path(colony_arg)
|
||||
if colony_path is None:
|
||||
print(f"Colony not found: {colony_arg}")
|
||||
continue
|
||||
try:
|
||||
session = await manager.create_session_with_worker_colony(
|
||||
str(colony_path), model=model
|
||||
)
|
||||
info = session.worker_info
|
||||
name = info.name if info else session.colony_id
|
||||
print(f"Loaded colony: {session.colony_id} ({name}) → session {session.id}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"Error loading colony {colony_arg}: {e}")
|
||||
|
||||
runner = web.AppRunner(app, access_log=None)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, args.host, args.port)
|
||||
await site.start()
|
||||
|
||||
dashboard_url = f"http://{args.host}:{args.port}"
|
||||
has_frontend = _frontend_dist_exists()
|
||||
|
||||
live_count = sum(1 for s in manager.list_sessions() if s.colony_runtime is not None)
|
||||
queen_only = sum(1 for s in manager.list_sessions() if s.colony_runtime is None)
|
||||
|
||||
print()
|
||||
print(f"Hive API server running on {dashboard_url}")
|
||||
if has_frontend:
|
||||
print(f"Dashboard: {dashboard_url}")
|
||||
print(f"Health: {dashboard_url}/api/health")
|
||||
print(f"Sessions: {live_count} colony, {queen_only} queen-only")
|
||||
print()
|
||||
print("Press Ctrl+C to stop")
|
||||
|
||||
if getattr(args, "open", False) and has_frontend:
|
||||
_open_browser(dashboard_url)
|
||||
|
||||
try:
|
||||
await shutdown_event.wait()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
await manager.shutdown_all()
|
||||
await runner.cleanup()
|
||||
|
||||
try:
|
||||
asyncio.run(run_server())
|
||||
except KeyboardInterrupt:
|
||||
print("\nServer stopped.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_open(args: argparse.Namespace) -> int:
|
||||
"""Start the HTTP server and open the dashboard in the browser."""
|
||||
_ping_hive_gateway_availability("hive-open")
|
||||
args.open = True
|
||||
return cmd_serve(args)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# queen
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _register_queen(subparsers: argparse._SubParsersAction) -> None:
|
||||
p = subparsers.add_parser(
|
||||
"queen",
|
||||
help="Manage queen profiles",
|
||||
description="List, inspect, and explore queen identities.",
|
||||
)
|
||||
sub = p.add_subparsers(dest="subcommand", required=True)
|
||||
|
||||
list_p = sub.add_parser("list", help="List all queen profiles")
|
||||
list_p.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
list_p.set_defaults(func=cmd_queen_list)
|
||||
|
||||
show_p = sub.add_parser("show", help="Show a queen profile")
|
||||
show_p.add_argument("queen_id", type=str, help="Queen identity (e.g. queen_technology)")
|
||||
show_p.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
show_p.set_defaults(func=cmd_queen_show)
|
||||
|
||||
sess_p = sub.add_parser("sessions", help="List sessions belonging to a queen")
|
||||
sess_p.add_argument("queen_id", type=str, help="Queen identity")
|
||||
sess_p.add_argument("--json", action="store_true")
|
||||
sess_p.set_defaults(func=cmd_queen_sessions)
|
||||
|
||||
|
||||
def cmd_queen_list(args: argparse.Namespace) -> int:
|
||||
from framework.agents.queen.queen_profiles import ensure_default_queens, list_queens
|
||||
|
||||
ensure_default_queens()
|
||||
queens = list_queens()
|
||||
if args.json:
|
||||
print(json.dumps(queens, indent=2))
|
||||
return 0
|
||||
|
||||
if not queens:
|
||||
print("No queen profiles found.")
|
||||
return 0
|
||||
|
||||
print(f"{'ID':<32} {'NAME':<24} TITLE")
|
||||
print("-" * 80)
|
||||
for q in queens:
|
||||
print(f"{q['id']:<32} {q['name']:<24} {q['title']}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_queen_show(args: argparse.Namespace) -> int:
|
||||
from framework.agents.queen.queen_profiles import load_queen_profile
|
||||
|
||||
try:
|
||||
profile = load_queen_profile(args.queen_id)
|
||||
except FileNotFoundError as e:
|
||||
print(f"Error: {e}")
|
||||
return 1
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(profile, indent=2))
|
||||
return 0
|
||||
|
||||
print(f"Queen ID: {args.queen_id}")
|
||||
print(f"Name: {profile.get('name', '')}")
|
||||
print(f"Title: {profile.get('title', '')}")
|
||||
desc = profile.get("description") or profile.get("core_traits") or ""
|
||||
if isinstance(desc, list):
|
||||
desc = ", ".join(desc)
|
||||
if desc:
|
||||
print(f"Traits: {desc}")
|
||||
skills = profile.get("skills") or []
|
||||
if skills:
|
||||
print(f"Skills: {', '.join(skills) if isinstance(skills, list) else skills}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_queen_sessions(args: argparse.Namespace) -> int:
|
||||
from framework.config import QUEENS_DIR
|
||||
|
||||
queen_dir = QUEENS_DIR / args.queen_id / "sessions"
|
||||
if not queen_dir.is_dir():
|
||||
print(f"No sessions for queen '{args.queen_id}'")
|
||||
return 0
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for session_dir in sorted(queen_dir.iterdir()):
|
||||
if not session_dir.is_dir():
|
||||
continue
|
||||
meta_path = session_dir / "meta.json"
|
||||
meta: dict = {}
|
||||
if meta_path.exists():
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
meta = {}
|
||||
rows.append({
|
||||
"session_id": session_dir.name,
|
||||
"phase": meta.get("phase", "?"),
|
||||
"agent_path": meta.get("agent_path", ""),
|
||||
"colony_fork": bool(meta.get("colony_fork")),
|
||||
})
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(rows, indent=2))
|
||||
return 0
|
||||
|
||||
if not rows:
|
||||
print(f"No sessions for queen '{args.queen_id}'")
|
||||
return 0
|
||||
|
||||
print(f"{'SESSION':<40} {'PHASE':<10} {'COLONY':<20} FLAGS")
|
||||
print("-" * 90)
|
||||
for r in rows:
|
||||
flags = "fork" if r["colony_fork"] else ""
|
||||
colony = Path(r["agent_path"]).name if r["agent_path"] else ""
|
||||
print(f"{r['session_id']:<40} {r['phase']:<10} {colony:<20} {flags}")
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# colony
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _register_colony(subparsers: argparse._SubParsersAction) -> None:
|
||||
p = subparsers.add_parser(
|
||||
"colony",
|
||||
help="Manage colonies",
|
||||
description="List, inspect, and delete colonies on disk.",
|
||||
)
|
||||
sub = p.add_subparsers(dest="subcommand", required=True)
|
||||
|
||||
list_p = sub.add_parser("list", help="List all colonies")
|
||||
list_p.add_argument("--json", action="store_true")
|
||||
list_p.set_defaults(func=cmd_colony_list)
|
||||
|
||||
info_p = sub.add_parser("info", help="Show colony details")
|
||||
info_p.add_argument("name", type=str, help="Colony name or path")
|
||||
info_p.add_argument("--json", action="store_true")
|
||||
info_p.set_defaults(func=cmd_colony_info)
|
||||
|
||||
del_p = sub.add_parser("delete", help="Delete a colony from disk")
|
||||
del_p.add_argument("name", type=str, help="Colony name")
|
||||
del_p.add_argument(
|
||||
"--purge-storage",
|
||||
action="store_true",
|
||||
help="Also delete worker storage at ~/.hive/agents/{name}/",
|
||||
)
|
||||
del_p.add_argument("--yes", "-y", action="store_true", help="Skip confirmation")
|
||||
del_p.set_defaults(func=cmd_colony_delete)
|
||||
|
||||
|
||||
def cmd_colony_list(args: argparse.Namespace) -> int:
|
||||
from framework.config import COLONIES_DIR
|
||||
|
||||
if not COLONIES_DIR.is_dir():
|
||||
if args.json:
|
||||
print("[]")
|
||||
else:
|
||||
print("No colonies found.")
|
||||
return 0
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for path in sorted(COLONIES_DIR.iterdir()):
|
||||
if not path.is_dir():
|
||||
continue
|
||||
meta_path = path / "metadata.json"
|
||||
meta: dict = {}
|
||||
if meta_path.exists():
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
meta = {}
|
||||
worker_count = sum(
|
||||
1
|
||||
for f in path.iterdir()
|
||||
if f.is_file() and f.suffix == ".json" and f.stem not in _RESERVED_JSON_STEMS
|
||||
)
|
||||
rows.append({
|
||||
"name": path.name,
|
||||
"queen_name": meta.get("queen_name", ""),
|
||||
"queen_session_id": meta.get("queen_session_id", ""),
|
||||
"workers": worker_count,
|
||||
"created_at": meta.get("created_at", ""),
|
||||
"path": str(path),
|
||||
})
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(rows, indent=2))
|
||||
return 0
|
||||
|
||||
if not rows:
|
||||
print("No colonies found.")
|
||||
return 0
|
||||
|
||||
print(f"{'NAME':<24} {'QUEEN':<28} {'WORKERS':<8} CREATED")
|
||||
print("-" * 90)
|
||||
for r in rows:
|
||||
print(
|
||||
f"{r['name']:<24} {r['queen_name']:<28} {r['workers']:<8} {r['created_at'][:19]}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_colony_info(args: argparse.Namespace) -> int:
|
||||
colony_path = _resolve_colony_path(args.name)
|
||||
if colony_path is None:
|
||||
print(f"Colony not found: {args.name}")
|
||||
return 1
|
||||
|
||||
meta_path = colony_path / "metadata.json"
|
||||
metadata: dict = {}
|
||||
if meta_path.exists():
|
||||
try:
|
||||
metadata = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
workers: dict[str, dict] = {}
|
||||
for f in sorted(colony_path.iterdir()):
|
||||
if not (f.is_file() and f.suffix == ".json"):
|
||||
continue
|
||||
if f.stem in _RESERVED_JSON_STEMS:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(f.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict):
|
||||
workers[f.stem] = {
|
||||
"name": data.get("name", f.stem),
|
||||
"description": data.get("description", ""),
|
||||
"tools": len(data.get("tools", [])),
|
||||
"goal": data.get("goal", {}).get("description", ""),
|
||||
"spawned_from": data.get("spawned_from", ""),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if args.json:
|
||||
print(json.dumps({"path": str(colony_path), "metadata": metadata, "workers": workers}, indent=2))
|
||||
return 0
|
||||
|
||||
print(f"Colony: {colony_path.name}")
|
||||
print(f"Path: {colony_path}")
|
||||
print(f"Queen: {metadata.get('queen_name', '?')}")
|
||||
print(f"Queen Session: {metadata.get('queen_session_id', '?')}")
|
||||
print(f"Source Session: {metadata.get('source_session_id', '?')}")
|
||||
print(f"Created: {metadata.get('created_at', '?')}")
|
||||
print()
|
||||
print(f"Workers ({len(workers)}):")
|
||||
for wname, w in workers.items():
|
||||
print(f" • {wname}")
|
||||
if w["goal"]:
|
||||
print(f" goal: {w['goal'][:80]}")
|
||||
print(f" tools: {w['tools']}")
|
||||
if w["spawned_from"]:
|
||||
print(f" from: {w['spawned_from']}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_colony_delete(args: argparse.Namespace) -> int:
|
||||
from framework.config import COLONIES_DIR, HIVE_HOME
|
||||
|
||||
colony_path = COLONIES_DIR / args.name
|
||||
if not colony_path.is_dir():
|
||||
print(f"Colony not found: {args.name}")
|
||||
return 1
|
||||
|
||||
storage_path = HIVE_HOME / "agents" / args.name
|
||||
purge_storage = args.purge_storage and storage_path.is_dir()
|
||||
|
||||
if not args.yes:
|
||||
print(f"This will permanently delete: {colony_path}")
|
||||
if purge_storage:
|
||||
print(f"And worker storage at: {storage_path}")
|
||||
confirm = input("Type the colony name to confirm: ").strip()
|
||||
if confirm != args.name:
|
||||
print("Cancelled.")
|
||||
return 1
|
||||
|
||||
shutil.rmtree(colony_path)
|
||||
print(f"Deleted {colony_path}")
|
||||
if purge_storage:
|
||||
shutil.rmtree(storage_path)
|
||||
print(f"Deleted {storage_path}")
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _register_session(subparsers: argparse._SubParsersAction) -> None:
|
||||
p = subparsers.add_parser(
|
||||
"session",
|
||||
help="Manage sessions",
|
||||
description="List live and cold sessions, stop running sessions.",
|
||||
)
|
||||
sub = p.add_subparsers(dest="subcommand", required=True)
|
||||
|
||||
list_p = sub.add_parser("list", help="List sessions")
|
||||
list_p.add_argument("--cold", action="store_true", help="Include cold (on-disk) sessions")
|
||||
list_p.add_argument("--server", default="http://127.0.0.1:8787", help="Hive server URL")
|
||||
list_p.add_argument("--json", action="store_true")
|
||||
list_p.set_defaults(func=cmd_session_list)
|
||||
|
||||
stop_p = sub.add_parser("stop", help="Stop a live session")
|
||||
stop_p.add_argument("session_id", type=str, help="Session ID to stop")
|
||||
stop_p.add_argument("--server", default="http://127.0.0.1:8787")
|
||||
stop_p.set_defaults(func=cmd_session_stop)
|
||||
|
||||
|
||||
def cmd_session_list(args: argparse.Namespace) -> int:
|
||||
if args.cold:
|
||||
# Read directly from disk -- works without server
|
||||
from framework.server.session_manager import SessionManager
|
||||
|
||||
rows = SessionManager.list_cold_sessions()
|
||||
else:
|
||||
# Hit the server's live session endpoint
|
||||
try:
|
||||
data = _http_get(f"{args.server}/api/sessions")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"Could not reach server at {args.server}: {e}")
|
||||
print("Tip: pass --cold to read on-disk sessions, or start 'hive serve' first.")
|
||||
return 1
|
||||
rows = data.get("sessions", [])
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(rows, indent=2))
|
||||
return 0
|
||||
|
||||
if not rows:
|
||||
print("No sessions.")
|
||||
return 0
|
||||
|
||||
print(f"{'SESSION':<40} {'COLONY':<20} {'PHASE':<12} WORKER")
|
||||
print("-" * 90)
|
||||
for r in rows:
|
||||
sid = r.get("session_id", "?")
|
||||
colony = r.get("colony_name") or r.get("colony_id") or ""
|
||||
phase = r.get("queen_phase", "?")
|
||||
has_worker = "yes" if r.get("has_worker") else "no"
|
||||
print(f"{sid:<40} {colony:<20} {phase:<12} {has_worker}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_session_stop(args: argparse.Namespace) -> int:
|
||||
try:
|
||||
data = _http_delete(f"{args.server}/api/sessions/{args.session_id}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"Could not reach server at {args.server}: {e}")
|
||||
return 1
|
||||
if data.get("stopped"):
|
||||
print(f"Stopped session {args.session_id}")
|
||||
return 0
|
||||
print(f"Failed to stop session: {data}")
|
||||
return 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# chat
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _register_chat(subparsers: argparse._SubParsersAction) -> None:
|
||||
p = subparsers.add_parser(
|
||||
"chat",
|
||||
help="Send a message to a live queen session",
|
||||
description="POST a chat message to a running session via the HTTP API.",
|
||||
)
|
||||
p.add_argument("session_id", type=str, help="Session ID")
|
||||
p.add_argument("message", type=str, help="Message text")
|
||||
p.add_argument("--server", default="http://127.0.0.1:8787", help="Hive server URL")
|
||||
p.set_defaults(func=cmd_chat)
|
||||
|
||||
|
||||
def cmd_chat(args: argparse.Namespace) -> int:
|
||||
try:
|
||||
data = _http_post(
|
||||
f"{args.server}/api/sessions/{args.session_id}/chat",
|
||||
{"message": args.message},
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"Could not reach server at {args.server}: {e}")
|
||||
return 1
|
||||
if "error" in data:
|
||||
print(f"Error: {data['error']}")
|
||||
return 1
|
||||
print(f"Sent. Tail the SSE stream at {args.server}/api/sessions/{args.session_id}/events")
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# JSON files inside ~/.hive/colonies/{name}/ that are NOT worker configs.
|
||||
_RESERVED_JSON_STEMS = {"agent", "flowchart", "triggers", "configuration", "metadata"}
|
||||
|
||||
|
||||
def _resolve_colony_path(name_or_path: str) -> Path | None:
|
||||
"""Resolve a colony argument to its on-disk Path.
|
||||
|
||||
Accepts either an absolute/relative path to a colony directory or
|
||||
a bare colony name (looked up under ~/.hive/colonies/{name}/).
|
||||
"""
|
||||
from framework.config import COLONIES_DIR
|
||||
|
||||
candidate = Path(name_or_path).expanduser()
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
by_name = COLONIES_DIR / name_or_path
|
||||
if by_name.is_dir():
|
||||
return by_name
|
||||
return None
|
||||
|
||||
|
||||
def _http_get(url: str, timeout: float = 10.0) -> dict:
|
||||
req = urlrequest.Request(url, method="GET")
|
||||
with urlrequest.urlopen(req, timeout=timeout) as r:
|
||||
return json.loads(r.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _http_post(url: str, body: dict, timeout: float = 30.0) -> dict:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urlrequest.Request(
|
||||
url, data=data, method="POST", headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urlrequest.urlopen(req, timeout=timeout) as r:
|
||||
return json.loads(r.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _http_delete(url: str, timeout: float = 10.0) -> dict:
|
||||
req = urlrequest.Request(url, method="DELETE")
|
||||
with urlrequest.urlopen(req, timeout=timeout) as r:
|
||||
return json.loads(r.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _frontend_dist_exists() -> bool:
|
||||
candidates = [Path("frontend/dist"), Path("core/frontend/dist")]
|
||||
return any((c / "index.html").exists() for c in candidates if c.is_dir())
|
||||
|
||||
|
||||
def _find_chrome_bin() -> str | None:
|
||||
"""Return the path to a Chrome/Chromium binary, or None if not found."""
|
||||
for candidate in (
|
||||
"google-chrome",
|
||||
"google-chrome-stable",
|
||||
"chromium",
|
||||
"chromium-browser",
|
||||
"microsoft-edge",
|
||||
"microsoft-edge-stable",
|
||||
):
|
||||
if shutil.which(candidate):
|
||||
return candidate
|
||||
|
||||
mac_paths = [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
Path.home() / "Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
]
|
||||
for p in mac_paths:
|
||||
if Path(p).exists():
|
||||
return str(p)
|
||||
return None
|
||||
|
||||
|
||||
def _open_browser(url: str) -> None:
|
||||
"""Open URL in the browser (best-effort, non-blocking)."""
|
||||
chrome = _find_chrome_bin()
|
||||
try:
|
||||
if chrome:
|
||||
subprocess.Popen(
|
||||
[chrome, url],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if sys.platform == "darwin":
|
||||
subprocess.Popen(
|
||||
["open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
||||
)
|
||||
elif sys.platform == "win32":
|
||||
subprocess.Popen(
|
||||
["cmd", "/c", "start", "", url],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
elif sys.platform == "linux":
|
||||
subprocess.Popen(
|
||||
["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _ping_hive_gateway_availability(from_source: str) -> None:
|
||||
"""Best-effort reachability ping to the Hive gateway."""
|
||||
base_url = "https://api.adenhq.com/v1/gateway/availability"
|
||||
query = urlparse.urlencode({"from": from_source})
|
||||
url = f"{base_url}?{query}"
|
||||
try:
|
||||
with urlrequest.urlopen(url, timeout=5) as response:
|
||||
response.read()
|
||||
except (urlerror.URLError, TimeoutError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _format_subprocess_output(output: str | bytes | None, limit: int = 2000) -> str:
|
||||
if not output:
|
||||
return ""
|
||||
text = output.decode(errors="replace") if isinstance(output, bytes) else output
|
||||
text = text.strip()
|
||||
return text if len(text) <= limit else text[-limit:]
|
||||
|
||||
|
||||
def _build_frontend() -> bool:
|
||||
"""Build the frontend if source is newer than dist. Returns True if dist exists."""
|
||||
candidates = [
|
||||
Path("core/frontend"),
|
||||
Path(__file__).resolve().parent.parent.parent / "frontend",
|
||||
]
|
||||
frontend_dir: Path | None = None
|
||||
for c in candidates:
|
||||
if (c / "package.json").is_file():
|
||||
frontend_dir = c.resolve()
|
||||
break
|
||||
|
||||
if frontend_dir is None:
|
||||
return False
|
||||
|
||||
dist_dir = frontend_dir / "dist"
|
||||
src_dir = frontend_dir / "src"
|
||||
|
||||
index_html = dist_dir / "index.html"
|
||||
if index_html.exists() and src_dir.is_dir():
|
||||
dist_mtime = index_html.stat().st_mtime
|
||||
needs_build = False
|
||||
for f in src_dir.rglob("*"):
|
||||
if f.is_file() and f.stat().st_mtime > dist_mtime:
|
||||
needs_build = True
|
||||
break
|
||||
if not needs_build:
|
||||
return True
|
||||
|
||||
print("Building frontend...")
|
||||
npm_cmd = "npm.cmd" if sys.platform == "win32" else "npm"
|
||||
try:
|
||||
for cache_file in frontend_dir.glob("tsconfig*.tsbuildinfo"):
|
||||
cache_file.unlink(missing_ok=True)
|
||||
|
||||
subprocess.run(
|
||||
[npm_cmd, "install", "--no-fund", "--no-audit"],
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
cwd=frontend_dir,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
[npm_cmd, "run", "build"],
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
cwd=frontend_dir,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
print("Frontend built.")
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
print("Node.js not found — skipping frontend build.")
|
||||
return dist_dir.is_dir()
|
||||
except subprocess.CalledProcessError as exc:
|
||||
stdout = _format_subprocess_output(exc.stdout)
|
||||
stderr = _format_subprocess_output(exc.stderr)
|
||||
cmd = " ".join(exc.cmd) if isinstance(exc.cmd, (list, tuple)) else str(exc.cmd)
|
||||
details = "\n".join(part for part in [stdout, stderr] if part).strip()
|
||||
if details:
|
||||
print(f"Frontend build failed while running {cmd}:\n{details}")
|
||||
else:
|
||||
print(f"Frontend build failed while running {cmd} (exit {exc.returncode}).")
|
||||
return dist_dir.is_dir()
|
||||
@@ -14,7 +14,7 @@ from typing import Any, Literal
|
||||
|
||||
import httpx
|
||||
|
||||
from framework.runner.mcp_errors import MCPToolNotFoundError
|
||||
from framework.loader.mcp_errors import MCPToolNotFoundError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -378,7 +378,10 @@ class MCPClient:
|
||||
|
||||
tool_names = list(self._tools.keys())
|
||||
logger.info(
|
||||
f"Discovered {len(self._tools)} tools from '{self.config.name}': {tool_names}"
|
||||
f"Discovered {len(self._tools)} tools from '{self.config.name}'"
|
||||
)
|
||||
logger.debug(
|
||||
f"Discovered tools from '{self.config.name}': {tool_names}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to discover tools from '{self.config.name}': {e}")
|
||||
@@ -464,8 +467,11 @@ class MCPClient:
|
||||
)
|
||||
|
||||
if self.config.transport == "stdio":
|
||||
with self._stdio_call_lock:
|
||||
return self._run_async(self._call_tool_stdio_async(tool_name, arguments))
|
||||
def _stdio_call() -> Any:
|
||||
with self._stdio_call_lock:
|
||||
return self._run_async(self._call_tool_stdio_async(tool_name, arguments))
|
||||
|
||||
return self._call_tool_with_retry(_stdio_call)
|
||||
elif self.config.transport == "sse":
|
||||
return self._call_tool_with_retry(
|
||||
lambda: self._run_async(self._call_tool_stdio_async(tool_name, arguments))
|
||||
@@ -475,10 +481,70 @@ class MCPClient:
|
||||
else:
|
||||
return self._call_tool_http(tool_name, arguments)
|
||||
|
||||
# Exceptions that indicate the STDIO session/subprocess is dead and
|
||||
# needs a fresh connect(). Keep this narrow — we don't want to mask
|
||||
# tool-level errors as transport errors.
|
||||
_STDIO_DEAD_SESSION_ERRORS = (
|
||||
BrokenPipeError,
|
||||
ConnectionError,
|
||||
ConnectionResetError,
|
||||
EOFError,
|
||||
)
|
||||
|
||||
def _is_stdio_dead_session_error(self, exc: BaseException) -> bool:
|
||||
if isinstance(exc, self._STDIO_DEAD_SESSION_ERRORS):
|
||||
return True
|
||||
# mcp SDK frequently wraps transport errors in RuntimeError with a
|
||||
# readable message — match on the common signals.
|
||||
if isinstance(exc, RuntimeError):
|
||||
msg = str(exc).lower()
|
||||
for needle in (
|
||||
"broken pipe",
|
||||
"connection closed",
|
||||
"connection reset",
|
||||
"stream closed",
|
||||
"session not initialized",
|
||||
"transport closed",
|
||||
"anyio.closedresourceerror",
|
||||
"read operation was cancelled",
|
||||
):
|
||||
if needle in msg:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _call_tool_with_retry(self, call: Any) -> Any:
|
||||
"""Retry transient MCP transport failures once after reconnecting."""
|
||||
"""Retry once after reconnecting when the transport looks dead.
|
||||
|
||||
Applies to all transports:
|
||||
- **stdio**: if the subprocess died (broken pipe, closed stream,
|
||||
session not initialized), tear it down and start a fresh one.
|
||||
- **sse / unix / http** (httpx-backed): same treatment for
|
||||
``httpx.ConnectError`` / ``httpx.ReadTimeout``.
|
||||
"""
|
||||
if self.config.transport == "stdio":
|
||||
return call()
|
||||
try:
|
||||
return call()
|
||||
except BaseException as original_error:
|
||||
if not self._is_stdio_dead_session_error(original_error):
|
||||
raise
|
||||
logger.warning(
|
||||
"Retrying MCP STDIO tool call after dead-session signal from '%s': %s",
|
||||
self.config.name,
|
||||
original_error,
|
||||
)
|
||||
try:
|
||||
self._reconnect()
|
||||
except Exception as reconnect_error:
|
||||
logger.warning(
|
||||
"Reconnect failed for MCP STDIO server '%s': %s",
|
||||
self.config.name,
|
||||
reconnect_error,
|
||||
)
|
||||
raise original_error from reconnect_error
|
||||
try:
|
||||
return call()
|
||||
except BaseException as retry_error:
|
||||
raise original_error from retry_error
|
||||
|
||||
if self.config.transport not in {"unix", "sse"}:
|
||||
return call()
|
||||
+1
-1
@@ -5,7 +5,7 @@ import threading
|
||||
|
||||
import httpx
|
||||
|
||||
from framework.runner.mcp_client import MCPClient, MCPServerConfig
|
||||
from framework.loader.mcp_client import MCPClient, MCPServerConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -14,9 +14,9 @@ from typing import Any, Literal
|
||||
|
||||
import httpx
|
||||
|
||||
from framework.runner.mcp_client import MCPClient, MCPServerConfig
|
||||
from framework.runner.mcp_connection_manager import MCPConnectionManager
|
||||
from framework.runner.mcp_errors import (
|
||||
from framework.loader.mcp_client import MCPClient, MCPServerConfig
|
||||
from framework.loader.mcp_connection_manager import MCPConnectionManager
|
||||
from framework.loader.mcp_errors import (
|
||||
MCPError,
|
||||
MCPErrorCode,
|
||||
MCPInstallError,
|
||||
@@ -36,6 +36,32 @@ _DEFAULT_CONFIG = {
|
||||
"refresh_interval_hours": DEFAULT_REFRESH_INTERVAL_HOURS,
|
||||
}
|
||||
|
||||
# Default local MCP servers that ship with Hive. Seeded on first startup so
|
||||
# fresh users get working file I/O, browser automation, and the hive tool
|
||||
# suite without having to run `hive mcp add` manually. ``cwd`` is filled in
|
||||
# at registration time with the absolute path to the ``tools/`` directory.
|
||||
_DEFAULT_LOCAL_SERVERS: dict[str, dict[str, Any]] = {
|
||||
"hive_tools": {
|
||||
"description": "Hive tools: web search, email, CRM, calendar, and 100+ integrations",
|
||||
"args": ["run", "python", "mcp_server.py", "--stdio"],
|
||||
},
|
||||
"gcu-tools": {
|
||||
"description": "Browser automation: click, type, navigate, screenshot, snapshot",
|
||||
"args": ["run", "python", "-m", "gcu.server", "--stdio"],
|
||||
},
|
||||
"files-tools": {
|
||||
"description": "File I/O: read, write, edit, search, list, run commands",
|
||||
"args": ["run", "python", "files_server.py", "--stdio"],
|
||||
},
|
||||
}
|
||||
|
||||
# Aliases that earlier versions of ensure_defaults wrote under the wrong name.
|
||||
# When we see one of these stale entries, drop it before seeding the canonical
|
||||
# name so the active agents (queen, credential_tester) can find their tools.
|
||||
_STALE_DEFAULT_ALIASES: dict[str, str] = {
|
||||
"hive_tools": "hive-tools",
|
||||
}
|
||||
|
||||
|
||||
class MCPRegistry:
|
||||
"""Manages local MCP server state in ~/.hive/mcp_registry/."""
|
||||
@@ -59,6 +85,69 @@ class MCPRegistry:
|
||||
if not self._installed_path.exists():
|
||||
self._write_json(self._installed_path, {"servers": {}})
|
||||
|
||||
def ensure_defaults(self) -> list[str]:
|
||||
"""Seed the built-in local MCP servers (hive-tools, gcu-tools, files-tools).
|
||||
|
||||
Idempotent — servers already present are left untouched. Skips seeding
|
||||
entirely when the source-tree ``tools/`` directory cannot be located
|
||||
(e.g. when Hive is installed from a wheel rather than a checkout).
|
||||
|
||||
Returns the list of names that were newly registered.
|
||||
"""
|
||||
self.initialize()
|
||||
|
||||
# parents: [0]=loader, [1]=framework, [2]=core, [3]=repo root
|
||||
tools_dir = Path(__file__).resolve().parents[3] / "tools"
|
||||
if not tools_dir.is_dir():
|
||||
logger.debug(
|
||||
"MCPRegistry.ensure_defaults: tools dir %s missing; skipping default seed",
|
||||
tools_dir,
|
||||
)
|
||||
return []
|
||||
|
||||
cwd = str(tools_dir)
|
||||
data = self._read_installed()
|
||||
existing = data.get("servers", {})
|
||||
added: list[str] = []
|
||||
|
||||
# Drop stale aliases (from earlier versions that wrote the wrong name).
|
||||
# Only remove the alias when the canonical name isn't already installed,
|
||||
# so we never clobber a hand-edited entry the user cares about.
|
||||
mutated = False
|
||||
for canonical, stale in _STALE_DEFAULT_ALIASES.items():
|
||||
if stale in existing and canonical not in existing:
|
||||
logger.info(
|
||||
"MCPRegistry.ensure_defaults: removing stale alias '%s' (canonical: '%s')",
|
||||
stale,
|
||||
canonical,
|
||||
)
|
||||
del existing[stale]
|
||||
mutated = True
|
||||
if mutated:
|
||||
self._write_installed(data)
|
||||
|
||||
for name, spec in _DEFAULT_LOCAL_SERVERS.items():
|
||||
if name in existing:
|
||||
continue
|
||||
try:
|
||||
self.add_local(
|
||||
name=name,
|
||||
transport="stdio",
|
||||
command="uv",
|
||||
args=list(spec["args"]),
|
||||
cwd=cwd,
|
||||
description=spec["description"],
|
||||
)
|
||||
added.append(name)
|
||||
except MCPError as exc:
|
||||
logger.warning(
|
||||
"MCPRegistry.ensure_defaults: failed to seed '%s': %s", name, exc
|
||||
)
|
||||
|
||||
if added:
|
||||
logger.info("MCPRegistry: seeded default local servers: %s", added)
|
||||
return added
|
||||
|
||||
# ── Internal I/O ────────────────────────────────────────────────
|
||||
|
||||
def _read_installed(self) -> dict:
|
||||
+25
-1
@@ -28,7 +28,7 @@ from typing import Any
|
||||
|
||||
def _get_registry(base_path: Path | None = None):
|
||||
"""Initialize and return an MCPRegistry instance."""
|
||||
from framework.runner.mcp_registry import MCPRegistry
|
||||
from framework.loader.mcp_registry import MCPRegistry
|
||||
|
||||
registry = MCPRegistry(base_path=base_path)
|
||||
registry.initialize()
|
||||
@@ -381,6 +381,13 @@ def register_mcp_commands(subparsers) -> None:
|
||||
health_p.add_argument("--json", dest="output_json", action="store_true", help="Output as JSON")
|
||||
health_p.set_defaults(func=cmd_mcp_health)
|
||||
|
||||
# ── init ──
|
||||
init_p = mcp_sub.add_parser(
|
||||
"init",
|
||||
help="Initialize the local MCP registry and seed built-in servers",
|
||||
)
|
||||
init_p.set_defaults(func=cmd_mcp_init)
|
||||
|
||||
# ── update ──
|
||||
update_p = mcp_sub.add_parser(
|
||||
"update", help="Update installed servers or refresh the registry index"
|
||||
@@ -786,6 +793,23 @@ def cmd_mcp_health(args) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_mcp_init(args) -> int:
|
||||
"""Initialize the local MCP registry and seed built-in local servers."""
|
||||
registry = _get_registry()
|
||||
try:
|
||||
added = registry.ensure_defaults()
|
||||
except Exception as exc:
|
||||
print(f"Error: failed to initialize MCP registry: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if added:
|
||||
for name in added:
|
||||
print(f"✓ Registered {name}")
|
||||
else:
|
||||
print("✓ MCP registry already initialized (no changes)")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_mcp_update(args) -> int:
|
||||
"""Update a single server, or refresh the index and update all registry servers."""
|
||||
registry = _get_registry()
|
||||
+2
-2
@@ -11,8 +11,8 @@ from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from framework.graph.edge import GraphSpec
|
||||
from framework.graph.node import NodeSpec
|
||||
from framework.orchestrator.edge import GraphSpec
|
||||
from framework.orchestrator.node import NodeSpec
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -48,7 +48,34 @@ class ToolRegistry:
|
||||
# Framework-internal context keys injected into tool calls.
|
||||
# Stripped from LLM-facing schemas (the LLM doesn't know these values)
|
||||
# and auto-injected at call time for tools that accept them.
|
||||
CONTEXT_PARAMS = frozenset({"agent_id", "data_dir"})
|
||||
CONTEXT_PARAMS = frozenset({"agent_id", "data_dir", "profile"})
|
||||
|
||||
# Tools that perform no filesystem/process/network writes and are safe
|
||||
# to run concurrently with other safe tools in the same assistant turn.
|
||||
# Unknown tools default to unsafe (serialized) - adding a name here is
|
||||
# an explicit promise about that tool's side effects. Keep this list
|
||||
# conservative: anything that mutates state, writes to disk, issues
|
||||
# POST/PUT/DELETE requests, or drives a browser MUST NOT be listed.
|
||||
CONCURRENCY_SAFE_TOOLS = frozenset(
|
||||
{
|
||||
# File system reads
|
||||
"read_file",
|
||||
"list_directory",
|
||||
"grep",
|
||||
"glob",
|
||||
# Web reads
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
# Browser read-only snapshots (mutate-free observations)
|
||||
"browser_screenshot",
|
||||
"browser_snapshot",
|
||||
"browser_console",
|
||||
"browser_get_text",
|
||||
# Background bash polling - reads output buffers only, does
|
||||
# not touch the subprocess itself.
|
||||
"bash_output",
|
||||
}
|
||||
)
|
||||
|
||||
# Credential directory used for change detection
|
||||
_CREDENTIAL_DIR = Path("~/.hive/credentials/credentials").expanduser()
|
||||
@@ -66,9 +93,24 @@ class ToolRegistry:
|
||||
self._mcp_cred_snapshot: set[str] = set() # Credential filenames at MCP load time
|
||||
self._mcp_aden_key_snapshot: str | None = None # ADEN_API_KEY value at MCP load time
|
||||
self._mcp_server_tools: dict[str, set[str]] = {} # server name -> tool names
|
||||
# tool name -> owning MCPClient (for force-kill on timeout)
|
||||
self._mcp_tool_clients: dict[str, Any] = {}
|
||||
# Per-agent env injected into every MCP server config.env. Kept
|
||||
# here (not on the process-wide os.environ) so parallel workers
|
||||
# in the same interpreter don't clobber each other's identity.
|
||||
self._mcp_extra_env: dict[str, str] = {}
|
||||
# Agent dir for re-loading registry MCP after credential resync.
|
||||
self._mcp_registry_agent_path: Path | None = None
|
||||
|
||||
def set_mcp_extra_env(self, env: dict[str, str]) -> None:
|
||||
"""Attach per-agent env vars to every MCPServerConfig this registry builds.
|
||||
|
||||
Use this instead of mutating ``os.environ`` — the global env dict
|
||||
is shared across all workers in a single interpreter, so writes
|
||||
from one worker race with MCP spawns from another.
|
||||
"""
|
||||
self._mcp_extra_env = dict(env)
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
@@ -137,6 +179,7 @@ class ToolRegistry:
|
||||
"properties": properties,
|
||||
"required": required,
|
||||
},
|
||||
concurrency_safe=tool_name in self.CONCURRENCY_SAFE_TOOLS,
|
||||
)
|
||||
|
||||
def executor(inputs: dict) -> Any:
|
||||
@@ -262,15 +305,21 @@ class ToolRegistry:
|
||||
is_error=False,
|
||||
)
|
||||
|
||||
registry_ref = self
|
||||
|
||||
def executor(tool_use: ToolUse) -> ToolResult:
|
||||
if tool_use.name not in self._tools:
|
||||
# Check if credential files changed (lightweight dir listing).
|
||||
# If new OAuth tokens appeared, restarts MCP servers to pick them up.
|
||||
registry_ref.resync_mcp_servers_if_needed()
|
||||
|
||||
if tool_use.name not in registry_ref._tools:
|
||||
return ToolResult(
|
||||
tool_use_id=tool_use.id,
|
||||
content=json.dumps({"error": f"Unknown tool: {tool_use.name}"}),
|
||||
is_error=True,
|
||||
)
|
||||
|
||||
registered = self._tools[tool_use.name]
|
||||
registered = registry_ref._tools[tool_use.name]
|
||||
try:
|
||||
result = registered.executor(tool_use.input)
|
||||
|
||||
@@ -320,6 +369,9 @@ class ToolRegistry:
|
||||
is_error=True,
|
||||
)
|
||||
|
||||
# Expose force-kill hook so the timeout handler can tear down a
|
||||
# hung MCP subprocess (asyncio.wait_for alone cannot).
|
||||
executor.kill_for_tool = registry_ref.kill_mcp_for_tool # type: ignore[attr-defined]
|
||||
return executor
|
||||
|
||||
def get_registered_names(self) -> list[str]:
|
||||
@@ -366,7 +418,7 @@ class ToolRegistry:
|
||||
"""Resolve cwd and script paths for MCP stdio config (Windows compatibility).
|
||||
|
||||
Use this when building MCPServerConfig from a config file (e.g. in
|
||||
list_agent_tools, discover_mcp_tools) so hive-tools and other servers
|
||||
list_agent_tools, discover_mcp_tools) so hive_tools and other servers
|
||||
work on Windows. Call with base_dir = directory containing the config.
|
||||
"""
|
||||
registry = ToolRegistry()
|
||||
@@ -504,6 +556,8 @@ class ToolRegistry:
|
||||
self._mcp_cred_snapshot = self._snapshot_credentials()
|
||||
self._mcp_aden_key_snapshot = os.environ.get("ADEN_API_KEY")
|
||||
|
||||
self._log_registry_snapshot("after load_mcp_config")
|
||||
|
||||
def _register_mcp_server_with_retry(
|
||||
self,
|
||||
server_config: dict[str, Any],
|
||||
@@ -635,16 +689,20 @@ class ToolRegistry:
|
||||
Number of tools registered from this server
|
||||
"""
|
||||
try:
|
||||
from framework.runner.mcp_client import MCPClient, MCPServerConfig
|
||||
from framework.runner.mcp_connection_manager import MCPConnectionManager
|
||||
from framework.loader.mcp_client import MCPClient, MCPServerConfig
|
||||
from framework.loader.mcp_connection_manager import MCPConnectionManager
|
||||
|
||||
# Build config object
|
||||
# Build config object. Merge per-agent env on top of the
|
||||
# server's own env so MCP subprocesses receive the identity
|
||||
# of the worker that spawned them (instead of whichever
|
||||
# worker most recently wrote to os.environ).
|
||||
merged_env = {**self._mcp_extra_env, **(server_config.get("env") or {})}
|
||||
config = MCPServerConfig(
|
||||
name=server_config["name"],
|
||||
transport=server_config["transport"],
|
||||
command=server_config.get("command"),
|
||||
args=server_config.get("args", []),
|
||||
env=server_config.get("env", {}),
|
||||
env=merged_env,
|
||||
cwd=server_config.get("cwd"),
|
||||
url=server_config.get("url"),
|
||||
headers=server_config.get("headers", {}),
|
||||
@@ -670,8 +728,19 @@ class ToolRegistry:
|
||||
server_name = server_config["name"]
|
||||
if server_name not in self._mcp_server_tools:
|
||||
self._mcp_server_tools[server_name] = set()
|
||||
|
||||
# Build admission gate: only admit MCP tools that are either
|
||||
# (a) credential-backed *and* have a configured account, or
|
||||
# (b) credential-less *and* listed in the verified manifest.
|
||||
# Servers that don't expose `__aden_verified_manifest` (third-party
|
||||
# MCP servers) bypass the gate entirely — preserves prior behavior.
|
||||
admit = self._build_mcp_admission_gate(client)
|
||||
|
||||
count = 0
|
||||
admitted_names: list[str] = []
|
||||
for mcp_tool in client.list_tools():
|
||||
if not admit(mcp_tool.name):
|
||||
continue
|
||||
if tool_cap is not None and count >= tool_cap:
|
||||
break
|
||||
|
||||
@@ -751,7 +820,9 @@ class ToolRegistry:
|
||||
make_mcp_executor(client, mcp_tool.name, self, tool_params),
|
||||
)
|
||||
self._mcp_tool_names.add(mcp_tool.name)
|
||||
self._mcp_tool_clients[mcp_tool.name] = client
|
||||
self._mcp_server_tools[server_name].add(mcp_tool.name)
|
||||
admitted_names.append(mcp_tool.name)
|
||||
count += 1
|
||||
|
||||
logger.info(
|
||||
@@ -763,6 +834,12 @@ class ToolRegistry:
|
||||
"skipped_reason": None,
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
"MCP server '%s' admitted %d tool(s): %s",
|
||||
config.name,
|
||||
len(admitted_names),
|
||||
sorted(admitted_names),
|
||||
)
|
||||
return count
|
||||
|
||||
except Exception as e:
|
||||
@@ -788,6 +865,110 @@ class ToolRegistry:
|
||||
return server_name
|
||||
return None
|
||||
|
||||
def _log_registry_snapshot(self, context: str) -> None:
|
||||
"""Emit a one-line summary of the current tool registry.
|
||||
|
||||
Called after every tool-list mutation (initial load + resync) so that
|
||||
operators can correlate "what tools does the queen have right now"
|
||||
with credential changes and MCP server lifecycle events. Per-server
|
||||
contents are already logged by `register_mcp_server`; this is just the
|
||||
rollup so the resync path also gets a single anchor line.
|
||||
"""
|
||||
per_server_counts = {
|
||||
server: len(names) for server, names in self._mcp_server_tools.items()
|
||||
}
|
||||
non_mcp_count = len(self._tools) - len(self._mcp_tool_names)
|
||||
logger.info(
|
||||
"ToolRegistry snapshot (%s): total=%d, mcp=%d, non_mcp=%d, per_server=%s",
|
||||
context,
|
||||
len(self._tools),
|
||||
len(self._mcp_tool_names),
|
||||
non_mcp_count,
|
||||
per_server_counts,
|
||||
)
|
||||
|
||||
_MCP_VERIFIED_MANIFEST_TOOL = "__aden_verified_manifest"
|
||||
|
||||
def _build_mcp_admission_gate(self, client: Any) -> Callable[[str], bool]:
|
||||
"""Build a per-server predicate that filters MCP tools at registration.
|
||||
|
||||
Rules:
|
||||
* The sentinel manifest tool itself is never admitted.
|
||||
* Credential-backed tools (provider in `tool_provider_map`) are
|
||||
admitted only when at least one account exists for that provider.
|
||||
* Credential-less tools are admitted only when they appear in the
|
||||
server's verified manifest.
|
||||
* Servers that don't expose a manifest bypass the verified gate
|
||||
entirely (third-party MCP servers behave as before).
|
||||
"""
|
||||
verified_names: set[str] = set()
|
||||
manifest_present = False
|
||||
# Only probe the sentinel when the server actually advertises it.
|
||||
# Calling ``__aden_verified_manifest`` unconditionally on every
|
||||
# MCP server at registration time (a) causes a bogus tool call
|
||||
# round-trip to every third-party server, (b) pollutes any
|
||||
# call-capturing fakes in tests, and (c) risks side effects on
|
||||
# servers that eagerly execute unknown tool names. Listing is
|
||||
# cheap and cached by the client; this keeps the manifest gate
|
||||
# active for aden-flavoured servers without penalising others.
|
||||
sentinel_advertised = False
|
||||
try:
|
||||
for t in client.list_tools():
|
||||
if getattr(t, "name", None) == self._MCP_VERIFIED_MANIFEST_TOOL:
|
||||
sentinel_advertised = True
|
||||
break
|
||||
except Exception:
|
||||
sentinel_advertised = False
|
||||
|
||||
if sentinel_advertised:
|
||||
try:
|
||||
raw = client.call_tool(self._MCP_VERIFIED_MANIFEST_TOOL, {})
|
||||
parsed: Any = raw
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
# Only treat the response as a manifest when it's a list
|
||||
# of strings. A malformed response shouldn't flip the gate
|
||||
# on and silently hide every real tool from the server.
|
||||
if isinstance(parsed, list) and all(isinstance(n, str) for n in parsed):
|
||||
verified_names = set(parsed)
|
||||
manifest_present = True
|
||||
except Exception:
|
||||
# Server advertised the sentinel but errored when called
|
||||
# — treat as no manifest; fall back to third-party bypass.
|
||||
pass
|
||||
|
||||
tool_provider_map: dict[str, str] = {}
|
||||
live_providers: set[str] = set()
|
||||
try:
|
||||
from aden_tools.credentials.store_adapter import CredentialStoreAdapter
|
||||
|
||||
adapter = CredentialStoreAdapter.default()
|
||||
tool_provider_map = adapter.get_tool_provider_map()
|
||||
live_providers = {
|
||||
a.get("provider", "")
|
||||
for a in adapter.get_all_account_info()
|
||||
if a.get("provider")
|
||||
}
|
||||
except Exception:
|
||||
logger.debug("Credential snapshot unavailable for MCP gate", exc_info=True)
|
||||
|
||||
def admit(tool_name: str) -> bool:
|
||||
if tool_name == self._MCP_VERIFIED_MANIFEST_TOOL:
|
||||
return False
|
||||
provider = tool_provider_map.get(tool_name)
|
||||
if provider:
|
||||
# Credentialed tool — needs an account.
|
||||
return provider in live_providers
|
||||
if not manifest_present:
|
||||
# Third-party MCP server: preserve legacy "admit everything".
|
||||
return True
|
||||
return tool_name in verified_names
|
||||
|
||||
return admit
|
||||
|
||||
def _convert_mcp_tool_to_framework_tool(self, mcp_tool: Any) -> Tool:
|
||||
"""
|
||||
Convert an MCP tool to a framework Tool.
|
||||
@@ -817,6 +998,7 @@ class ToolRegistry:
|
||||
"properties": properties,
|
||||
"required": required,
|
||||
},
|
||||
concurrency_safe=mcp_tool.name in self.CONCURRENCY_SAFE_TOOLS,
|
||||
)
|
||||
|
||||
return tool
|
||||
@@ -883,7 +1065,7 @@ class ToolRegistry:
|
||||
"""Re-run ``mcp_registry.json`` resolution and register servers (post-resync)."""
|
||||
if self._mcp_registry_agent_path is None:
|
||||
return
|
||||
from framework.runner.mcp_registry import MCPRegistry
|
||||
from framework.loader.mcp_registry import MCPRegistry
|
||||
|
||||
try:
|
||||
reg = MCPRegistry()
|
||||
@@ -922,6 +1104,11 @@ class ToolRegistry:
|
||||
clients and re-loads them so the new subprocess picks up the fresh
|
||||
credentials.
|
||||
|
||||
Note: Individual credential TTL/refresh is handled by the MCP server
|
||||
process internally -- it resolves tokens from the credential store
|
||||
on every tool call, not at startup. This method only handles the case
|
||||
where entirely new credential files appear.
|
||||
|
||||
Returns True if a resync was performed, False otherwise.
|
||||
"""
|
||||
if not self._mcp_clients or self._mcp_config_path is None:
|
||||
@@ -959,6 +1146,7 @@ class ToolRegistry:
|
||||
self.reload_registry_mcp_servers_after_resync()
|
||||
|
||||
logger.info("MCP server resync complete")
|
||||
self._log_registry_snapshot("after resync_mcp_servers_if_needed")
|
||||
return True
|
||||
|
||||
def cleanup(self) -> None:
|
||||
@@ -975,7 +1163,7 @@ class ToolRegistry:
|
||||
server_name = self._mcp_client_servers.get(client_id, client.config.name)
|
||||
try:
|
||||
if client_id in self._mcp_managed_clients:
|
||||
from framework.runner.mcp_connection_manager import MCPConnectionManager
|
||||
from framework.loader.mcp_connection_manager import MCPConnectionManager
|
||||
|
||||
MCPConnectionManager.get_instance().release(server_name)
|
||||
else:
|
||||
@@ -985,6 +1173,33 @@ class ToolRegistry:
|
||||
self._mcp_clients.clear()
|
||||
self._mcp_client_servers.clear()
|
||||
self._mcp_managed_clients.clear()
|
||||
self._mcp_tool_clients.clear()
|
||||
|
||||
def kill_mcp_for_tool(self, tool_name: str) -> bool:
|
||||
"""Force-disconnect the MCP client that owns *tool_name*.
|
||||
|
||||
Called from the timeout handler in ``execute_tool`` when a tool
|
||||
call hangs. Plain ``asyncio.wait_for`` cancellation cannot stop
|
||||
a sync executor running inside a thread pool (and therefore
|
||||
cannot stop the MCP subprocess), so we reach through to the
|
||||
client here and tear it down. The next ``call_tool`` triggers
|
||||
an automatic reconnect.
|
||||
|
||||
Returns True if a client was found and disconnect was attempted.
|
||||
"""
|
||||
client = self._mcp_tool_clients.get(tool_name)
|
||||
if client is None:
|
||||
return False
|
||||
try:
|
||||
logger.warning(
|
||||
"Force-disconnecting MCP client for hung tool '%s' on server '%s'",
|
||||
tool_name,
|
||||
getattr(client.config, "name", "?"),
|
||||
)
|
||||
client.disconnect()
|
||||
except Exception as exc:
|
||||
logger.warning("Error force-disconnecting MCP client for '%s': %s", tool_name, exc)
|
||||
return True
|
||||
|
||||
def __del__(self):
|
||||
"""Destructor to ensure cleanup."""
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Orchestrator layer -- how agents are composed via graphs.
|
||||
|
||||
Lazy imports to avoid circular dependencies with graph/event_loop/*.
|
||||
"""
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name in ("GraphContext",):
|
||||
from framework.orchestrator.context import GraphContext
|
||||
|
||||
return GraphContext
|
||||
if name in ("DEFAULT_MAX_TOKENS", "EdgeCondition", "EdgeSpec", "GraphSpec"):
|
||||
from framework.orchestrator import edge as _e
|
||||
|
||||
return getattr(_e, name)
|
||||
if name in ("Orchestrator", "ExecutionResult"):
|
||||
from framework.orchestrator import orchestrator as _o
|
||||
|
||||
return getattr(_o, name)
|
||||
if name in ("Constraint", "Goal", "GoalStatus", "SuccessCriterion"):
|
||||
from framework.orchestrator import goal as _g
|
||||
|
||||
return getattr(_g, name)
|
||||
if name in ("DataBuffer", "NodeContext", "NodeProtocol", "NodeResult", "NodeSpec"):
|
||||
from framework.orchestrator import node as _n
|
||||
|
||||
return getattr(_n, name)
|
||||
if name in (
|
||||
"NodeWorker",
|
||||
"Activation",
|
||||
"FanOutTag",
|
||||
"FanOutTracker",
|
||||
"WorkerCompletion",
|
||||
"WorkerLifecycle",
|
||||
):
|
||||
from framework.orchestrator import node_worker as _nw
|
||||
|
||||
return getattr(_nw, name)
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user