Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 505e1e30fd | |||
| 3fb2b285fb | |||
| a76109840c | |||
| 39212350ba | |||
| 7ede3ba171 | |||
| 635d2976f4 | |||
| 4e1525880d | |||
| 20427e213a |
@@ -420,6 +420,16 @@ Visit [docs.adenhq.com](https://docs.adenhq.com/) for complete guides, API refer
|
||||
|
||||
Contributions are welcome! Fork the repository, create your feature branch, implement your changes, and submit a pull request. See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://star-history.com/#aden-hive/hive&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=aden-hive/hive&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=aden-hive/hive&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=aden-hive/hive&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -1920,6 +1920,11 @@ class EventLoopNode(NodeProtocol):
|
||||
# Accumulate ALL tool calls across inner iterations for L3 logging.
|
||||
# Unlike real_tool_results (reset each inner iteration), this persists.
|
||||
logged_tool_calls: list[dict] = []
|
||||
# Counter for LLM calls within a single iteration. Each pass through
|
||||
# the inner tool loop starts a fresh LLM stream whose snapshot resets
|
||||
# to "". Without this, all calls share the same message ID on the
|
||||
# frontend and the second call's text silently replaces the first.
|
||||
inner_turn = 0
|
||||
|
||||
# Inner tool loop: stream may produce tool calls requiring re-invocation
|
||||
while True:
|
||||
@@ -1960,6 +1965,7 @@ class EventLoopNode(NodeProtocol):
|
||||
async def _do_stream(
|
||||
_msgs: list = messages, # noqa: B006
|
||||
_tc: list[ToolCallEvent] = tool_calls, # noqa: B006
|
||||
inner_turn: int = inner_turn,
|
||||
) -> None:
|
||||
nonlocal accumulated_text, _stream_error
|
||||
async for event in ctx.llm.stream(
|
||||
@@ -1978,6 +1984,7 @@ class EventLoopNode(NodeProtocol):
|
||||
ctx,
|
||||
execution_id,
|
||||
iteration=iteration,
|
||||
inner_turn=inner_turn,
|
||||
)
|
||||
|
||||
elif isinstance(event, ToolCallEvent):
|
||||
@@ -2206,6 +2213,7 @@ class EventLoopNode(NodeProtocol):
|
||||
ctx=ctx,
|
||||
execution_id=execution_id,
|
||||
iteration=iteration,
|
||||
inner_turn=inner_turn,
|
||||
)
|
||||
|
||||
result = ToolResult(
|
||||
@@ -2659,6 +2667,7 @@ class EventLoopNode(NodeProtocol):
|
||||
)
|
||||
|
||||
# Tool calls processed -- loop back to stream with updated conversation
|
||||
inner_turn += 1
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Synthetic tools: set_output, ask_user, escalate
|
||||
@@ -4344,6 +4353,7 @@ class EventLoopNode(NodeProtocol):
|
||||
ctx: NodeContext,
|
||||
execution_id: str = "",
|
||||
iteration: int | None = None,
|
||||
inner_turn: int = 0,
|
||||
) -> None:
|
||||
if self._event_bus:
|
||||
if ctx.node_spec.client_facing:
|
||||
@@ -4354,6 +4364,7 @@ class EventLoopNode(NodeProtocol):
|
||||
snapshot=snapshot,
|
||||
execution_id=execution_id,
|
||||
iteration=iteration,
|
||||
inner_turn=inner_turn,
|
||||
)
|
||||
else:
|
||||
await self._event_bus.emit_llm_text_delta(
|
||||
@@ -4362,6 +4373,7 @@ class EventLoopNode(NodeProtocol):
|
||||
content=content,
|
||||
snapshot=snapshot,
|
||||
execution_id=execution_id,
|
||||
inner_turn=inner_turn,
|
||||
)
|
||||
|
||||
async def _publish_tool_started(
|
||||
|
||||
@@ -27,12 +27,14 @@ from framework.graph.node import (
|
||||
SharedMemory,
|
||||
)
|
||||
from framework.graph.validator import OutputValidator
|
||||
from framework.llm.provider import LLMProvider, Tool
|
||||
from framework.llm.provider import LLMProvider, Tool, ToolUse
|
||||
from framework.observability import set_trace_context
|
||||
from framework.runtime.core import Runtime
|
||||
from framework.schemas.checkpoint import Checkpoint
|
||||
from framework.storage.checkpoint_store import CheckpointStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _default_max_context_tokens() -> int:
|
||||
"""Resolve max_context_tokens from global config, falling back to 32000."""
|
||||
@@ -937,6 +939,33 @@ class GraphExecutor:
|
||||
self.logger.info(" Executing...")
|
||||
result = await node_impl.execute(ctx)
|
||||
|
||||
# GCU tab cleanup: stop the browser profile after a top-level GCU node
|
||||
# finishes so tabs don't accumulate. Mirrors the subagent cleanup in
|
||||
# EventLoopNode._execute_subagent().
|
||||
if node_spec.node_type == "gcu" and self.tool_executor is not None:
|
||||
try:
|
||||
from gcu.browser.session import (
|
||||
_active_profile as _gcu_profile_var,
|
||||
)
|
||||
|
||||
_gcu_profile = _gcu_profile_var.get()
|
||||
_stop_use = ToolUse(
|
||||
id="gcu-cleanup",
|
||||
name="browser_stop",
|
||||
input={"profile": _gcu_profile},
|
||||
)
|
||||
_stop_result = self.tool_executor(_stop_use)
|
||||
if asyncio.iscoroutine(_stop_result) or asyncio.isfuture(_stop_result):
|
||||
await _stop_result
|
||||
except ImportError:
|
||||
pass # GCU not installed
|
||||
except Exception as _gcu_exc:
|
||||
logger.warning(
|
||||
"GCU browser_stop failed for profile %r: %s",
|
||||
_gcu_profile,
|
||||
_gcu_exc,
|
||||
)
|
||||
|
||||
# Emit node-completed event (skip event_loop nodes)
|
||||
if self._event_bus and node_spec.node_type != "event_loop":
|
||||
await self._event_bus.emit_node_loop_completed(
|
||||
|
||||
@@ -262,7 +262,7 @@ class EventBus:
|
||||
self._session_log: IO[str] | None = None
|
||||
self._session_log_iteration_offset: int = 0
|
||||
# Accumulator for client_output_delta snapshots — flushed on llm_turn_complete.
|
||||
# Key: (stream_id, node_id, execution_id, iteration) → latest AgentEvent
|
||||
# Key: (stream_id, node_id, execution_id, iteration, inner_turn) → latest AgentEvent
|
||||
self._pending_output_snapshots: dict[tuple, AgentEvent] = {}
|
||||
|
||||
def set_session_log(self, path: Path, *, iteration_offset: int = 0) -> None:
|
||||
@@ -328,6 +328,7 @@ class EventBus:
|
||||
event.node_id,
|
||||
event.execution_id,
|
||||
event.data.get("iteration"),
|
||||
event.data.get("inner_turn", 0),
|
||||
)
|
||||
self._pending_output_snapshots[key] = event
|
||||
return
|
||||
@@ -361,7 +362,7 @@ class EventBus:
|
||||
to_flush: list[tuple] = []
|
||||
for key, _evt in self._pending_output_snapshots.items():
|
||||
if stream_id is not None:
|
||||
k_stream, k_node, k_exec, _ = key
|
||||
k_stream, k_node, k_exec, _, _ = key
|
||||
if k_stream != stream_id or k_node != node_id or k_exec != execution_id:
|
||||
continue
|
||||
to_flush.append(key)
|
||||
@@ -749,6 +750,7 @@ class EventBus:
|
||||
content: str,
|
||||
snapshot: str,
|
||||
execution_id: str | None = None,
|
||||
inner_turn: int = 0,
|
||||
) -> None:
|
||||
"""Emit LLM text delta event."""
|
||||
await self.publish(
|
||||
@@ -757,7 +759,7 @@ class EventBus:
|
||||
stream_id=stream_id,
|
||||
node_id=node_id,
|
||||
execution_id=execution_id,
|
||||
data={"content": content, "snapshot": snapshot},
|
||||
data={"content": content, "snapshot": snapshot, "inner_turn": inner_turn},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -873,9 +875,10 @@ class EventBus:
|
||||
snapshot: str,
|
||||
execution_id: str | None = None,
|
||||
iteration: int | None = None,
|
||||
inner_turn: int = 0,
|
||||
) -> None:
|
||||
"""Emit client output delta event (client_facing=True nodes)."""
|
||||
data: dict = {"content": content, "snapshot": snapshot}
|
||||
data: dict = {"content": content, "snapshot": snapshot, "inner_turn": inner_turn}
|
||||
if iteration is not None:
|
||||
data["iteration"] = iteration
|
||||
await self.publish(
|
||||
|
||||
@@ -73,7 +73,7 @@ function useDraftChromeColors() {
|
||||
type DraftNodeStatus = "pending" | "running" | "complete" | "error";
|
||||
|
||||
interface DraftGraphProps {
|
||||
draft: DraftGraphData;
|
||||
draft: DraftGraphData | null;
|
||||
onNodeClick?: (node: DraftNode) => void;
|
||||
/** Runtime node ID → list of original draft node IDs (post-dissolution mapping). */
|
||||
flowchartMap?: Record<string, string[]>;
|
||||
@@ -83,6 +83,8 @@ interface DraftGraphProps {
|
||||
onRuntimeNodeClick?: (runtimeNodeId: string) => void;
|
||||
/** True while the queen is building the agent from the draft. */
|
||||
building?: boolean;
|
||||
/** True while the queen is designing the draft (no draft yet). Shows a spinner. */
|
||||
loading?: boolean;
|
||||
/** Called when the user clicks Run. */
|
||||
onRun?: () => void;
|
||||
/** Called when the user clicks Pause. */
|
||||
@@ -355,7 +357,7 @@ function Tooltip({ node, style }: { node: DraftNode; style: React.CSSProperties
|
||||
);
|
||||
}
|
||||
|
||||
export default function DraftGraph({ draft, onNodeClick, flowchartMap, runtimeNodes, onRuntimeNodeClick, building, onRun, onPause, runState = "idle" }: DraftGraphProps) {
|
||||
export default function DraftGraph({ draft, onNodeClick, flowchartMap, runtimeNodes, onRuntimeNodeClick, building, loading, onRun, onPause, runState = "idle" }: DraftGraphProps) {
|
||||
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
|
||||
const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -463,7 +465,8 @@ export default function DraftGraph({ draft, onNodeClick, flowchartMap, runtimeNo
|
||||
|
||||
const hasStatusOverlay = Object.keys(nodeStatuses).length > 0;
|
||||
|
||||
const { nodes, edges } = draft;
|
||||
const nodes = draft?.nodes ?? [];
|
||||
const edges = draft?.edges ?? [];
|
||||
|
||||
const idxMap = useMemo(
|
||||
() => Object.fromEntries(nodes.map((n, i) => [n.id, i])),
|
||||
@@ -656,25 +659,6 @@ export default function DraftGraph({ draft, onNodeClick, flowchartMap, runtimeNo
|
||||
return { layers, nodeW, firstColX, nodeXPositions, backEdgeOverflow, maxContentRight };
|
||||
}, [nodes, forwardEdges, backEdges.length, containerW, flowchartMap, idxMap]);
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-4 pt-4 pb-2">
|
||||
<p className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider">
|
||||
Draft
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center px-4">
|
||||
<p className="text-xs text-muted-foreground/60 text-center italic">
|
||||
No draft graph yet.
|
||||
<br />
|
||||
Describe your workflow to get started.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { layers, nodeW, nodeXPositions, backEdgeOverflow, maxContentRight } = layout;
|
||||
|
||||
const maxLayer = nodes.length > 0 ? Math.max(...layers) : 0;
|
||||
@@ -982,6 +966,31 @@ export default function DraftGraph({ draft, onNodeClick, flowchartMap, runtimeNo
|
||||
);
|
||||
};
|
||||
|
||||
if (loading || !draft || nodes.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-4 pt-3 pb-1.5 flex items-center gap-2">
|
||||
<p className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider">Draft</p>
|
||||
<span className="text-[9px] font-mono font-medium rounded px-1 py-0.5 leading-none border text-amber-500/60 border-amber-500/20">planning</span>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3">
|
||||
{loading || !draft ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground/40" />
|
||||
<p className="text-xs text-muted-foreground/50">Designing flowchart…</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground/60 text-center italic">
|
||||
No draft graph yet.
|
||||
<br />
|
||||
Describe your workflow to get started.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
|
||||
@@ -196,6 +196,102 @@ describe("sseEventToChatMessage", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("different inner_turn values produce different message IDs", () => {
|
||||
const e1 = makeEvent({
|
||||
type: "client_output_delta",
|
||||
node_id: "queen",
|
||||
execution_id: "exec-1",
|
||||
data: { snapshot: "first response", iteration: 0, inner_turn: 0 },
|
||||
});
|
||||
const e2 = makeEvent({
|
||||
type: "client_output_delta",
|
||||
node_id: "queen",
|
||||
execution_id: "exec-1",
|
||||
data: { snapshot: "after tool call", iteration: 0, inner_turn: 1 },
|
||||
});
|
||||
const r1 = sseEventToChatMessage(e1, "t");
|
||||
const r2 = sseEventToChatMessage(e2, "t");
|
||||
expect(r1!.id).not.toBe(r2!.id);
|
||||
});
|
||||
|
||||
it("same inner_turn produces same ID (streaming upsert within one LLM call)", () => {
|
||||
const e1 = makeEvent({
|
||||
type: "client_output_delta",
|
||||
node_id: "queen",
|
||||
execution_id: "exec-1",
|
||||
data: { snapshot: "partial", iteration: 0, inner_turn: 1 },
|
||||
});
|
||||
const e2 = makeEvent({
|
||||
type: "client_output_delta",
|
||||
node_id: "queen",
|
||||
execution_id: "exec-1",
|
||||
data: { snapshot: "partial response", iteration: 0, inner_turn: 1 },
|
||||
});
|
||||
expect(sseEventToChatMessage(e1, "t")!.id).toBe(
|
||||
sseEventToChatMessage(e2, "t")!.id,
|
||||
);
|
||||
});
|
||||
|
||||
it("absent inner_turn produces same ID as inner_turn=0 (backward compat)", () => {
|
||||
const withField = makeEvent({
|
||||
type: "client_output_delta",
|
||||
node_id: "queen",
|
||||
execution_id: "exec-1",
|
||||
data: { snapshot: "hello", iteration: 2, inner_turn: 0 },
|
||||
});
|
||||
const withoutField = makeEvent({
|
||||
type: "client_output_delta",
|
||||
node_id: "queen",
|
||||
execution_id: "exec-1",
|
||||
data: { snapshot: "hello", iteration: 2 },
|
||||
});
|
||||
expect(sseEventToChatMessage(withField, "t")!.id).toBe(
|
||||
sseEventToChatMessage(withoutField, "t")!.id,
|
||||
);
|
||||
});
|
||||
|
||||
it("inner_turn=0 produces no suffix (matches old ID format)", () => {
|
||||
const event = makeEvent({
|
||||
type: "client_output_delta",
|
||||
node_id: "queen",
|
||||
execution_id: "exec-1",
|
||||
data: { snapshot: "hello", iteration: 3, inner_turn: 0 },
|
||||
});
|
||||
const result = sseEventToChatMessage(event, "t");
|
||||
expect(result!.id).toBe("stream-exec-1-3-queen");
|
||||
});
|
||||
|
||||
it("inner_turn>0 adds -t suffix to ID", () => {
|
||||
const event = makeEvent({
|
||||
type: "client_output_delta",
|
||||
node_id: "queen",
|
||||
execution_id: "exec-1",
|
||||
data: { snapshot: "hello", iteration: 3, inner_turn: 2 },
|
||||
});
|
||||
const result = sseEventToChatMessage(event, "t");
|
||||
expect(result!.id).toBe("stream-exec-1-3-t2-queen");
|
||||
});
|
||||
|
||||
it("llm_text_delta also uses inner_turn for distinct IDs", () => {
|
||||
const e1 = makeEvent({
|
||||
type: "llm_text_delta",
|
||||
node_id: "research",
|
||||
execution_id: "exec-1",
|
||||
data: { snapshot: "first", inner_turn: 0 },
|
||||
});
|
||||
const e2 = makeEvent({
|
||||
type: "llm_text_delta",
|
||||
node_id: "research",
|
||||
execution_id: "exec-1",
|
||||
data: { snapshot: "second", inner_turn: 1 },
|
||||
});
|
||||
const r1 = sseEventToChatMessage(e1, "t");
|
||||
const r2 = sseEventToChatMessage(e2, "t");
|
||||
expect(r1!.id).not.toBe(r2!.id);
|
||||
expect(r1!.id).toBe("stream-exec-1-research");
|
||||
expect(r2!.id).toBe("stream-exec-1-t1-research");
|
||||
});
|
||||
|
||||
it("uses timestamp fallback when both turnId and execution_id are null", () => {
|
||||
const event = makeEvent({
|
||||
type: "client_output_delta",
|
||||
|
||||
@@ -56,10 +56,15 @@ export function sseEventToChatMessage(
|
||||
const iterTid = iter != null ? String(iter) : tid;
|
||||
const iterIdKey = eid && iterTid ? `${eid}-${iterTid}` : eid || iterTid || `t-${Date.now()}`;
|
||||
|
||||
// Distinguish multiple LLM calls within the same iteration (inner tool loop).
|
||||
// inner_turn=0 (or absent) produces no suffix for backward compat.
|
||||
const innerTurn = event.data?.inner_turn as number | undefined;
|
||||
const innerSuffix = innerTurn != null && innerTurn > 0 ? `-t${innerTurn}` : "";
|
||||
|
||||
const snapshot = (event.data?.snapshot as string) || (event.data?.content as string) || "";
|
||||
if (!snapshot) return null;
|
||||
return {
|
||||
id: `stream-${iterIdKey}-${event.node_id}`,
|
||||
id: `stream-${iterIdKey}${innerSuffix}-${event.node_id}`,
|
||||
agent: agentDisplayName || event.node_id || "Agent",
|
||||
agentColor: "",
|
||||
content: snapshot,
|
||||
@@ -91,10 +96,13 @@ export function sseEventToChatMessage(
|
||||
}
|
||||
|
||||
case "llm_text_delta": {
|
||||
const llmInnerTurn = event.data?.inner_turn as number | undefined;
|
||||
const llmInnerSuffix = llmInnerTurn != null && llmInnerTurn > 0 ? `-t${llmInnerTurn}` : "";
|
||||
|
||||
const snapshot = (event.data?.snapshot as string) || (event.data?.content as string) || "";
|
||||
if (!snapshot) return null;
|
||||
return {
|
||||
id: `stream-${idKey}-${event.node_id}`,
|
||||
id: `stream-${idKey}${llmInnerSuffix}-${event.node_id}`,
|
||||
agent: event.node_id || "Agent",
|
||||
agentColor: "",
|
||||
content: snapshot,
|
||||
|
||||
@@ -2813,10 +2813,10 @@ export default function Workspace() {
|
||||
<div className="flex flex-1 min-h-0">
|
||||
|
||||
{/* ── Pipeline graph + chat ──────────────────────────────────── */}
|
||||
<div className={`${((activeAgentState?.queenPhase === "planning" || activeAgentState?.queenPhase === "building") && activeAgentState?.draftGraph) || activeAgentState?.originalDraft ? "w-[500px] min-w-[400px]" : "w-[300px] min-w-[240px]"} bg-card/30 flex flex-col border-r border-border/30 transition-[width] duration-200`}>
|
||||
<div className={`${activeAgentState?.queenPhase === "planning" || activeAgentState?.queenPhase === "building" || activeAgentState?.originalDraft ? "w-[500px] min-w-[400px]" : "w-[300px] min-w-[240px]"} bg-card/30 flex flex-col border-r border-border/30 transition-[width] duration-200`}>
|
||||
<div className="flex-1 min-h-0">
|
||||
{(activeAgentState?.queenPhase === "planning" || activeAgentState?.queenPhase === "building") && activeAgentState?.draftGraph ? (
|
||||
<DraftGraph draft={activeAgentState.draftGraph} building={activeAgentState?.queenBuilding} onRun={handleRun} onPause={handlePause} runState={activeAgentState?.workerRunState ?? "idle"} />
|
||||
{activeAgentState?.queenPhase === "planning" || activeAgentState?.queenPhase === "building" ? (
|
||||
<DraftGraph draft={activeAgentState?.draftGraph ?? null} loading={!activeAgentState?.draftGraph} building={activeAgentState?.queenBuilding} onRun={handleRun} onPause={handlePause} runState={activeAgentState?.workerRunState ?? "idle"} />
|
||||
) : activeAgentState?.originalDraft ? (
|
||||
<DraftGraph
|
||||
draft={activeAgentState.originalDraft}
|
||||
|
||||
Reference in New Issue
Block a user