Merge remote-tracking branch 'upstream/feat/open-hive' into feat/sub-agent-framework

This commit is contained in:
Richard Tang
2026-02-27 14:48:47 -08:00
8 changed files with 221 additions and 72 deletions
+6 -7
View File
@@ -14,7 +14,7 @@
</p>
<p align="center">
<a href="https://github.com/adenhq/hive/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-Apache%202.0-blue.svg" alt="Apache 2.0 License" /></a>
<a href="https://github.com/aden-hive/hive/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-Apache%202.0-blue.svg" alt="Apache 2.0 License" /></a>
<a href="https://www.ycombinator.com/companies/aden"><img src="https://img.shields.io/badge/Y%20Combinator-Aden-orange" alt="Y Combinator" /></a>
<a href="https://discord.com/invite/MXE49hrKDk"><img src="https://img.shields.io/discord/1172610340073242735?logo=discord&labelColor=%235462eb&logoColor=%23f5f5f5&color=%235462eb" alt="Discord" /></a>
<a href="https://x.com/aden_hq"><img src="https://img.shields.io/twitter/follow/teamaden?logo=X&color=%23f5f5f5" alt="Twitter Follow" /></a>
@@ -71,7 +71,7 @@ Use Hive when you need:
- **[Documentation](https://docs.adenhq.com/)** - Complete guides and API reference
- **[Self-Hosting Guide](https://docs.adenhq.com/getting-started/quickstart)** - Deploy Hive on your infrastructure
- **[Changelog](https://github.com/adenhq/hive/releases)** - Latest updates and releases
- **[Changelog](https://github.com/aden-hive/hive/releases)** - Latest updates and releases
- **[Roadmap](docs/roadmap.md)** - Upcoming features and plans
- **[Report Issues](https://github.com/adenhq/hive/issues)** - Bug reports and feature requests
- **[Contributing](CONTRIBUTING.md)** - How to contribute and submit PRs
@@ -94,9 +94,10 @@ Use Hive when you need:
```bash
# Clone the repository
git clone https://github.com/adenhq/hive.git
git clone https://github.com/aden-hive/hive.git
cd hive
# Run quickstart setup
./quickstart.sh
```
@@ -178,8 +179,7 @@ Skills and MCP servers are also available in [Antigravity IDE](https://antigravi
## Integration
<a href="https://github.com/adenhq/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>
<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>
Hive is built to be model-agnostic and system-agnostic.
- **LLM flexibility** - Hive Framework is designed to support various types of LLMs, including hosted and local models through LiteLLM-compatible providers.
@@ -398,8 +398,7 @@ flowchart TB
```
## Contributing
We welcome contributions from the community! Were especially looking for help building tools, integrations, and example agents for the framework ([check #2805](https://github.com/adenhq/hive/issues/2805)). If youre interested in extending its functionality, this is the perfect place to start. Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
We welcome contributions from the community! Were especially looking for help building tools, integrations, and example agents for the framework ([check #2805](https://github.com/aden-hive/hive/issues/2805)). If youre interested in extending its functionality, this is the perfect place to start. Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
**Important:** Please get assigned to an issue before submitting a PR. Comment on an issue to claim it, and a maintainer will assign you. Issues with reproducible steps and proposals are prioritized. This helps prevent duplicate work.
+4 -1
View File
@@ -395,7 +395,10 @@ def validate_agent_credentials(
spec = CREDENTIAL_SPECS[cn]
affected = sorted(t for t in required_tools if t in spec.tools)
_check_credential(
spec, cn, affected_tools=affected, affected_node_types=[],
spec,
cn,
affected_tools=affected,
affected_node_types=[],
alternative_group=group_key,
)
+31 -3
View File
@@ -570,8 +570,7 @@ class ExecutionStream:
if not _is_shared_session:
await self._write_session_state(execution_id, ctx, result=result)
# Emit completion/failure event
# (skip for pauses — executor already emitted execution_paused)
# Emit completion/failure/pause event
if self._scoped_event_bus:
if result.success:
await self._scoped_event_bus.emit_execution_completed(
@@ -580,7 +579,17 @@ class ExecutionStream:
output=result.output,
correlation_id=ctx.correlation_id,
)
elif not result.paused_at:
elif result.paused_at:
# The executor returns paused_at on CancelledError but
# does NOT emit execution_paused itself — we must emit
# it here so the frontend can transition out of "running".
await self._scoped_event_bus.emit_execution_paused(
stream_id=self.stream_id,
node_id=result.paused_at,
reason=result.error or "Execution paused",
execution_id=execution_id,
)
else:
await self._scoped_event_bus.emit_execution_failed(
stream_id=self.stream_id,
execution_id=execution_id,
@@ -629,6 +638,25 @@ class ExecutionStream:
execution_id, ctx, error="Execution cancelled"
)
# Emit SSE event so the frontend knows the execution stopped.
# The executor does NOT emit on CancelledError, so there is no
# risk of double-emitting.
if self._scoped_event_bus:
if has_result and result.paused_at:
await self._scoped_event_bus.emit_execution_paused(
stream_id=self.stream_id,
node_id=result.paused_at,
reason="Execution cancelled",
execution_id=execution_id,
)
else:
await self._scoped_event_bus.emit_execution_failed(
stream_id=self.stream_id,
execution_id=execution_id,
error="Execution cancelled",
correlation_id=ctx.correlation_id,
)
# Don't re-raise - we've handled it and saved state
except Exception as e:
+3 -1
View File
@@ -154,7 +154,9 @@ async def handle_check_agent(request: web.Request) -> web.Response:
ensure_credential_key_env()
nodes = load_agent_nodes(agent_path)
result = validate_agent_credentials(nodes, verify=verify, raise_on_error=False, force_refresh=True)
result = validate_agent_credentials(
nodes, verify=verify, raise_on_error=False, force_refresh=True
)
# If any credential needs Aden, include ADEN_API_KEY as a first-class row
if any(c.aden_supported for c in result.credentials):
+19 -3
View File
@@ -113,27 +113,43 @@ async def handle_events(request: web.Request) -> web.StreamResponse:
sse = SSEResponse()
await sse.prepare(request)
logger.info(
"SSE connected: session='%s', sub_id='%s', types=%d", session.id, sub_id, len(event_types)
)
event_count = 0
close_reason = "unknown"
try:
while True:
try:
data = await asyncio.wait_for(queue.get(), timeout=KEEPALIVE_INTERVAL)
await sse.send_event(data)
event_count += 1
if event_count == 1:
logger.info(
"SSE first event: session='%s', type='%s'", session.id, data.get("type")
)
except TimeoutError:
await sse.send_keepalive()
except (ConnectionResetError, ConnectionError):
close_reason = "client_disconnected"
break
except Exception as exc:
logger.debug("SSE stream closed: %s", exc)
close_reason = f"error: {exc}"
break
except asyncio.CancelledError:
pass
close_reason = "cancelled"
finally:
try:
event_bus.unsubscribe(sub_id)
except Exception:
pass
logger.debug("SSE client disconnected from session '%s'", session.id)
logger.info(
"SSE disconnected: session='%s', events_sent=%d, reason='%s'",
session.id,
event_count,
close_reason,
)
return sse.response
+71 -9
View File
@@ -1,5 +1,5 @@
import { memo, useState, useRef, useEffect } from "react";
import { Send, Square, Crown, Cpu } from "lucide-react";
import { Send, Square, Crown, Cpu, Check, ChevronRight, Loader2 } from "lucide-react";
import { formatAgentDisplayName } from "@/lib/chat-helpers";
import MarkdownContent from "@/components/MarkdownContent";
@@ -35,6 +35,75 @@ function getColor(_agent: string, role?: "queen" | "worker"): string {
return "hsl(220,60%,55%)";
}
function ToolActivityRow({ content }: { content: string }) {
const [expanded, setExpanded] = useState(false);
let tools: { name: string; done: boolean }[] = [];
let allDone = false;
try {
const parsed = JSON.parse(content);
tools = parsed.tools || [];
allDone = parsed.allDone ?? false;
} catch {
// Legacy plain-text fallback
return (
<div className="flex gap-3 pl-10">
<span className="text-[11px] text-muted-foreground bg-muted/40 px-3 py-1 rounded-full border border-border/40">
{content}
</span>
</div>
);
}
if (tools.length === 0) return null;
const total = tools.length;
if (allDone && !expanded) {
return (
<div className="flex gap-3 pl-10">
<button
onClick={() => setExpanded(true)}
className="flex items-center gap-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronRight className="w-3 h-3" />
<Check className="w-3 h-3 text-emerald-500" />
<span>{total} tool{total === 1 ? "" : "s"} used</span>
</button>
</div>
);
}
return (
<div className="flex gap-3 pl-10">
<div className="flex flex-wrap items-center gap-1.5">
{allDone && (
<button onClick={() => setExpanded(false)} className="text-muted-foreground hover:text-foreground transition-colors">
<ChevronRight className="w-3 h-3 rotate-90" />
</button>
)}
{tools.map((t, i) => (
<span
key={i}
className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full border ${
t.done
? "text-emerald-600 bg-emerald-500/10 border-emerald-500/20"
: "text-muted-foreground bg-muted/40 border-border/40"
}`}
>
{t.done ? (
<Check className="w-2.5 h-2.5" />
) : (
<Loader2 className="w-2.5 h-2.5 animate-spin" />
)}
{t.name}
</span>
))}
</div>
</div>
);
}
const MessageBubble = memo(function MessageBubble({ msg }: { msg: ChatMessage }) {
const isUser = msg.type === "user";
const isQueen = msg.role === "queen";
@@ -51,13 +120,7 @@ const MessageBubble = memo(function MessageBubble({ msg }: { msg: ChatMessage })
}
if (msg.type === "tool_status") {
return (
<div className="flex gap-3 pl-10">
<span className="text-[11px] text-muted-foreground bg-muted/40 px-3 py-1 rounded-full border border-border/40">
{msg.content}
</span>
</div>
);
return <ToolActivityRow content={msg.content} />;
}
if (isUser) {
@@ -120,7 +183,6 @@ export default function ChatPanel({ messages, onSend, isWaiting, activeThread, a
if (m.type === "system" && !m.thread) return false;
return m.thread === activeThread;
});
console.log('[ChatPanel] render: messages:', messages.length, 'threadMessages:', threadMessages.length, 'activeThread:', activeThread, 'threads:', [...new Set(messages.map(m => m.thread))]);
// Mark current thread as read
useEffect(() => {
+19 -8
View File
@@ -80,28 +80,39 @@ export function useMultiSSE({ sessions, onEvent }: UseMultiSSEOptions) {
const onEventRef = useRef(onEvent);
onEventRef.current = onEvent;
const sourcesRef = useRef(new Map<string, EventSource>());
// Track both the EventSource and its session ID so we can detect session changes
const sourcesRef = useRef(new Map<string, { es: EventSource; sessionId: string }>());
// Diff-based open/close — runs on every `sessions` change
useEffect(() => {
const current = sourcesRef.current;
const desired = new Set(Object.keys(sessions));
// Close connections for sessions no longer in the map
for (const [agentType, es] of current) {
if (!desired.has(agentType)) {
es.close();
// Close connections for removed agents OR changed session IDs
for (const [agentType, entry] of current) {
if (!desired.has(agentType) || sessions[agentType] !== entry.sessionId) {
console.log('[SSE] closing:', agentType, entry.sessionId, desired.has(agentType) ? '(session changed)' : '(removed)');
entry.es.close();
current.delete(agentType);
}
}
// Open connections for newly added sessions
// Open connections for new/changed sessions
for (const [agentType, sessionId] of Object.entries(sessions)) {
if (!sessionId || current.has(agentType)) continue;
const url = `/api/sessions/${sessionId}/events`;
console.log('[SSE] opening:', agentType, sessionId);
const es = new EventSource(url);
es.onopen = () => {
console.log('[SSE] connected:', agentType, sessionId);
};
es.onerror = () => {
console.error('[SSE] error:', agentType, sessionId, 'readyState:', es.readyState);
};
es.onmessage = (e: MessageEvent) => {
try {
const event: AgentEvent = JSON.parse(e.data);
@@ -112,14 +123,14 @@ export function useMultiSSE({ sessions, onEvent }: UseMultiSSEOptions) {
}
};
current.set(agentType, es);
current.set(agentType, { es, sessionId });
}
}, [sessions]);
// Close all on unmount only
useEffect(() => {
return () => {
for (const es of sourcesRef.current.values()) es.close();
for (const entry of sourcesRef.current.values()) entry.es.close();
sourcesRef.current.clear();
};
}, []);
+68 -40
View File
@@ -286,7 +286,7 @@ interface AgentBackendState {
isTyping: boolean;
isStreaming: boolean;
llmSnapshots: Record<string, string>;
toolCallCounts: Record<string, number>;
activeToolCalls: Record<string, { name: string; done: boolean; streamId: string }>;
}
function defaultAgentState(): AgentBackendState {
@@ -307,7 +307,7 @@ function defaultAgentState(): AgentBackendState {
isTyping: false,
isStreaming: false,
llmSnapshots: {},
toolCallCounts: {},
activeToolCalls: {},
};
}
@@ -964,7 +964,7 @@ export default function Workspace() {
currentExecutionId: event.execution_id || agentStates[agentType]?.currentExecutionId || null,
nodeLogs: {},
llmSnapshots: {},
toolCallCounts: {},
activeToolCalls: {},
});
markAllNodesAs(agentType, ["running", "looping", "complete", "error"], "pending");
}
@@ -1054,7 +1054,7 @@ export default function Workspace() {
case "node_loop_started":
turnCounterRef.current[agentType] = currentTurn + 1;
updateAgentState(agentType, { isTyping: true });
updateAgentState(agentType, { isTyping: true, activeToolCalls: {} });
if (!isQueen && event.node_id) {
const sessions = sessionsRef.current[agentType] || [];
const activeId = activeSessionRef.current[agentType] || sessions[0]?.id;
@@ -1070,7 +1070,7 @@ export default function Workspace() {
case "node_loop_iteration":
turnCounterRef.current[agentType] = currentTurn + 1;
updateAgentState(agentType, { isStreaming: false });
updateAgentState(agentType, { isStreaming: false, activeToolCalls: {} });
if (!isQueen && event.node_id) {
const pendingText = agentStates[agentType]?.llmSnapshots[event.node_id];
if (pendingText?.trim()) {
@@ -1117,58 +1117,58 @@ export default function Workspace() {
case "tool_call_started": {
console.log('[TOOL_PILL] tool_call_started received:', { isQueen, nodeId: event.node_id, streamId: event.stream_id, agentType, executionId: event.execution_id, toolName: event.data?.tool_name });
if (!isQueen && event.node_id) {
const pendingText = agentStates[agentType]?.llmSnapshots[event.node_id];
if (pendingText?.trim()) {
appendNodeLog(agentType, event.node_id, `${ts} INFO LLM: ${truncate(pendingText.trim(), 300)}`);
setAgentStates(prev => {
const state = prev[agentType];
if (!state) return prev;
const { [event.node_id!]: _, ...rest } = state.llmSnapshots;
return { ...prev, [agentType]: { ...state, llmSnapshots: rest } };
});
if (event.node_id) {
if (!isQueen) {
const pendingText = agentStates[agentType]?.llmSnapshots[event.node_id];
if (pendingText?.trim()) {
appendNodeLog(agentType, event.node_id, `${ts} INFO LLM: ${truncate(pendingText.trim(), 300)}`);
setAgentStates(prev => {
const state = prev[agentType];
if (!state) return prev;
const { [event.node_id!]: _, ...rest } = state.llmSnapshots;
return { ...prev, [agentType]: { ...state, llmSnapshots: rest } };
});
}
appendNodeLog(agentType, event.node_id, `${ts} INFO Calling ${(event.data?.tool_name as string) || "unknown"}(${event.data?.tool_input ? truncate(JSON.stringify(event.data.tool_input), 200) : ""})`);
}
const toolName = (event.data?.tool_name as string) || "unknown";
const toolInput = event.data?.tool_input;
const argsStr = toolInput ? truncate(JSON.stringify(toolInput), 200) : "";
appendNodeLog(agentType, event.node_id, `${ts} INFO Calling ${toolName}(${argsStr})`);
// Update tool call counts and upsert compact pill into chat
let pillContent = "";
const toolName = (event.data?.tool_name as string) || "unknown";
const toolUseId = (event.data?.tool_use_id as string) || "";
// Track active (in-flight) tools and upsert activity row into chat
const sid = event.stream_id;
setAgentStates(prev => {
const state = prev[agentType];
if (!state) return prev;
const newCounts = { ...state.toolCallCounts, [toolName]: (state.toolCallCounts[toolName] || 0) + 1 };
pillContent = Object.entries(newCounts).map(([n, c]) => `${c} ${n}`).join(", ");
return {
...prev,
[agentType]: { ...state, isStreaming: false, toolCallCounts: newCounts },
};
});
console.log('[TOOL_PILL] pillContent:', pillContent, 'agentType:', agentType);
if (pillContent) {
const pillMsg: ChatMessage = {
id: `tool-pill-${event.execution_id || "exec"}`,
const newActive = { ...state.activeToolCalls, [toolUseId]: { name: toolName, done: false, streamId: sid } };
// Only include tools from this stream in the pill
const tools = Object.values(newActive).filter(t => t.streamId === sid).map(t => ({ name: t.name, done: t.done }));
const allDone = tools.length > 0 && tools.every(t => t.done);
upsertChatMessage(agentType, {
id: `tool-pill-${sid}-${event.execution_id || "exec"}-${currentTurn}`,
agent: agentDisplayName || event.node_id || "Agent",
agentColor: "",
content: pillContent,
content: JSON.stringify({ tools, allDone }),
timestamp: "",
type: "tool_status",
role: "worker",
role,
thread: agentType,
});
return {
...prev,
[agentType]: { ...state, isStreaming: false, activeToolCalls: newActive },
};
console.log('[TOOL_PILL] upserting:', pillMsg);
upsertChatMessage(agentType, pillMsg);
}
});
} else {
console.log('[TOOL_PILL] SKIPPED: isQueen=', isQueen, 'node_id=', event.node_id);
console.log('[TOOL_PILL] SKIPPED: no node_id', event.node_id);
}
break;
}
case "tool_call_completed":
if (!isQueen && event.node_id) {
case "tool_call_completed": {
if (event.node_id) {
const toolName = (event.data?.tool_name as string) || "unknown";
const toolUseId = (event.data?.tool_use_id as string) || "";
const isError = event.data?.is_error as boolean | undefined;
const result = event.data?.result as string | undefined;
if (isError) {
@@ -1177,8 +1177,36 @@ export default function Workspace() {
const resultStr = result ? ` (${truncate(result, 200)})` : "";
appendNodeLog(agentType, event.node_id, `${ts} INFO ${toolName} done${resultStr}`);
}
// Mark tool as done and update activity row
const sid = event.stream_id;
setAgentStates(prev => {
const state = prev[agentType];
if (!state) return prev;
const updated = { ...state.activeToolCalls };
if (updated[toolUseId]) {
updated[toolUseId] = { ...updated[toolUseId], done: true };
}
const tools = Object.values(updated).filter(t => t.streamId === sid).map(t => ({ name: t.name, done: t.done }));
const allDone = tools.length > 0 && tools.every(t => t.done);
upsertChatMessage(agentType, {
id: `tool-pill-${sid}-${event.execution_id || "exec"}-${currentTurn}`,
agent: agentDisplayName || event.node_id || "Agent",
agentColor: "",
content: JSON.stringify({ tools, allDone }),
timestamp: "",
type: "tool_status",
role,
thread: agentType,
});
return {
...prev,
[agentType]: { ...state, activeToolCalls: updated },
};
});
}
break;
}
case "node_internal_output":
if (!isQueen && event.node_id) {