Compare commits

...

8 Commits

Author SHA1 Message Date
RichardTang-Aden 505e1e30fd Merge branch 'main' into fix/session-resume-new-agent 2026-03-13 20:19:36 -07:00
Timothy 3fb2b285fb chore: add star history widget 2026-03-13 20:17:35 -07:00
RichardTang-Aden a76109840c Merge pull request #6345 from aden-hive/feat/gcu-updates
feat: GCU browser cleanup, draft loading state, and inner_turn message fix
2026-03-13 20:16:38 -07:00
RichardTang-Aden 39212350ba Merge pull request #6342 from aden-hive/ci/level-2-dummy-agent-testing
Add Level 2 dummy agent end-to-end tests
2026-03-13 19:42:34 -07:00
bryan 7ede3ba171 feat: queen upsert fix 2026-03-13 19:34:26 -07:00
bryan 635d2976f4 feat: show loading spinner in draft panel during planning phase 2026-03-13 16:40:33 -07:00
bryan 4e1525880d feat: clean up browser profile after top-level GCU node execution 2026-03-13 16:40:20 -07:00
Richard Tang 20427e213a fix: update meta.json when loaded worker 2026-03-13 13:52:15 -07:00
8 changed files with 199 additions and 32 deletions
+10
View File
@@ -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">
+12
View File
@@ -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(
+30 -1
View File
@@ -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(
+7 -4
View File
@@ -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(
+31 -22
View File
@@ -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",
+10 -2
View File
@@ -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,
+3 -3
View File
@@ -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}