Merge pull request #6282 from aden-hive/feat/refactor-session
Release / Create Release (push) Waiting to run

Refactor session lifecycle with flowchart planning and triggers
This commit is contained in:
RichardTang-Aden
2026-03-12 21:15:10 -07:00
committed by GitHub
76 changed files with 8063 additions and 1854 deletions
+10 -10
View File
@@ -5,20 +5,20 @@ help: ## Show this help
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'
lint: ## Run ruff linter and formatter (with auto-fix)
cd core && ruff check --fix .
cd tools && ruff check --fix .
cd core && ruff format .
cd tools && ruff format .
cd core && uv run ruff check --fix .
cd tools && uv run ruff check --fix .
cd core && uv run ruff format .
cd tools && uv run ruff format .
format: ## Run ruff formatter
cd core && ruff format .
cd tools && ruff format .
cd core && uv run ruff format .
cd tools && uv run ruff format .
check: ## Run all checks without modifying files (CI-safe)
cd core && ruff check .
cd tools && ruff check .
cd core && ruff format --check .
cd tools && ruff format --check .
cd core && uv run ruff check .
cd tools && uv run ruff check .
cd core && uv run ruff format --check .
cd tools && uv run ruff format --check .
test: ## Run all tests (core + tools, excludes live)
cd core && uv run python -m pytest tests/ -v
+1 -1
View File
@@ -601,7 +601,7 @@ async def handle_ws(websocket):
)
node = EventLoopNode(
event_bus=bus,
config=LoopConfig(max_iterations=10_000, max_history_tokens=32_000),
config=LoopConfig(max_iterations=10_000, max_context_tokens=32_000),
conversation_store=STORE,
tool_executor=tool_executor,
)
+1 -1
View File
@@ -1769,7 +1769,7 @@ async def _run_pipeline(websocket, initial_message: str):
config=LoopConfig(
max_iterations=30,
max_tool_calls_per_turn=30,
max_history_tokens=64000,
max_context_tokens=64000,
max_tool_result_chars=8_000,
spillover_dir=str(_DATA_DIR),
),
+2 -2
View File
@@ -752,7 +752,7 @@ async def _run_pipeline(websocket, topic: str):
config=LoopConfig(
max_iterations=20,
max_tool_calls_per_turn=30,
max_history_tokens=32_000,
max_context_tokens=32_000,
),
conversation_store=store_a,
tool_executor=tool_executor,
@@ -850,7 +850,7 @@ async def _run_pipeline(websocket, topic: str):
config=LoopConfig(
max_iterations=10,
max_tool_calls_per_turn=30,
max_history_tokens=32_000,
max_context_tokens=32_000,
),
conversation_store=store_b,
)
+1 -1
View File
@@ -1258,7 +1258,7 @@ async def _run_org_pipeline(websocket, topic: str):
config=LoopConfig(
max_iterations=30,
max_tool_calls_per_turn=30,
max_history_tokens=32_000,
max_context_tokens=32_000,
),
conversation_store=store,
tool_executor=executor,
@@ -1,8 +1,6 @@
"""CLI entry point for Credential Tester agent."""
import asyncio
import logging
import sys
import click
@@ -10,13 +8,14 @@ from .agent import CredentialTesterAgent
def setup_logging(verbose=False, debug=False):
from framework.observability import configure_logging
if debug:
level, fmt = logging.DEBUG, "%(asctime)s %(name)s: %(message)s"
configure_logging(level="DEBUG")
elif verbose:
level, fmt = logging.INFO, "%(message)s"
configure_logging(level="INFO")
else:
level, fmt = logging.WARNING, "%(levelname)s: %(message)s"
logging.basicConfig(level=level, format=fmt, stream=sys.stderr)
configure_logging(level="WARNING")
def pick_account(agent: CredentialTesterAgent) -> dict | None:
@@ -19,6 +19,7 @@ from __future__ import annotations
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
@@ -455,7 +456,6 @@ identity_prompt = (
loop_config = {
"max_iterations": 50,
"max_tool_calls_per_turn": 30,
"max_history_tokens": 32000,
}
# ---------------------------------------------------------------------------
@@ -541,7 +541,7 @@ class CredentialTesterAgent:
loop_config={
"max_iterations": 50,
"max_tool_calls_per_turn": 30,
"max_history_tokens": 32000,
"max_context_tokens": get_max_context_tokens(),
},
conversation_mode="continuous",
identity_prompt=(
+27
View File
@@ -16,6 +16,7 @@ class AgentEntry:
description: str
category: str
session_count: int = 0
run_count: int = 0
node_count: int = 0
tool_count: int = 0
tags: list[str] = field(default_factory=list)
@@ -52,6 +53,31 @@ def _count_sessions(agent_name: str) -> int:
return sum(1 for d in sessions_dir.iterdir() if d.is_dir() and d.name.startswith("session_"))
def _count_runs(agent_name: str) -> int:
"""Count unique run_ids across all sessions for an agent."""
sessions_dir = Path.home() / ".hive" / "agents" / agent_name / "sessions"
if not sessions_dir.exists():
return 0
run_ids: set[str] = set()
for session_dir in sessions_dir.iterdir():
if not session_dir.is_dir() or not session_dir.name.startswith("session_"):
continue
# runs.jsonl lives inside workspace subdirectories
for runs_file in session_dir.rglob("runs.jsonl"):
try:
for line in runs_file.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
record = json.loads(line)
rid = record.get("run_id")
if rid:
run_ids.add(rid)
except Exception:
continue
return len(run_ids)
def _extract_agent_stats(agent_path: Path) -> tuple[int, int, list[str]]:
"""Extract node count, tool count, and tags from an agent directory.
@@ -139,6 +165,7 @@ def discover_agents() -> dict[str, list[AgentEntry]]:
description=desc,
category=category,
session_count=_count_sessions(path.name),
run_count=_count_runs(path.name),
node_count=node_count,
tool_count=tool_count,
tags=tags,
+1 -3
View File
@@ -14,8 +14,7 @@ queen_goal = Goal(
id="queen-manager",
name="Queen Manager",
description=(
"Manage the worker agent lifecycle and serve as the user's primary "
"interactive interface. Triage health escalations from the judge."
"Manage the worker agent lifecycle and serve as the user's primary interactive interface."
),
success_criteria=[],
constraints=[],
@@ -35,6 +34,5 @@ queen_graph = GraphSpec(
loop_config={
"max_iterations": 999_999,
"max_tool_calls_per_turn": 30,
"max_history_tokens": 32000,
},
)
+399 -189
View File
@@ -77,6 +77,10 @@ _QUEEN_PLANNING_TOOLS = [
"list_agent_sessions",
"list_agent_checkpoints",
"get_agent_checkpoint",
# Draft graph (visual-only, no code) — new planning workflow
"save_agent_draft",
"confirm_and_build",
# Scaffold + transition to building (requires confirm_and_build first)
"initialize_and_build_agent",
# Load existing agent (after user confirms)
"load_built_agent",
@@ -87,6 +91,7 @@ _QUEEN_BUILDING_TOOLS = _SHARED_TOOLS + [
"load_built_agent",
"list_credentials",
"replan_agent",
"save_agent_draft", # Re-draft during building → auto-dissolves + updates flowchart
"write_to_diary", # Episodic memory — available in all phases
]
@@ -105,6 +110,10 @@ _QUEEN_STAGING_TOOLS = [
"stop_worker_and_edit",
"stop_worker_and_plan",
"write_to_diary", # Episodic memory — available in all phases
# Trigger management
"set_trigger",
"remove_trigger",
"list_triggers",
]
# Running phase: worker is executing — monitor and control.
@@ -121,11 +130,16 @@ _QUEEN_RUNNING_TOOLS = [
"stop_worker_and_edit",
"stop_worker_and_plan",
"get_worker_status",
"run_agent_with_input",
"inject_worker_message",
# Monitoring
"get_worker_health_summary",
"notify_operator",
"write_to_diary", # Episodic memory — available in all phases
# Trigger management
"set_trigger",
"remove_trigger",
"list_triggers",
]
@@ -168,12 +182,8 @@ search_files, or list_directory — those are YOUR tools, not theirs.
)
_planning_knowledge = """\
**A responsible engineer doesn't jump into building. First, \
understand the problem and be transparent about what the framework can and cannot do.**
Use the user's selection (or their custom description if they chose "Other") \
as context when shaping the goal below. If the user already described \
what they want before this step, skip the question and proceed directly.
**Be responsible, understand the problem by asking practical qualify questions \
and be transparent about what the framework can and cannot do.**
# Core Mandates (Planning)
- **DO NOT propose a complete goal on your own.** Instead, \
@@ -185,45 +195,33 @@ docs. Always run list_agent_tools() to see what actually exists.
# Tool Discovery (MANDATORY before designing)
Before designing any agent, run list_agent_tools() with NO arguments \
to see ALL available tools (names + descriptions, grouped by category). \
ONLY use tools from this list in your node definitions. \
Before designing any agent, discover tools progressively start compact, drill into \
what you need. ONLY use tools from this list in your node definitions. \
NEVER guess or fabricate tool names from memory.
list_agent_tools() # ALWAYS call this first (simple mode)
list_agent_tools(group="google", output_schema="full") # drill into a provider
list_agent_tools() # Step 1: provider summary
list_agent_tools(group="google", output_schema="summary") # Step 2: service breakdown
list_agent_tools(group="google", service="gmail") # Step 3: tool names
list_agent_tools( # Step 4: full detail
group="google", service="gmail", output_schema="full"
)
NEVER skip the first call. Always start with the full list \
so you know what providers and tools exist before drilling in. \
Simple mode truncates long descriptions use group + "full" to \
get the complete description and input_schema for the tools you need.
Step 1 is MANDATORY. Returns provider names, tool counts, credential availability very compact. \
Step 2 breaks a provider into services (e.g. google gmail/calendar/sheets/drive). Only do this \
for providers that are relevant to the task. \
Step 3 gets tool names for a specific service no descriptions, minimal tokens. \
Step 4 only for services you plan to actually use. \
Use credentials="available" at any step to filter to tools whose credentials are already configured.
# Discovery & Design Workflow
## 1: Fast Discovery (3-6 Turns)
## 1: Discovery (3-6 Turns)
**The core principle**: Discovery should feel like progress, not paperwork. \
The stakeholder should walk away feeling like you understood them faster \
than anyone else would have.
**Communication sytle**: Be concise. Say less. Mean more. Impatient stakeholders \
don't want a wall of text — they want to know you get it. Every sentence you say \
should either move the conversation forward or prove you understood something. \
If it does neither, cut it.
**Ask Question Rules: Respect Their Time.** Every question must earn its place by:
1. **Preventing a costly wrong turn** you're about to build the wrong thing
2. **Unlocking a shortcut** their answer lets you simplify the design
3. **Surfacing a dealbreaker** there's a constraint that changes everything
4. **Provide Options** - Provide options to your questions if possible, \
but also always allow the user to type something beyong the options.
If a question doesn't do one of these, don't ask it. Make an assumption, state it, and move on.
---
### 1.1: Let Them Talk, But Listen Like an Solution Architect
Ask questions to help the user find bridge the goal and the solution \
When the stakeholder describes what they want, mentally construct:
- **The pain**: What about today's situation is broken, slow, or missing?
@@ -234,57 +232,6 @@ When the stakeholder describes what they want, mentally construct:
---
### 1.2: Use Domain Knowledge to Fill In the Blanks
You have broad knowledge of how systems work. Use it aggressively.
If they say "I need a research agent," you already know it probably involves: \
search, summarization, source tracking, and iteration. Don't ask about each — \
use them as your starting mental model and let their specifics override your defaults.
If they say "I need to monitor files and alert me," you know this probably involves: \
watch patterns, triggers, notifications, and state tracking.
---
### 1.3: Play Back a Proposed Model (Not a List of Questions)
After listening, present a **concrete picture** of what you think they need. \
Make it specific enough that they can spot what's wrong. \
Can you ASCII to show the user
**Pattern: "Here's what I heard — tell me where I'm off"**
> "OK here's how I'm picturing this: [User type] needs to [core action]. \
Right now they're [current painful workflow]. \
What you want is [proposed solution that replaces the pain].
> The way I'd structure this: [key entities] connected by [key relationships], \
with the main flow being [trigger steps outcome].
> For the MVP, I'd focus on [the one thing that delivers the most value] \
and hold off on [things that can wait].
> Before I start [1-2 specific questions you genuinely can't infer]."
---
### 1.4: Ask Only What You Cannot Infer
Your questions should be **narrow, specific, and consequential**. \
Never ask what you could answer yourself.
**Good questions** (high-stakes, can't infer):
- "Who's the primary user — you or your end customers?"
- "Is this replacing a spreadsheet, or is there literally nothing today?"
- "Does this need to integrate with anything, or standalone?"
- "Is there existing data to migrate, or starting fresh?"
**Bad questions** (low-stakes, inferable):
- "What should happen if there's an error?" *(handle gracefully, obviously)*
- "Should it have search?" *(if there's a list, yes)*
- "How should we handle permissions?" *(follow standard patterns)*
- "What tools should I use?" *(your call, not theirs)*
---
## 2: Capability Assessment & Gap Analysis
**After the user responds, assess fit and gaps together.** Be honest and specific. \
@@ -299,70 +246,153 @@ Present a short **Framework Fit Assessment**:
- **Gaps/Deal-breakers**: Only list genuinely missing capabilities after checking \
both list_agent_tools() and built-in features like GCU
## 3: Design Graph and Propose
### Credential Check (MANDATORY)
Act like an experienced AI solution architect Design the agent architecture:
- Goal: id, name, description, 3-5 success criteria, 2-4 constraints
- Nodes: **3-6 nodes** (HARD RULE: never fewer than 3, never more than 6). \
2 nodes is ALWAYS wrong it means you under-decomposed the task. \
Use as many nodes as the use case requires, but don't create nodes without \
tools merge them into nodes that do real work.
- Edges: on_success for linear, conditional for routing
- Lifecycle: ALWAYS have terminal_nodes
The summary from list_agent_tools() includes `credentials_required` and \
`credentials_available` per provider. **Before designing the graph**, check \
which providers the design will need and whether credentials are available.
**MERGE nodes when:**
- Node has NO tools (pure LLM reasoning) merge into predecessor/successor
- Node sets only 1 trivial output collapse into predecessor
For each provider whose tools you plan to use and where \
`credentials_available` is false:
- Tell the user which credential is missing and what it's needed for
- Ask if they have access to set it up (e.g., API key, OAuth, service account)
- If they don't have access, adjust the design to work without that provider \
or suggest alternatives
**SEPARATE nodes when:**
- Fundamentally different tool sets (e.g., search vs. write vs. validate)
- Fan-out parallelism (parallel branches MUST be separate)
- Different failure/retry semantics (e.g., gather can retry, transform cannot)
- Distinct phases of work (e.g., research, transform, validate, deliver)
- A node would need more than ~5 tools split by responsibility
**Do NOT proceed to the design step with tools that require unavailable \
credentials without the user acknowledging it.** Finding out at runtime that \
credentials are missing wastes everyone's time. Surface this early.
**Typical patterns (queen manages all user interaction):**
- 3 nodes: `gather work review`
- 4 nodes: `gather analyze transform review`
- 5 nodes: `gather research transform validate deliver`
- WRONG: 2 nodes where everything is crammed into one giant node
- WRONG: 7 nodes where half have no tools and just do LLM reasoning
Example:
> "The design needs Google Sheets tools, but the `google` credential isn't \
configured yet. Do you have a Google service account or OAuth credentials \
you can set up? If not, I can use CSV file output instead."
Read reference agents before designing:
list_agents()
read_file("exports/deep_research_agent/agent.py")
read_file("exports/deep_research_agent/nodes/__init__.py")
## 3: Design flowchart
Present the design to the user. Lead with a large ASCII graph inside \
a code block so it renders in monospace. Make it visually prominent \
use box-drawing characters and clear flow arrows:
Act like an experienced AI solution architect. Design the agent architecture \
in the flowchart
The flowchart is the shared canvas. Every structural change should be \
visible to the user immediately. The draft captures business logic \
(node purposes, data flow, tools) without requiring executable code. \
Include in each node: id, name, description, planned tools, \
input/output keys, and success criteria as high-level hints.
Each node is auto-classified into an ISO 5807 flowchart symbol type \
with a unique color. You can override auto-detection by setting \
`flowchart_type` explicitly on a node. Common types:
**Core symbols:**
- **start** (green, stadium): Entry point / trigger
- **terminal** (red, stadium): End of flow
- **process** (blue, rectangle): Standard processing step
- **decision** (amber, diamond): Conditional branching
- **io** (purple, parallelogram): External data input/output
- **document** (blue-grey, wavy rect): Report or document generation
- **subprocess** (teal, subroutine): Delegated sub-agent / predefined process
- **preparation** (brown, hexagon): Setup / initialization step
- **manual_operation** (pink, trapezoid): Human-in-the-loop / manual review
- **delay** (orange, D-shape): Wait / throttle / cooldown
- **display** (cyan): Present results to user
**Data storage:**
- **database** (light green, cylinder): Database or data store
- **stored_data** (lime): Generic persistent data
- **internal_storage** (amber): In-memory / cache
**Flow operations:**
- **merge** (indigo, inv. triangle): Combine multiple inputs
- **extract** (indigo, triangle): Split or filter data
- **connector** (grey, circle): On-page link
- **offpage_connector** (dark grey, pentagon): Cross-page link
**Domain-specific:**
- **browser** (dark indigo, hexagon): GCU browser automation / sub-agent \
delegation. At build time, browser nodes are dissolved into the parent \
node's sub_agents list. Use for any GCU or sub-agent leaf node.
Auto-detection works well for most cases: first node start, nodes with \
no outgoing edges terminal, nodes with multiple conditional outgoing \
edges decision, GCU nodes browser, nodes mentioning "database" \
database, nodes mentioning "report/document" document, etc. Set \
flowchart_type explicitly only when auto-detection would be wrong.
## Decision Nodes — Planning-Only Conditional Branching
Decision nodes (amber diamonds) are **planning-only** visual elements. They \
let you show explicit conditional logic in the flowchart so the user can see \
and approve branching behavior. At `confirm_and_build()`, decision nodes are \
automatically **dissolved** into the runtime graph:
- The decision clause is merged into the predecessor node's `success_criteria`
- The yes/no edges are rewired as the predecessor's `on_success`/`on_failure` edges
- The original flowchart (with decision diamonds) is preserved for display
**When to use decision nodes:**
- When a workflow has a meaningful condition that determines the next step \
(e.g., "Did we find enough results?", "Is the data valid?", "Amount > $100?")
- When the branching logic is important for the user to understand and approve
- When different outcomes lead to genuinely different processing paths
**How to create a decision node:**
- Set `flowchart_type: "decision"` on the node
- Set `decision_clause` to the condition text (e.g., "Data passes validation?")
- Add two outgoing edges with `label: "Yes"` and `label: "No"` pointing \
to the respective target nodes
**Good flowcharts display conditions explicitly.** During planning, the user \
sees the full flowchart with decision diamonds. This is different from the \
building/running phase where conditions are embedded inside node criteria. \
The flowchart is the user-facing contract make branching logic visible.
Example with a decision node:
```
gather
subagent: gcu_search
input: user_request
tools: load_data,
save_data
on_success
work
subagent: gcu_interact
tools: load_data,
save_data
on_success
review
tools: save_data
serve_file_to_user
on_failure
back to gather
gather [Valid data?] Yes transform deliver
No notify_user
```
In the draft: the `[Valid data?]` node has `flowchart_type: "decision"`, \
`decision_clause: "Data passes validation checks?"`, with labeled yes/no edges.
## Sub-Agent Nodes — Planning-Only Delegation
Sub-agent nodes (dark teal subroutines) are **planning-only** visual elements \
that show which nodes delegate to sub-agents. At `confirm_and_build()`, \
sub-agent nodes are **dissolved** into their parent node:
- The sub-agent node's ID is added to the predecessor's `sub_agents` list
- The sub-agent node and its connecting edge are removed
- At runtime, the parent node can invoke the sub-agent via `delegate_to_sub_agent`
**Rules for sub-agent nodes (INCLUDING GCU nodes):**
- GCU nodes are auto-detected as `flowchart_type: "browser"` (hexagon)
- Connect from the managing parent node to the sub-agent node
- Sub-agent nodes must be **leaf nodes** NO outgoing edges to other nodes
- At build time, browser/GCU nodes are dissolved into the parent's \
`sub_agents` list, just like decision nodes are dissolved into criteria
**CRITICAL: GCU nodes (`node_type: "gcu"`) are ALWAYS sub-agents.** \
They MUST NOT appear in the linear flow. NEVER chain GCU nodes \
sequentially (A gcu1 gcu2 B is WRONG). Instead, attach them \
as leaves to the parent that orchestrates them:
```
WRONG: intake gcu_find_prospect gcu_scan_mutuals check_results
WRONG: decision_node gcu_node (as a yes/no branch)
RIGHT: intake (sub_agents: [gcu_find, gcu_scan]) check_results
```
The parent node delegates to its GCU sub-agents and collects results. \
The main flow continues from the parent, not from the GCU node. \
GCU nodes MUST NOT be children of decision nodes decision nodes \
dissolve at build time, which would leave the GCU as a dangling \
workflow step.
**How to show delegation in the flowchart:**
```
research (deep_searcher) browser/GCU node, leaf
research [Enough results?] decision node
```
After dissolution: `research` node gets `sub_agents: ["deep_searcher"]` \
and `success_criteria: "Enough results?"`.
If the worker agent start from some initial input it is okay. \
The queen(you) owns intake: you gathers user requirements, then calls \
@@ -371,18 +401,25 @@ When building the agent, design the entry node's `input_keys` to \
match what the queen will provide at run time. Worker nodes should \
use `escalate` for blockers.
Follow the graph with a brief summary of each node's purpose. \
Get user approval before implementing.
## 4: Get User Confirmation (MANDATORY GATE)
## 4: Get User Confirmation by ask_user
**This is a hard boundary between planning and building.** \
You MUST get explicit user approval before ANY code is generated.
**WAIT for user response.** You MUST get explicit user approval before \
calling `initialize_and_build_agent`.
- If **Proceed**: Move to implementing (call `initialize_and_build_agent`)
- If **Adjust scope**: Discuss what to change, update your notes, re-assess if needed
- If **More questions**: Answer them honestly, then ask again
- If **Reconsider**: Discuss alternatives. If they decide to proceed anyway, \
that's their informed choice
1. Call ask_user() with options like \
["Approve and build", "Adjust the design", "I have questions"]
2. **WAIT for user response.** Do NOT proceed without it.
3. Handle the response:
- If **Approve / Proceed**: Call confirm_and_build(), then \
initialize_and_build_agent(agent_name, nodes)
- If **Adjust scope**: Discuss changes, update the draft with \
save_agent_draft() again, and re-ask
- If **More questions**: Answer them honestly, then ask again
- If **Reconsider**: Discuss alternatives. If they decide to proceed, \
that's their informed choice
**NEVER call initialize_and_build_agent without first calling \
confirm_and_build().** The system will block the transition if you try.
"""
_building_knowledge = """\
@@ -410,11 +447,10 @@ hashline=True for anchors in results
- undo_changes(path?) restore from git snapshot
## Meta-Agent
- list_agent_tools(server_config_path?, output_schema?, group?) discover \
available tools grouped by category. output_schema: "simple" (default, \
descriptions truncated to ~200 chars) or "full" (complete descriptions + \
input_schema). group: "all" (default) or a provider like "google". \
Call FIRST before designing.
- list_agent_tools(group?, service?, output_schema?, credentials?) discover tools \
progressively: no args=provider summary; group+output_schema="summary"=service breakdown; \
group+service=tool names; group+service+output_schema="full"=full details. \
credentials="available" filters to configured tools. Call FIRST before designing.
- validate_agent_package(agent_name) run ALL validation checks in one call \
(class validation, runner load, tool validation, tests). Call after building.
- list_agents() list all agent packages in exports/ with session counts
@@ -440,7 +476,9 @@ When a user says "my agent is failing" or "debug this agent":
## 5. Implement
**Please make sure you have propose the design to the user before implementing**
**You should only reach this step after the user has approved the draft design \
in the planning phase. The draft metadata will pre-populate descriptions, \
goals, success criteria, and node metadata in the generated files.**
Call `initialize_and_build_agent(agent_name, nodes)` to generate all package \
files. The agent_name must be snake_case (e.g., "my_agent"). Pass node names \
@@ -467,8 +505,8 @@ nodes/__init__.py
- Goal description, success criteria values, constraint values, edge \
definitions, identity_prompt in agent.py
- CLI options in __main__.py
- For async entry points (timers/webhooks), add AsyncEntryPointSpec \
and AgentRuntimeConfig to agent.py
- For triggers (timers/webhooks), add entries to triggers.json in the \
agent's export directory
Do NOT modify or rewrite:
- Import statements at top of agent.py (they are correct)
@@ -503,12 +541,15 @@ _package_builder_knowledge = _shared_building_knowledge + _planning_knowledge +
_queen_identity_planning = """\
You are an experienced, responsible and curious Solution Architect. \
"Queen" is the internal alias. \
You ask smart questions to guide user to the solution \
You are in PLANNING phase your job is to either: \
(a) understand what the user wants and design a new agent, or \
(b) diagnose issues with an existing agent, discuss a fix plan with the user, \
then transition to building to implement. \
You have read-only tools for exploration but no write/edit tools. \
Focus on conversation, research, and design.\
Focus on conversation, research, and design. \
You MUST use ask_user / ask_user_multiple tools for ALL questions \
never ask questions in plain text without calling the tool.\
"""
_queen_identity_building = """\
@@ -551,24 +592,45 @@ but no write/edit tools.
- run_command(command, cwd?, timeout?) Read-only commands only (grep, ls, git log). \
Never use this to write files, run scripts, or modify the filesystem transition \
to BUILDING phase for that.
- list_agent_tools(server_config_path?, output_schema?, group?) \
Discover available tools for design
- list_agent_tools(server_config_path?, output_schema?, group?, credentials?) \
Discover available tools for design (summary names full)
- list_agents() See existing agent packages for reference
- list_agent_sessions(agent_name, status?, limit?) Inspect past runs of an agent
- list_agent_checkpoints(agent_name, session_id) View execution history
- get_agent_checkpoint(agent_name, session_id, checkpoint_id?) Load a checkpoint
- initialize_and_build_agent(agent_name?, nodes?) With agent_name: scaffold a \
new agent and transition to BUILDING phase. Without agent_name: transition to \
BUILDING to fix the currently loaded agent (requires a loaded worker).
## Draft Graph Workflow (new agents)
- save_agent_draft(agent_name, goal, nodes, edges?, terminal_nodes?, ...) \
Create an ISO 5807 color-coded flowchart draft. No code is generated. Each \
node is auto-classified into a standard flowchart symbol (process, decision, \
document, database, subprocess, etc.) with unique shapes and colors. Set \
flowchart_type on a node to override. Nodes need only an id. \
Use decision nodes (flowchart_type: "decision", with decision_clause and \
labeled yes/no edges) to make conditional branching explicit. \
GCU/sub-agent nodes (node_type: "gcu") are auto-detected as browser \
hexagons connect them as leaf nodes to their parent.
- confirm_and_build() Record user confirmation of the draft. Dissolves \
planning-only nodes (decision predecessor criteria; browser/GCU \
predecessor sub_agents list). Call this ONLY after the user explicitly \
approves via ask_user.
- initialize_and_build_agent(agent_name?, nodes?) Scaffold the agent package \
and transition to BUILDING phase. For new agents, this REQUIRES \
save_agent_draft() + confirm_and_build() first. The draft metadata is used to \
pre-populate the generated files. Without agent_name: transition to BUILDING \
to fix the currently loaded agent (no draft required).
## Loading existing agents
- load_built_agent(agent_path) Load an existing agent and switch to STAGING \
phase. Only use this when the user explicitly asks to work with an existing agent \
(e.g. "load my_agent", "run the research agent"). Confirm with the user first.
Focus on understanding requirements and proposing an agent architecture \
with ASCII graph art. Use ask_user to get user approval, then call \
initialize_and_build_agent to begin building. If the user wants to work with \
an existing agent instead, use load_built_agent after confirming. \
If you are diagnosing an existing agent, call initialize_and_build_agent() \
## Workflow summary
1. Understand requirements discover tools design graph
2. Call save_agent_draft() to create visual draft present to user
3. Call ask_user() to get explicit approval
4. Call confirm_and_build() to record approval
5. Call initialize_and_build_agent() to scaffold and start building
For diagnosis of existing agents, call initialize_and_build_agent() \
(no args) after agreeing on a fix plan with the user.
"""
@@ -583,6 +645,15 @@ list_agents, list_agent_sessions, \
list_agent_checkpoints, get_agent_checkpoint
- load_built_agent(agent_path) Load the agent and switch to STAGING phase
- list_credentials(credential_id?) List authorized credentials
- save_agent_draft(...) **Re-draft the flowchart during building.** When \
called during building, planning-only nodes (decision, browser/GCU) are \
dissolved automatically no re-confirmation needed. The user sees the \
updated flowchart immediately. Use this when you make structural changes \
(add/remove nodes, change edges) so the flowchart stays in sync.
- replan_agent() Switch back to PLANNING phase. The previous draft is \
restored (with decision/browser nodes intact) so you can edit it. Use \
when the user wants to change integrations, swap tools, rethink the \
flow, or discuss any design changes before you build them.
When you finish building an agent, call load_built_agent(path) to stage it.
"""
@@ -598,6 +669,9 @@ The agent is loaded and ready to run. You can inspect it and launch it:
- stop_worker_and_plan() Go to PLANNING phase to discuss changes with the user \
first (DEFAULT for most modification requests)
- stop_worker_and_edit() Go to BUILDING phase for immediate, specific fixes
- set_trigger(trigger_id, trigger_type?, trigger_config?) Activate a trigger (timer)
- remove_trigger(trigger_id) Deactivate a trigger
- list_triggers() List all triggers and their active/inactive status
You do NOT have write tools. To modify the agent, prefer \
stop_worker_and_plan() unless the user gave a specific instruction.
@@ -620,6 +694,15 @@ with the user first (DEFAULT for most modification requests)
You do NOT have write tools. To modify the agent, prefer \
stop_worker_and_plan() unless the user gave a specific instruction. \
To just stop without modifying, call stop_worker().
- stop_worker_and_edit() Stop the worker and switch back to BUILDING phase
- set_trigger(trigger_id, trigger_type?, trigger_config?) Activate a trigger (timer)
- remove_trigger(trigger_id) Deactivate a trigger
- list_triggers() List all triggers and their active/inactive status
You do NOT have write tools or agent construction tools. \
If you need to modify the agent, call stop_worker_and_edit() to switch back \
to BUILDING phase. To stop the worker and ask the user what to do next, call \
stop_worker() to return to STAGING phase.
"""
# -- Behavior shared across all phases --
@@ -627,25 +710,57 @@ To just stop without modifying, call stop_worker().
_queen_behavior_always = """
# Behavior
## CRITICAL RULE — ask_user tool
## CRITICAL RULE — ask_user / ask_user_multiple
Every response that ends with a question, a prompt, or expects user \
input MUST finish with a call to ask_user(prompt, options). \
input MUST finish with a call to ask_user or ask_user_multiple. \
The system CANNOT detect that you are waiting for \
input unless you call ask_user. You MUST call ask_user as the LAST \
input unless you call one of these tools. You MUST call it as the LAST \
action in your response.
NEVER end a response with a question in text without calling ask_user. \
NEVER rely on the user seeing your text and replying call ask_user.
NEVER rely on the user seeing your text and replying call ask_user. \
NEVER list options as text bullets the tool renders interactive buttons.
**When you have 2+ questions**, use ask_user_multiple instead of ask_user. \
This renders all questions at once so the user answers in one interaction \
instead of going back and forth. ALWAYS prefer ask_user_multiple when \
you need to clarify multiple things. \
**IMPORTANT: When using ask_user_multiple, do NOT repeat the questions \
in your text response.** The widget renders the questions with options \
duplicating them in text wastes the user's time and delays the widget \
appearing. Keep your text to a brief context/intro sentence only.
Always provide 2-4 short options that cover the most likely answers. \
The user can always type a custom response.
Examples:
- ask_user("What do you need?",
["Build a new agent", "Run the loaded worker", "Help with code"])
- ask_user("Which pattern?",
["Simple 3-node", "Rich with feedback", "Custom"])
### WRONG — never do this:
```
I need a few details:
- Documentation Source: Where should the agent look?
- Trigger: Should the agent poll or get a URL?
- Review Channel: Slack, Email, or Sheets?
Which of these would you like to define first?
1. Documentation source
2. Trigger
3. Review channel
```
This lists questions as plain text with NO tool call the user has no \
interactive widget and the system doesn't know you're waiting for input.
### RIGHT — always do this:
Write a brief intro (1-2 sentences), then call the tool:
- ask_user_multiple(questions=[
{"id": "docs", "prompt": "Where should the agent find answers?",
"options": ["GitHub repo", "Documentation website", "Internal wiki"]},
{"id": "trigger", "prompt": "How should questions be discovered?",
"options": ["Poll search automatically", "I provide a URL"]},
{"id": "review", "prompt": "Where to send drafted responses?",
"options": ["Slack", "Email", "Google Sheets"]}
])
Examples (single question):
- ask_user("Ready to proceed?",
["Yes, go ahead", "Let me change something"])
@@ -690,9 +805,26 @@ You are in planning mode. Your job is to:
3. Discover available tools with list_agent_tools()
4. Assess framework fit and gaps
5. Consider multiple approaches and their trade-offs
6. Design the agent graph and present it as ASCII art
7. Use ask_user to get explicit user approval and clarify the approach
8. Call initialize_and_build_agent(agent_name, nodes) to scaffold and start building
6. Design the agent graph call save_agent_draft() **as soon as you have a \
rough shape**, even before finalizing all details
7. **Iterate on the draft interactively** every time the user gives feedback \
that changes the structure, call save_agent_draft() again so they see the \
update in real-time. The flowchart is a live collaboration tool.
8. When the design is stable, use ask_user to get explicit approval
9. Call confirm_and_build() after the user approves
10. Call initialize_and_build_agent(agent_name, nodes) to scaffold and start building
**The flowchart is your shared whiteboard.** Don't describe changes in text \
and then ask "should I update the draft?" just update it. If the user says \
"add a validation step," immediately call save_agent_draft() with the new \
node added. If they say "remove that," update and re-draft. The user should \
see every structural change reflected in the visualizer as you discuss it.
**CRITICAL: Planning Building boundary.** You MUST get explicit user \
confirmation before moving to building. The sequence is:
save_agent_draft() iterate with user ask_user() confirm_and_build() \
initialize_and_build_agent()
Skipping any of these steps will be blocked by the system.
Remember: DO NOT write or edit any files yet. This is a read-only exploration \
and planning phase. You have read-only tools but no write/edit tools in this \
@@ -745,6 +877,41 @@ run_agent_with_input(task) (if in staging) or load then run (if in building)
subtasks to justify delegation.
- Building, modifying, or configuring agents is ALWAYS your job. Never \
delegate agent construction to the worker, even as a "research" subtask.
## Keeping the flowchart in sync during building
When you make structural changes to the agent (add/remove/rename nodes, \
change edges, modify sub-agent assignments), call save_agent_draft() to \
update the flowchart. During building, this auto-dissolves planning-only \
nodes without needing user re-confirmation. The user sees the updated \
flowchart immediately.
- **Minor changes** (add a node, rename, adjust edges): call \
save_agent_draft() with the updated graph and keep building.
- **User wants to discuss, redesign, or change integrations/tools**: call \
replan_agent(). The previous draft is restored so you can edit it with \
the user. After they approve, confirm_and_build() continue building.
**When to call replan_agent():** Changing which tools or integrations a \
node uses, swapping data sources, rethinking the flow, or any time the \
user says "replan", "go back", "let's redesign", "change the approach", \
"use a different tool/API", etc. Do NOT stay in building to handle these \
switch to planning so the user can review and approve the new design.
## CRITICAL — Graph topology errors require replanning, not code edits
If you discover that the agent graph has structural problems GCU nodes \
in the linear flow, missing edges, wrong node connections, incorrect \
sub-agent assignments you MUST call replan_agent() and fix the draft. \
Do NOT attempt to fix topology by editing agent.py directly. The graph \
structure is defined by the draft dissolution code-gen pipeline. \
Editing code to rewire nodes bypasses the flowchart and creates drift \
between what the user sees and what the code does.
**WRONG:** "Let me fix agent.py to remove GCU nodes from edges..."
**RIGHT:** Call replan_agent(), fix the draft with save_agent_draft(), \
get user approval, then confirm_and_build() the corrected code is \
generated automatically.
"""
# -- STAGING phase behavior --
@@ -822,6 +989,33 @@ Use stop_worker_and_edit() only when:
- The user gave a specific, concrete instruction ("add save_data to the gather node")
- You already discussed the fix in a previous planning session
- The change is trivial and unambiguous (rename, toggle a flag)
## Trigger Management
Use list_triggers() to see available triggers from the loaded worker.
Use set_trigger(trigger_id) to activate a timer. Once active, triggers \
fire periodically and inject [TRIGGER: ...] messages so you can decide \
whether to call run_agent_with_input(task).
### When the user says "Enable trigger <id>" (or clicks Enable in the UI):
1. Call get_worker_status(focus="memory") to check if the worker has \
saved configuration (rules, preferences, settings from a prior run).
2. If memory contains saved config: compose a task string from it \
(e.g. "Process inbox emails using saved rules") and call \
set_trigger(trigger_id, task="...") immediately. Tell the user the \
trigger is now active and what schedule it uses. Do NOT ask them to \
provide the task you derive it from memory.
3. If memory is empty (no prior run): tell the user the agent needs to \
run once first so its configuration can be saved. Offer to run it now. \
Once the worker finishes, enable the trigger.
4. If the user just provided config this session (rules/task context \
already in conversation): use that directly, no memory lookup needed. \
Enable the trigger immediately.
Never ask "what should the task be?" when enabling a trigger for an \
agent with a clear purpose. The task string is a brief description of \
what the worker does, derived from its saved state or your current context.
"""
# -- RUNNING phase behavior --
@@ -836,7 +1030,6 @@ NOT ask the user directly.
You wake up when:
- The user explicitly addresses you
- A worker escalation arrives (`[WORKER_ESCALATION_REQUEST]`)
- An escalation ticket arrives from the judge
- The worker finishes (`[WORKER_TERMINAL]`)
If the user asks for progress, call get_worker_status() ONCE and report. \
@@ -914,6 +1107,21 @@ When the user asks to fix, change, modify, or update the loaded worker \
**Default: use stop_worker_and_plan().** Most modification requests need \
discussion first. Only use stop_worker_and_edit() when the user gave a \
specific, unambiguous instruction or you already agreed on the fix.
## Trigger Handling
You will receive [TRIGGER: ...] messages when a scheduled timer fires. \
These are framework-level signals, not user messages.
Rules:
- Check get_worker_status() before calling run_agent_with_input(task). If the worker \
is already RUNNING, decide: skip this trigger, or note it for after completion.
- When multiple [TRIGGER] messages arrive at once, read them all before acting. \
Batch your response do not call run_agent_with_input() once per trigger.
- If a trigger fires but the task no longer makes sense (e.g., user changed \
config since last run), skip it and inform the user.
- Never disable a trigger without telling the user. Use remove_trigger() only \
when explicitly asked or when the trigger is clearly obsolete.
"""
# -- Backward-compatible composed versions (used by queen_node.system_prompt default) --
@@ -931,8 +1139,10 @@ _queen_tools_docs = (
+ "\n\n### RUNNING phase (worker is executing)\n"
+ _queen_tools_running.strip()
+ "\n\n### Phase transitions\n"
"- initialize_and_build_agent(agent_name?, nodes?) → with name: scaffolds package; "
"without name: switches to BUILDING for existing agent\n"
"- save_agent_draft(...) → creates visual-only draft graph (stays in PLANNING)\n"
"- confirm_and_build() → records user approval of draft (stays in PLANNING)\n"
"- initialize_and_build_agent(agent_name?, nodes?) → scaffolds package + switches to "
"BUILDING (requires draft + confirmation for new agents)\n"
"- replan_agent() → switches back to PLANNING phase (only when user explicitly requests)\n"
"- load_built_agent(path) → switches to STAGING phase\n"
"- run_agent_with_input(task) → starts worker, switches to RUNNING phase\n"
@@ -975,8 +1185,8 @@ ticket_triage_node = NodeSpec(
id="ticket_triage",
name="Ticket Triage",
description=(
"Queen's triage node. Receives an EscalationTicket from the Health Judge "
"via event-driven entry point and decides: dismiss or notify the operator."
"Queen's triage node. Receives an EscalationTicket via event-driven "
"entry point and decides: dismiss or notify the operator."
),
node_type="event_loop",
client_facing=True, # Operator can chat with queen once connected (Ctrl+Q)
@@ -990,8 +1200,8 @@ ticket_triage_node = NodeSpec(
),
tools=["notify_operator"],
system_prompt="""\
You are the Queen. The Worker Health Judge has escalated a worker \
issue to you. The ticket is in your memory under key "ticket". Read it carefully.
You are the Queen. A worker health issue has been escalated to you. \
The ticket is in your memory under key "ticket". Read it carefully.
## Dismiss criteria — do NOT call notify_operator:
- severity is "low" AND steps_since_last_accept < 8
@@ -1030,7 +1240,7 @@ queen_node = NodeSpec(
description=(
"User's primary interactive interface with full coding capability. "
"Can build agents directly or delegate to the worker. Manages the "
"worker agent lifecycle and triages health escalations from the judge."
"worker agent lifecycle."
),
node_type="event_loop",
client_facing=True,
@@ -180,7 +180,7 @@ terminal_nodes = [] # Forever-alive
# Module-level vars read by AgentRunner.load()
conversation_mode = "continuous"
identity_prompt = "You are a helpful agent."
loop_config = {"max_iterations": 100, "max_tool_calls_per_turn": 20, "max_history_tokens": 32000}
loop_config = {"max_iterations": 100, "max_tool_calls_per_turn": 20, "max_context_tokens": 32000}
class MyAgent:
@@ -332,81 +332,46 @@ class MyAgent:
default_agent = MyAgent()
```
## agent.py — Async Entry Points Variant
## triggers.json — Timer and Webhook Triggers
When an agent needs timers, webhooks, or event-driven triggers, add
`async_entry_points` and optionally `runtime_config` as module-level variables.
These are IN ADDITION to the standard variables above.
When an agent needs timers, webhooks, or event-driven triggers, create a
`triggers.json` file in the agent's directory (alongside `agent.py`).
The queen loads these at session start and the user can manage them via
the `set_trigger` / `remove_trigger` tools at runtime.
```python
# Additional imports for async entry points
from framework.graph.edge import GraphSpec, AsyncEntryPointSpec
from framework.runtime.agent_runtime import (
AgentRuntime, AgentRuntimeConfig, create_agent_runtime,
)
# ... (goal, nodes, edges, entry_node, entry_points, etc. as above) ...
# Async entry points — event-driven triggers
async_entry_points = [
# Timer with cron: daily at 9am
AsyncEntryPointSpec(
id="daily-check",
name="Daily Check",
entry_node="process-node",
trigger_type="timer",
trigger_config={"cron": "0 9 * * *"},
isolation_level="shared",
max_concurrent=1,
),
# Timer with fixed interval: every 20 minutes
AsyncEntryPointSpec(
id="scheduled-check",
name="Scheduled Check",
entry_node="process-node",
trigger_type="timer",
trigger_config={"interval_minutes": 20, "run_immediately": False},
isolation_level="shared",
max_concurrent=1,
),
# Event: reacts to webhook events
AsyncEntryPointSpec(
id="webhook-event",
name="Webhook Event Handler",
entry_node="process-node",
trigger_type="event",
trigger_config={"event_types": ["webhook_received"]},
isolation_level="shared",
max_concurrent=10,
),
```json
[
{
"id": "daily-check",
"name": "Daily Check",
"trigger_type": "timer",
"trigger_config": {"cron": "0 9 * * *"},
"task": "Run the daily check process"
},
{
"id": "scheduled-check",
"name": "Scheduled Check",
"trigger_type": "timer",
"trigger_config": {"interval_minutes": 20},
"task": "Run the scheduled check"
},
{
"id": "webhook-event",
"name": "Webhook Event Handler",
"trigger_type": "webhook",
"trigger_config": {"event_types": ["webhook_received"]},
"task": "Process incoming webhook event"
}
]
# Webhook server config (only needed if using webhooks)
runtime_config = AgentRuntimeConfig(
webhook_host="127.0.0.1",
webhook_port=8080,
webhook_routes=[
{
"source_id": "my-source",
"path": "/webhooks/my-source",
"methods": ["POST"],
},
],
)
```
**Key rules for async entry points:**
- `async_entry_points` is a list of `AsyncEntryPointSpec` (NOT `EntryPointSpec`)
- `runtime_config` is `AgentRuntimeConfig` (NOT `RuntimeConfig` from config.py)
- Valid trigger_types: `timer`, `event`, `webhook`, `manual`, `api`
- Valid isolation_levels: `isolated`, `shared`, `synchronized`
**Key rules for triggers.json:**
- Valid trigger_types: `timer`, `webhook`
- Timer trigger_config (cron): `{"cron": "0 9 * * *"}` — standard 5-field cron expression
- Timer trigger_config (interval): `{"interval_minutes": float, "run_immediately": bool}`
- Event trigger_config: `{"event_types": ["webhook_received"], "filter_stream": "...", "filter_node": "..."}`
- Use `isolation_level="shared"` for async entry points that need to read
the primary session's memory (e.g., user-configured rules)
- The `_build_graph()` method passes `async_entry_points` to GraphSpec
- Reference: `exports/gmail_inbox_guardian/agent.py`
- Timer trigger_config (interval): `{"interval_minutes": float}`
- Each trigger must have a unique `id`
- The `task` field describes what the worker should do when the trigger fires
- Triggers are persisted back to `triggers.json` when modified via queen tools
## __init__.py
@@ -453,21 +418,6 @@ __all__ = [
]
```
**If the agent uses async entry points**, also import and export:
```python
from .agent import (
...,
async_entry_points,
runtime_config, # Only if using webhooks
)
__all__ = [
...,
"async_entry_points",
"runtime_config",
]
```
## __main__.py
```python
@@ -31,8 +31,7 @@ module-level variables via `getattr()`:
| `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 |
| `async_entry_points` | no | `[]` | No async triggers (timers, webhooks, events) |
| `runtime_config` | no | `None` | No webhook server |
| `triggers.json` (file) | no | not present | No triggers (timers, webhooks) |
**CRITICAL:** `__init__.py` MUST import and re-export ALL of these from
`agent.py`. Missing exports silently fall back to defaults, causing
@@ -226,7 +225,7 @@ Only three valid keys:
loop_config = {
"max_iterations": 100, # Max LLM turns per node visit
"max_tool_calls_per_turn": 20, # Max tool calls per LLM response
"max_history_tokens": 32000, # Triggers conversation compaction
"max_context_tokens": 32000, # Triggers conversation compaction
}
```
**INVALID keys** (do NOT use): `"strategy"`, `"mode"`, `"timeout"`,
@@ -257,44 +256,28 @@ Multiple ON_SUCCESS edges from same source → parallel execution via asyncio.ga
Judge is the SOLE acceptance mechanism — no ad-hoc framework gating.
## Async Entry Points (Webhooks, Timers, Events)
## Triggers (Timers, Webhooks)
For agents that react to external events, use `AsyncEntryPointSpec`:
For agents that react to external events, create a `triggers.json` file
in the agent's export directory:
```python
from framework.graph.edge import AsyncEntryPointSpec
from framework.runtime.agent_runtime import AgentRuntimeConfig
# Timer trigger (cron or interval)
async_entry_points = [
AsyncEntryPointSpec(
id="daily-check",
name="Daily Check",
entry_node="process",
trigger_type="timer",
trigger_config={"cron": "0 9 * * *"}, # daily at 9am
isolation_level="shared",
)
```json
[
{
"id": "daily-check",
"name": "Daily Check",
"trigger_type": "timer",
"trigger_config": {"cron": "0 9 * * *"},
"task": "Run the daily check process"
}
]
# Webhook server (optional)
runtime_config = AgentRuntimeConfig(
webhook_host="127.0.0.1",
webhook_port=8080,
webhook_routes=[{"source_id": "gmail", "path": "/webhooks/gmail", "methods": ["POST"]}],
)
```
### Key Fields
- `trigger_type`: `"timer"`, `"event"`, `"webhook"`, `"manual"`
- `trigger_type`: `"timer"` or `"webhook"`
- `trigger_config`: `{"cron": "0 9 * * *"}` or `{"interval_minutes": 20}`
- `isolation_level`: `"shared"` (recommended), `"isolated"`, `"synchronized"`
- `event_types`: For event triggers, e.g., `["webhook_received"]`
### Exports Required
Both `async_entry_points` and `runtime_config` must be exported from `__init__.py`.
See `exports/gmail_inbox_guardian/agent.py` for complete example.
- `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
@@ -1,8 +1,8 @@
"""Queen's ticket receiver entry point.
When the Worker Health Judge emits a WORKER_ESCALATION_TICKET event on the
shared EventBus, this entry point fires and routes to the ``ticket_triage``
node, where the Queen deliberates and decides whether to notify the operator.
When a WORKER_ESCALATION_TICKET event is emitted on the shared EventBus,
this entry point fires and routes to the ``ticket_triage`` node, where the
Queen deliberates and decides whether to notify the operator.
Isolation level is ``isolated`` the queen's triage memory is kept separate
from the worker's shared memory. Each ticket triage runs in its own context.
+9
View File
@@ -56,6 +56,14 @@ def get_max_tokens() -> int:
return get_hive_config().get("llm", {}).get("max_tokens", DEFAULT_MAX_TOKENS)
DEFAULT_MAX_CONTEXT_TOKENS = 32_000
def get_max_context_tokens() -> int:
"""Return the configured max_context_tokens, falling back to DEFAULT_MAX_CONTEXT_TOKENS."""
return get_hive_config().get("llm", {}).get("max_context_tokens", DEFAULT_MAX_CONTEXT_TOKENS)
def get_api_key() -> str | None:
"""Return the API key, supporting env var, Claude Code subscription, Codex, and ZAI Code.
@@ -178,6 +186,7 @@ class RuntimeConfig:
model: str = field(default_factory=get_preferred_model)
temperature: float = 0.7
max_tokens: int = field(default_factory=get_max_tokens)
max_context_tokens: int = field(default_factory=get_max_context_tokens)
api_key: str | None = field(default_factory=get_api_key)
api_base: str | None = field(default_factory=get_api_base)
extra_kwargs: dict[str, Any] = field(default_factory=get_llm_extra_kwargs)
+9 -9
View File
@@ -307,13 +307,13 @@ class NodeConversation:
def __init__(
self,
system_prompt: str = "",
max_history_tokens: int = 32000,
max_context_tokens: int = 32000,
compaction_threshold: float = 0.8,
output_keys: list[str] | None = None,
store: ConversationStore | None = None,
) -> None:
self._system_prompt = system_prompt
self._max_history_tokens = max_history_tokens
self._max_context_tokens = max_context_tokens
self._compaction_threshold = compaction_threshold
self._output_keys = output_keys
self._store = store
@@ -525,16 +525,16 @@ class NodeConversation:
self._last_api_input_tokens = actual_input_tokens
def usage_ratio(self) -> float:
"""Current token usage as a fraction of *max_history_tokens*.
"""Current token usage as a fraction of *max_context_tokens*.
Returns 0.0 when ``max_history_tokens`` is zero (unlimited).
Returns 0.0 when ``max_context_tokens`` is zero (unlimited).
"""
if self._max_history_tokens <= 0:
if self._max_context_tokens <= 0:
return 0.0
return self.estimate_tokens() / self._max_history_tokens
return self.estimate_tokens() / self._max_context_tokens
def needs_compaction(self) -> bool:
return self.estimate_tokens() >= self._max_history_tokens * self._compaction_threshold
return self.estimate_tokens() >= self._max_context_tokens * self._compaction_threshold
# --- Output-key extraction ---------------------------------------------
@@ -1029,7 +1029,7 @@ class NodeConversation:
await self._store.write_meta(
{
"system_prompt": self._system_prompt,
"max_history_tokens": self._max_history_tokens,
"max_context_tokens": self._max_context_tokens,
"compaction_threshold": self._compaction_threshold,
"output_keys": self._output_keys,
}
@@ -1062,7 +1062,7 @@ class NodeConversation:
conv = cls(
system_prompt=meta.get("system_prompt", ""),
max_history_tokens=meta.get("max_history_tokens", 32000),
max_context_tokens=meta.get("max_context_tokens", 32000),
compaction_threshold=meta.get("compaction_threshold", 0.8),
output_keys=meta.get("output_keys"),
store=store,
+3 -3
View File
@@ -37,7 +37,7 @@ async def evaluate_phase_completion(
phase_description: str,
success_criteria: str,
accumulator_state: dict[str, Any],
max_history_tokens: int = 8_196,
max_context_tokens: int = 8_196,
) -> PhaseVerdict:
"""Level 2 judge: read the conversation and evaluate quality.
@@ -50,7 +50,7 @@ async def evaluate_phase_completion(
phase_description: Description of the phase
success_criteria: Natural-language criteria for phase completion
accumulator_state: Current output key values
max_history_tokens: Main conversation token budget (judge gets 20%)
max_context_tokens: Main conversation token budget (judge gets 20%)
Returns:
PhaseVerdict with action and optional feedback
@@ -89,7 +89,7 @@ FEEDBACK: (reason if RETRY, empty if ACCEPT)"""
response = await llm.acomplete(
messages=[{"role": "user", "content": user_prompt}],
system=system_prompt,
max_tokens=max(1024, max_history_tokens // 5),
max_tokens=max(1024, max_context_tokens // 5),
max_retries=1,
)
if not response.content or not response.content.strip():
+13 -85
View File
@@ -322,7 +322,11 @@ class AsyncEntryPointSpec(BaseModel):
id: str = Field(description="Unique identifier for this entry point")
name: str = Field(description="Human-readable name")
entry_node: str = Field(description="Node ID to start execution from")
entry_node: str = Field(
default="",
description="Deprecated: Node ID to start execution from. "
"Triggers are graph-level; worker always enters at GraphSpec.entry_node.",
)
trigger_type: str = Field(
default="manual",
description="How this entry point is triggered: webhook, api, timer, event, manual",
@@ -331,6 +335,10 @@ class AsyncEntryPointSpec(BaseModel):
default_factory=dict,
description="Trigger-specific configuration (e.g., webhook URL, timer interval)",
)
task: str = Field(
default="",
description="Worker task string when this trigger fires autonomously",
)
isolation_level: str = Field(
default="shared", description="State isolation: isolated, shared, or synchronized"
)
@@ -368,28 +376,8 @@ class GraphSpec(BaseModel):
edges=[...],
)
For multi-entry-point agents (concurrent streams):
GraphSpec(
id="support-agent-graph",
goal_id="support-001",
entry_node="process-webhook", # Default entry
async_entry_points=[
AsyncEntryPointSpec(
id="webhook",
name="Zendesk Webhook",
entry_node="process-webhook",
trigger_type="webhook",
),
AsyncEntryPointSpec(
id="api",
name="API Handler",
entry_node="process-request",
trigger_type="api",
),
],
nodes=[...],
edges=[...],
)
Triggers (timer, webhook, event) are now defined in ``triggers.json``
alongside the agent directory, not embedded in the graph spec.
"""
id: str
@@ -402,12 +390,6 @@ class GraphSpec(BaseModel):
default_factory=dict,
description="Named entry points for resuming execution. Format: {name: node_id}",
)
async_entry_points: list[AsyncEntryPointSpec] = Field(
default_factory=list,
description=(
"Asynchronous entry points for concurrent execution streams (used with AgentRuntime)"
),
)
terminal_nodes: list[str] = Field(
default_factory=list, description="IDs of nodes that end execution"
)
@@ -486,17 +468,6 @@ class GraphSpec(BaseModel):
return node
return None
def has_async_entry_points(self) -> bool:
"""Check if this graph uses async entry points (multi-stream execution)."""
return len(self.async_entry_points) > 0
def get_async_entry_point(self, entry_point_id: str) -> AsyncEntryPointSpec | None:
"""Get an async entry point by ID."""
for ep in self.async_entry_points:
if ep.id == entry_point_id:
return ep
return None
def get_outgoing_edges(self, node_id: str) -> list[EdgeSpec]:
"""Get all edges leaving a node, sorted by priority."""
edges = [e for e in self.edges if e.source == node_id]
@@ -587,37 +558,6 @@ class GraphSpec(BaseModel):
if not self.get_node(self.entry_node):
errors.append(f"Entry node '{self.entry_node}' not found")
# Check async entry points
seen_entry_ids = set()
for entry_point in self.async_entry_points:
# Check for duplicate IDs
if entry_point.id in seen_entry_ids:
errors.append(f"Duplicate async entry point ID: '{entry_point.id}'")
seen_entry_ids.add(entry_point.id)
# Check entry node exists
if not self.get_node(entry_point.entry_node):
errors.append(
f"Async entry point '{entry_point.id}' references "
f"missing node '{entry_point.entry_node}'"
)
# Validate isolation level
valid_isolation = {"isolated", "shared", "synchronized"}
if entry_point.isolation_level not in valid_isolation:
errors.append(
f"Async entry point '{entry_point.id}' has invalid isolation_level "
f"'{entry_point.isolation_level}'. Valid: {valid_isolation}"
)
# Validate trigger type
valid_triggers = {"webhook", "api", "timer", "event", "manual"}
if entry_point.trigger_type not in valid_triggers:
errors.append(
f"Async entry point '{entry_point.id}' has invalid trigger_type "
f"'{entry_point.trigger_type}'. Valid: {valid_triggers}"
)
# Check terminal nodes exist
for term in self.terminal_nodes:
if not self.get_node(term):
@@ -646,10 +586,6 @@ class GraphSpec(BaseModel):
for entry_point_node in self.entry_points.values():
to_visit.append(entry_point_node)
# Add all async entry points as valid starting points
for async_entry in self.async_entry_points:
to_visit.append(async_entry.entry_node)
# Traverse from all entry points
while to_visit:
current = to_visit.pop()
@@ -666,18 +602,10 @@ class GraphSpec(BaseModel):
for sub_agent_id in sub_agents:
reachable.add(sub_agent_id)
# Build set of async entry point nodes for quick lookup
async_entry_nodes = {ep.entry_node for ep in self.async_entry_points}
for node in self.nodes:
if node.id not in reachable:
# Skip if node is a pause node, entry point target, or async entry
# (pause/resume architecture and async entry points make reachable)
if (
node.id in self.pause_nodes
or node.id in self.entry_points.values()
or node.id in async_entry_nodes
):
# Skip if node is a pause node or entry point target
if node.id in self.pause_nodes or node.id in self.entry_points.values():
continue
errors.append(f"Node '{node.id}' is unreachable from entry")
+271 -28
View File
@@ -36,6 +36,21 @@ from framework.runtime.llm_debug_logger import log_llm_turn
logger = logging.getLogger(__name__)
@dataclass
class TriggerEvent:
"""A framework-level trigger signal (timer tick or webhook hit).
Triggers are queued separately from user messages / external events
and drained atomically so the LLM sees all pending triggers at once.
"""
trigger_type: str # "timer" | "webhook"
source_id: str # entry point ID or webhook route ID
payload: dict[str, Any] = field(default_factory=dict)
timestamp: float = field(default_factory=time.time)
# Pattern for detecting context-window-exceeded errors across LLM providers.
_CONTEXT_TOO_LARGE_RE = re.compile(
r"context.{0,20}(length|window|limit|size)|"
@@ -170,7 +185,7 @@ class LoopConfig:
judge_every_n_turns: int = 1
stall_detection_threshold: int = 3
stall_similarity_threshold: float = 0.85
max_history_tokens: int = 32_000
max_context_tokens: int = 32_000
store_prefix: str = ""
# Overflow margin for max_tool_calls_per_turn. Tool calls are only
@@ -346,6 +361,7 @@ class EventLoopNode(NodeProtocol):
self._tool_executor = tool_executor
self._conversation_store = conversation_store
self._injection_queue: asyncio.Queue[tuple[str, bool]] = asyncio.Queue()
self._trigger_queue: asyncio.Queue[TriggerEvent] = asyncio.Queue()
# Client-facing input blocking state
self._input_ready = asyncio.Event()
self._awaiting_input = False
@@ -512,7 +528,7 @@ class EventLoopNode(NodeProtocol):
conversation = NodeConversation(
system_prompt=system_prompt,
max_history_tokens=self._config.max_history_tokens,
max_context_tokens=self._config.max_context_tokens,
output_keys=ctx.node_spec.output_keys or None,
store=self._conversation_store,
)
@@ -549,6 +565,8 @@ class EventLoopNode(NodeProtocol):
tools.append(set_output_tool)
if ctx.node_spec.client_facing and not ctx.event_triggered:
tools.append(self._build_ask_user_tool())
if stream_id == "queen":
tools.append(self._build_ask_user_multiple_tool())
# Workers/subagents can escalate blockers to the queen.
if stream_id not in ("queen", "judge"):
tools.append(self._build_escalate_tool())
@@ -629,12 +647,15 @@ class EventLoopNode(NodeProtocol):
# 6b. Drain injection queue
await self._drain_injection_queue(conversation)
# 6b1. Drain trigger queue (framework-level signals)
await self._drain_trigger_queue(conversation)
# 6b2. Dynamic tool refresh (mode switching)
if ctx.dynamic_tools_provider is not None:
_synthetic_names = {
"set_output",
"ask_user",
"ask_user_multiple",
"escalate",
"delegate_to_sub_agent",
"report_to_parent",
@@ -653,8 +674,20 @@ class EventLoopNode(NodeProtocol):
conversation.update_system_prompt(_new_prompt)
logger.info("[%s] Dynamic prompt updated (phase switch)", node_id)
# 6c. Publish iteration event
await self._publish_iteration(stream_id, node_id, iteration, execution_id)
# 6c. Publish iteration event (with per-iteration metadata when available)
_iter_meta = None
if ctx.iteration_metadata_provider is not None:
try:
_iter_meta = ctx.iteration_metadata_provider()
except Exception:
pass
await self._publish_iteration(
stream_id,
node_id,
iteration,
execution_id,
extra_data=_iter_meta,
)
# 6d. Pre-turn compaction check (tiered)
_compacted_this_iter = False
@@ -712,6 +745,7 @@ class EventLoopNode(NodeProtocol):
model=turn_tokens.get("model", ""),
input_tokens=turn_tokens.get("input", 0),
output_tokens=turn_tokens.get("output", 0),
cached_tokens=turn_tokens.get("cached", 0),
execution_id=execution_id,
iteration=iteration,
)
@@ -1058,7 +1092,13 @@ class EventLoopNode(NodeProtocol):
mcp_tool_calls = [
tc
for tc in logged_tool_calls
if tc.get("tool_name") not in ("set_output", "ask_user", "escalate")
if tc.get("tool_name")
not in (
"set_output",
"ask_user",
"ask_user_multiple",
"escalate",
)
]
if mcp_tool_calls:
fps = self._fingerprint_tool_calls(mcp_tool_calls)
@@ -1252,9 +1292,28 @@ class EventLoopNode(NodeProtocol):
iteration,
_cf_auto,
)
# Check for multi-question batch from ask_user_multiple
multi_qs = getattr(self, "_pending_multi_questions", None)
self._pending_multi_questions = None
got_input = await self._await_user_input(
ctx, prompt=_cf_prompt, options=ask_user_options
ctx,
prompt=_cf_prompt,
options=ask_user_options,
questions=multi_qs,
)
# Emit deferred tool_call_completed for ask_user / ask_user_multiple
deferred = getattr(self, "_deferred_tool_complete", None)
if deferred:
self._deferred_tool_complete = None
await self._publish_tool_completed(
deferred["stream_id"],
deferred["node_id"],
deferred["tool_use_id"],
deferred["tool_name"],
deferred["content"],
deferred["is_error"],
deferred["execution_id"],
)
logger.info("[%s] iter=%d: unblocked, got_input=%s", node_id, iteration, got_input)
if not got_input:
await self._publish_loop_completed(
@@ -1709,6 +1768,15 @@ class EventLoopNode(NodeProtocol):
await self._injection_queue.put((content, is_client_input))
self._input_ready.set()
async def inject_trigger(self, trigger: TriggerEvent) -> None:
"""Inject a framework-level trigger into the running queen loop.
Triggers are queued separately from user messages and drained
atomically via _drain_trigger_queue().
"""
await self._trigger_queue.put(trigger)
self._input_ready.set()
def signal_shutdown(self) -> None:
"""Signal the node to exit its loop cleanly.
@@ -1736,6 +1804,7 @@ class EventLoopNode(NodeProtocol):
prompt: str = "",
*,
options: list[str] | None = None,
questions: list[dict] | None = None,
emit_client_request: bool = True,
) -> bool:
"""Block until user input arrives or shutdown is signaled.
@@ -1750,15 +1819,17 @@ class EventLoopNode(NodeProtocol):
options: Optional predefined choices for the user (from ask_user).
Passed through to the CLIENT_INPUT_REQUESTED event so the
frontend can render a QuestionWidget with buttons.
questions: Optional list of question dicts for ask_user_multiple.
Each dict has id, prompt, and optional options.
emit_client_request: When False, wait silently without publishing
CLIENT_INPUT_REQUESTED. Used for worker waits where input is
expected from the queen via inject_worker_message().
Returns True if input arrived, False if shutdown was signaled.
"""
# If messages arrived while the LLM was processing, skip blocking
# entirely — the next _drain_injection_queue() will pick them up.
if not self._injection_queue.empty():
# If messages or triggers arrived while the LLM was processing, skip
# blocking — the next drain pass will pick them up.
if not self._injection_queue.empty() or not self._trigger_queue.empty():
return True
# Clear BEFORE emitting so that synchronous handlers (e.g. the
@@ -1774,6 +1845,7 @@ class EventLoopNode(NodeProtocol):
prompt=prompt,
execution_id=ctx.execution_id or "",
options=options,
questions=questions,
)
self._awaiting_input = True
@@ -1833,7 +1905,7 @@ class EventLoopNode(NodeProtocol):
stream_id = ctx.stream_id or ctx.node_id
node_id = ctx.node_id
execution_id = ctx.execution_id or ""
token_counts: dict[str, int] = {"input": 0, "output": 0}
token_counts: dict[str, int] = {"input": 0, "output": 0, "cached": 0}
tool_call_count = 0
final_text = ""
final_system_prompt = conversation.system_prompt
@@ -1914,6 +1986,7 @@ class EventLoopNode(NodeProtocol):
elif isinstance(event, FinishEvent):
token_counts["input"] += event.input_tokens
token_counts["output"] += event.output_tokens
token_counts["cached"] += event.cached_tokens
token_counts["stop_reason"] = event.stop_reason
token_counts["model"] = event.model
@@ -2142,6 +2215,61 @@ class EventLoopNode(NodeProtocol):
)
results_by_id[tc.tool_use_id] = result
elif tc.tool_name == "ask_user_multiple":
# --- Framework-level ask_user_multiple ---
user_input_requested = True
raw_questions = tc.tool_input.get("questions", [])
if not isinstance(raw_questions, list) or len(raw_questions) < 2:
result = ToolResult(
tool_use_id=tc.tool_use_id,
content=(
"ERROR: questions must be an array of at "
"least 2 question objects. Use ask_user "
"for single questions."
),
is_error=True,
)
results_by_id[tc.tool_use_id] = result
user_input_requested = False
continue
# Normalize each question entry
questions: list[dict] = []
for i, q in enumerate(raw_questions):
if not isinstance(q, dict):
continue
qid = str(q.get("id", f"q{i + 1}"))
prompt = str(q.get("prompt", ""))
opts = q.get("options", None)
if isinstance(opts, list):
opts = [str(o) for o in opts if o]
if len(opts) < 2:
opts = None
else:
opts = None
questions.append(
{
"id": qid,
"prompt": prompt,
**({"options": opts} if opts else {}),
}
)
# Store as multi-question prompt/options for
# the event emission path
ask_user_prompt = ""
ask_user_options = None
# Pass the full questions list via a special
# key that the event emitter picks up
self._pending_multi_questions = questions
result = ToolResult(
tool_use_id=tc.tool_use_id,
content="Waiting for user input...",
is_error=False,
)
results_by_id[tc.tool_use_id] = result
elif tc.tool_name == "escalate":
# --- Framework-level escalate handling ---
reason = str(tc.tool_input.get("reason", "")).strip()
@@ -2388,6 +2516,7 @@ class EventLoopNode(NodeProtocol):
if tc.tool_name not in (
"set_output",
"ask_user",
"ask_user_multiple",
"escalate",
"delegate_to_sub_agent",
"report_to_parent",
@@ -2408,15 +2537,27 @@ class EventLoopNode(NodeProtocol):
content=result.content,
is_error=result.is_error,
)
await self._publish_tool_completed(
stream_id,
node_id,
tc.tool_use_id,
tc.tool_name,
result.content,
result.is_error,
execution_id,
)
if tc.tool_name in ("ask_user", "ask_user_multiple"):
# Defer tool_call_completed until after user responds
self._deferred_tool_complete = {
"stream_id": stream_id,
"node_id": node_id,
"tool_use_id": tc.tool_use_id,
"tool_name": tc.tool_name,
"content": result.content,
"is_error": result.is_error,
"execution_id": execution_id,
}
else:
await self._publish_tool_completed(
stream_id,
node_id,
tc.tool_use_id,
tc.tool_name,
result.content,
result.is_error,
execution_id,
)
# If the limit was hit, add error results for every remaining
# tool call so the conversation stays consistent. Without this,
@@ -2457,7 +2598,7 @@ class EventLoopNode(NodeProtocol):
# next turn. The char-based token estimator underestimates
# actual API tokens, so the standard compaction check in the
# outer loop may not trigger in time.
protect = max(2000, self._config.max_history_tokens // 12)
protect = max(2000, self._config.max_context_tokens // 12)
pruned = await conversation.prune_old_tool_results(
protect_tokens=protect,
min_prune_tokens=max(1000, protect // 3),
@@ -2466,7 +2607,7 @@ class EventLoopNode(NodeProtocol):
logger.info(
"Post-limit pruning: cleared %d old tool results (budget: %d)",
pruned,
self._config.max_history_tokens,
self._config.max_context_tokens,
)
# Limit hit — return from this turn so the judge can
# evaluate instead of looping back for another stream.
@@ -2487,7 +2628,7 @@ class EventLoopNode(NodeProtocol):
# --- Mid-turn pruning: prevent context blowup within a single turn ---
if conversation.usage_ratio() >= 0.6:
protect = max(2000, self._config.max_history_tokens // 12)
protect = max(2000, self._config.max_context_tokens // 12)
pruned = await conversation.prune_old_tool_results(
protect_tokens=protect,
min_prune_tokens=max(1000, protect // 3),
@@ -2580,6 +2721,72 @@ class EventLoopNode(NodeProtocol):
},
)
def _build_ask_user_multiple_tool(self) -> Tool:
"""Build the synthetic ask_user_multiple tool for batched questions.
Queen-only tool that presents multiple questions at once so the user
can answer them all in a single interaction rather than one at a time.
"""
return Tool(
name="ask_user_multiple",
description=(
"Ask the user multiple questions at once. Use this instead of "
"ask_user when you have 2 or more questions to ask in the same "
"turn — it lets the user answer everything in one go rather than "
"going back and forth. Each question can have its own predefined "
"options (2-3 choices) or be free-form. The UI renders all "
"questions together with a single Submit button. "
"ALWAYS prefer this over ask_user when you have multiple things "
"to clarify. "
"IMPORTANT: Do NOT repeat the questions in your text response — "
"the widget renders them. Keep your text to a brief intro only. "
'Example: {"questions": ['
' {"id": "scope", "prompt": "What scope?", "options": ["Full", "Partial"]},'
' {"id": "format", "prompt": "Output format?", "options": ["PDF", "CSV", "JSON"]},'
' {"id": "details", "prompt": "Any special requirements?"}'
"]}"
),
parameters={
"type": "object",
"properties": {
"questions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": (
"Short identifier for this question (used in the response)."
),
},
"prompt": {
"type": "string",
"description": "The question text shown to the user.",
},
"options": {
"type": "array",
"items": {"type": "string"},
"description": (
"2-3 predefined choices. The UI appends an "
"'Other' free-text input automatically. "
"Omit only when the user must type a free-form answer."
),
"minItems": 2,
"maxItems": 3,
},
},
"required": ["id", "prompt"],
},
"minItems": 2,
"maxItems": 8,
"description": "List of questions to present to the user.",
},
},
"required": ["questions"],
},
)
def _build_set_output_tool(self, output_keys: list[str] | None) -> Tool | None:
"""Build the synthetic set_output tool for explicit output declaration."""
if not output_keys:
@@ -2914,7 +3121,7 @@ class EventLoopNode(NodeProtocol):
phase_description=ctx.node_spec.description,
success_criteria=ctx.node_spec.success_criteria,
accumulator_state=accumulator.to_dict(),
max_history_tokens=self._config.max_history_tokens,
max_context_tokens=self._config.max_context_tokens,
)
if verdict.action != "ACCEPT":
return JudgeVerdict(
@@ -3354,7 +3561,7 @@ class EventLoopNode(NodeProtocol):
phase_grad = getattr(ctx, "continuous_mode", False)
# --- Step 1: Prune old tool results (free, no LLM) ---
protect = max(2000, self._config.max_history_tokens // 12)
protect = max(2000, self._config.max_context_tokens // 12)
pruned = await conversation.prune_old_tool_results(
protect_tokens=protect,
min_prune_tokens=max(1000, protect // 3),
@@ -3460,7 +3667,7 @@ class EventLoopNode(NodeProtocol):
accumulator,
formatted,
)
summary_budget = max(1024, self._config.max_history_tokens // 2)
summary_budget = max(1024, self._config.max_context_tokens // 2)
try:
response = await ctx.llm.acomplete(
messages=[{"role": "user", "content": prompt}],
@@ -3563,7 +3770,7 @@ class EventLoopNode(NodeProtocol):
elif spec.output_keys:
ctx_lines.append(f"OUTPUTS STILL NEEDED: {', '.join(spec.output_keys)}")
target_tokens = self._config.max_history_tokens // 2
target_tokens = self._config.max_context_tokens // 2
target_chars = target_tokens * 4
node_ctx = "\n".join(ctx_lines)
@@ -3879,6 +4086,34 @@ class EventLoopNode(NodeProtocol):
break
return count
async def _drain_trigger_queue(self, conversation: NodeConversation) -> int:
"""Drain all pending trigger events as a single batched user message.
Multiple triggers are merged so the LLM sees them atomically and can
reason about all pending triggers before acting.
"""
triggers: list[TriggerEvent] = []
while not self._trigger_queue.empty():
try:
triggers.append(self._trigger_queue.get_nowait())
except asyncio.QueueEmpty:
break
if not triggers:
return 0
parts: list[str] = []
for t in triggers:
task = t.payload.get("task", "")
task_line = f"\nTask: {task}" if task else ""
payload_str = json.dumps(t.payload, default=str)
parts.append(f"[TRIGGER: {t.trigger_type}/{t.source_id}]{task_line}\n{payload_str}")
combined = "\n\n".join(parts)
logger.info("[drain] %d trigger(s): %s", len(triggers), combined[:200])
await conversation.add_user_message(combined)
return len(triggers)
async def _check_pause(
self,
ctx: NodeContext,
@@ -4013,7 +4248,12 @@ class EventLoopNode(NodeProtocol):
await conversation.add_user_message(result.inject)
async def _publish_iteration(
self, stream_id: str, node_id: str, iteration: int, execution_id: str = ""
self,
stream_id: str,
node_id: str,
iteration: int,
execution_id: str = "",
extra_data: dict | None = None,
) -> None:
if self._event_bus:
await self._event_bus.emit_node_loop_iteration(
@@ -4021,6 +4261,7 @@ class EventLoopNode(NodeProtocol):
node_id=node_id,
iteration=iteration,
execution_id=execution_id,
extra_data=extra_data,
)
async def _publish_llm_turn_complete(
@@ -4031,6 +4272,7 @@ class EventLoopNode(NodeProtocol):
model: str,
input_tokens: int,
output_tokens: int,
cached_tokens: int = 0,
execution_id: str = "",
iteration: int | None = None,
) -> None:
@@ -4042,6 +4284,7 @@ class EventLoopNode(NodeProtocol):
model=model,
input_tokens=input_tokens,
output_tokens=output_tokens,
cached_tokens=cached_tokens,
execution_id=execution_id,
iteration=iteration,
)
@@ -4442,7 +4685,7 @@ class EventLoopNode(NodeProtocol):
max_iterations=max_iter, # Tighter budget
max_tool_calls_per_turn=self._config.max_tool_calls_per_turn,
tool_call_overflow_margin=self._config.tool_call_overflow_margin,
max_history_tokens=self._config.max_history_tokens,
max_context_tokens=self._config.max_context_tokens,
stall_detection_threshold=self._config.stall_detection_threshold,
max_tool_result_chars=self._config.max_tool_result_chars,
spillover_dir=subagent_spillover,
+15 -2
View File
@@ -34,6 +34,16 @@ from framework.schemas.checkpoint import Checkpoint
from framework.storage.checkpoint_store import CheckpointStore
def _default_max_context_tokens() -> int:
"""Resolve max_context_tokens from global config, falling back to 32000."""
try:
from framework.config import get_max_context_tokens
return get_max_context_tokens()
except Exception:
return 32_000
@dataclass
class ExecutionResult:
"""Result of executing a graph."""
@@ -138,6 +148,7 @@ class GraphExecutor:
tool_provider_map: dict[str, str] | None = None,
dynamic_tools_provider: Callable | None = None,
dynamic_prompt_provider: Callable | None = None,
iteration_metadata_provider: Callable | None = None,
):
"""
Initialize the executor.
@@ -183,6 +194,7 @@ class GraphExecutor:
self.tool_provider_map = tool_provider_map
self.dynamic_tools_provider = dynamic_tools_provider
self.dynamic_prompt_provider = dynamic_prompt_provider
self.iteration_metadata_provider = iteration_metadata_provider
# Parallel execution settings
self.enable_parallel_execution = enable_parallel_execution
@@ -330,7 +342,7 @@ class GraphExecutor:
_depth,
)
else:
max_tokens = getattr(conversation, "_max_history_tokens", 32000)
max_tokens = getattr(conversation, "_max_context_tokens", 32000)
target_tokens = max_tokens // 2
target_chars = target_tokens * 4
@@ -1799,6 +1811,7 @@ class GraphExecutor:
shared_node_registry=self.node_registry, # For subagent escalation routing
dynamic_tools_provider=self.dynamic_tools_provider,
dynamic_prompt_provider=self.dynamic_prompt_provider,
iteration_metadata_provider=self.iteration_metadata_provider,
)
VALID_NODE_TYPES = {
@@ -1872,7 +1885,7 @@ class GraphExecutor:
max_tool_calls_per_turn=lc.get("max_tool_calls_per_turn", 30),
tool_call_overflow_margin=lc.get("tool_call_overflow_margin", 0.5),
stall_detection_threshold=lc.get("stall_detection_threshold", 3),
max_history_tokens=lc.get("max_history_tokens", 32000),
max_context_tokens=lc.get("max_context_tokens", _default_max_context_tokens()),
max_tool_result_chars=lc.get("max_tool_result_chars", 30_000),
spillover_dir=spillover,
hooks=lc.get("hooks", {}),
+5
View File
@@ -565,6 +565,11 @@ class NodeContext:
# staging / running) without restarting the conversation.
dynamic_prompt_provider: Any = None # Callable[[], str] | None
# Per-iteration metadata provider — when set, EventLoopNode merges
# the returned dict into node_loop_iteration event data. Used by
# the queen to record the current phase per iteration.
iteration_metadata_provider: Any = None # Callable[[], dict] | None
@dataclass
class NodeResult:
+118 -4
View File
@@ -118,6 +118,25 @@ RATE_LIMIT_MAX_RETRIES = 10
RATE_LIMIT_BACKOFF_BASE = 2 # seconds
RATE_LIMIT_MAX_DELAY = 120 # seconds - cap to prevent absurd waits
MINIMAX_API_BASE = "https://api.minimax.io/v1"
# Providers that accept cache_control on message content blocks.
# Anthropic: native ephemeral caching. MiniMax & Z-AI/GLM: pass-through to their APIs.
# (OpenAI caches automatically server-side; Groq/Gemini/etc. strip the header.)
_CACHE_CONTROL_PREFIXES = (
"anthropic/",
"claude-",
"minimax/",
"minimax-",
"MiniMax-",
"zai-glm",
"glm-",
)
def _model_supports_cache_control(model: str) -> bool:
return any(model.startswith(p) for p in _CACHE_CONTROL_PREFIXES)
# Kimi For Coding uses an Anthropic-compatible endpoint (no /v1 suffix).
# Claude Code integration uses this format; the /v1 OpenAI-compatible endpoint
# enforces a coding-agent whitelist that blocks unknown User-Agents.
@@ -707,7 +726,10 @@ class LiteLLMProvider(LLMProvider):
full_messages: list[dict[str, Any]] = []
if system:
full_messages.append({"role": "system", "content": system})
sys_msg: dict[str, Any] = {"role": "system", "content": system}
if _model_supports_cache_control(self.model):
sys_msg["cache_control"] = {"type": "ephemeral"}
full_messages.append(sys_msg)
full_messages.extend(messages)
if json_mode:
@@ -878,7 +900,10 @@ class LiteLLMProvider(LLMProvider):
full_messages: list[dict[str, Any]] = []
if system:
full_messages.append({"role": "system", "content": system})
sys_msg: dict[str, Any] = {"role": "system", "content": system}
if _model_supports_cache_control(self.model):
sys_msg["cache_control"] = {"type": "ephemeral"}
full_messages.append(sys_msg)
full_messages.extend(messages)
# Codex Responses API requires an `instructions` field (system prompt).
@@ -943,9 +968,26 @@ class LiteLLMProvider(LLMProvider):
response = await litellm.acompletion(**kwargs) # type: ignore[union-attr]
async for chunk in response:
choice = chunk.choices[0] if chunk.choices else None
if not choice:
# Capture usage from the trailing usage-only chunk that
# stream_options={"include_usage": True} sends with empty choices.
if not chunk.choices:
usage = getattr(chunk, "usage", None)
if usage:
input_tokens = getattr(usage, "prompt_tokens", 0) or 0
output_tokens = getattr(usage, "completion_tokens", 0) or 0
logger.debug(
"[tokens] trailing usage chunk: input=%d output=%d model=%s",
input_tokens,
output_tokens,
self.model,
)
else:
logger.debug(
"[tokens] empty-choices chunk with no usage (model=%s)",
self.model,
)
continue
choice = chunk.choices[0]
delta = choice.delta
@@ -1018,19 +1060,91 @@ class LiteLLMProvider(LLMProvider):
tail_events.append(TextEndEvent(full_text=accumulated_text))
usage = getattr(chunk, "usage", None)
logger.debug(
"[tokens] finish-chunk raw usage: %r (type=%s)",
usage,
type(usage).__name__,
)
cached_tokens = 0
if usage:
input_tokens = getattr(usage, "prompt_tokens", 0) or 0
output_tokens = getattr(usage, "completion_tokens", 0) or 0
_details = getattr(usage, "prompt_tokens_details", None)
cached_tokens = (
getattr(_details, "cached_tokens", 0) or 0
if _details is not None
else getattr(usage, "cache_read_input_tokens", 0) or 0
)
logger.debug(
"[tokens] finish-chunk usage: "
"input=%d output=%d cached=%d model=%s",
input_tokens,
output_tokens,
cached_tokens,
self.model,
)
logger.debug(
"[tokens] finish event: input=%d output=%d cached=%d stop=%s model=%s",
input_tokens,
output_tokens,
cached_tokens,
choice.finish_reason,
self.model,
)
tail_events.append(
FinishEvent(
stop_reason=choice.finish_reason,
input_tokens=input_tokens,
output_tokens=output_tokens,
cached_tokens=cached_tokens,
model=self.model,
)
)
# Fallback: LiteLLM strips usage from yielded chunks before
# returning them to us, but appends the original chunk (with
# usage intact) to response.chunks first. Use LiteLLM's own
# calculate_total_usage() on that accumulated list.
if input_tokens == 0 and output_tokens == 0:
try:
from litellm.litellm_core_utils.streaming_handler import (
calculate_total_usage,
)
_chunks = getattr(response, "chunks", None)
if _chunks:
_usage = calculate_total_usage(chunks=_chunks)
input_tokens = _usage.prompt_tokens or 0
output_tokens = _usage.completion_tokens or 0
_details = getattr(_usage, "prompt_tokens_details", None)
cached_tokens = (
getattr(_details, "cached_tokens", 0) or 0
if _details is not None
else getattr(_usage, "cache_read_input_tokens", 0) or 0
)
logger.debug(
"[tokens] post-loop chunks fallback:"
" input=%d output=%d cached=%d model=%s",
input_tokens,
output_tokens,
cached_tokens,
self.model,
)
# Patch the FinishEvent already queued with 0 tokens
for _i, _ev in enumerate(tail_events):
if isinstance(_ev, FinishEvent) and _ev.input_tokens == 0:
tail_events[_i] = FinishEvent(
stop_reason=_ev.stop_reason,
input_tokens=input_tokens,
output_tokens=output_tokens,
cached_tokens=cached_tokens,
model=_ev.model,
)
break
except Exception as _e:
logger.debug("[tokens] chunks fallback failed: %s", _e)
# Check whether the stream produced any real content.
# (If text deltas were yielded above, has_content is True
# and we skip the retry path — nothing was yielded in vain.)
+1
View File
@@ -71,6 +71,7 @@ class FinishEvent:
stop_reason: str = ""
input_tokens: int = 0
output_tokens: int = 0
cached_tokens: int = 0
model: str = ""
+1 -33
View File
@@ -1,33 +1 @@
"""Framework-level worker monitoring package.
Provides the Worker Health Judge: a reusable secondary graph that attaches to
any worker agent runtime and monitors its execution health via periodic log
inspection. Emits structured EscalationTickets when degradation is detected.
Usage::
from framework.monitoring import HEALTH_JUDGE_ENTRY_POINT, judge_goal, judge_graph
from framework.tools.worker_monitoring_tools import register_worker_monitoring_tools
# Register tools bound to the worker runtime's EventBus
monitoring_registry = ToolRegistry()
register_worker_monitoring_tools(monitoring_registry, worker_runtime._event_bus, storage_path)
# Load judge as secondary graph on the worker runtime
await worker_runtime.add_graph(
graph_id="judge",
graph=judge_graph,
goal=judge_goal,
entry_points={"health_check": HEALTH_JUDGE_ENTRY_POINT},
storage_subpath="graphs/judge",
)
"""
from .judge import HEALTH_JUDGE_ENTRY_POINT, judge_goal, judge_graph, judge_node
__all__ = [
"HEALTH_JUDGE_ENTRY_POINT",
"judge_goal",
"judge_graph",
"judge_node",
]
"""Framework-level worker monitoring package."""
-258
View File
@@ -1,258 +0,0 @@
"""Worker Health Judge — framework-level reusable monitoring graph.
Attaches to any worker agent runtime as a secondary graph. Fires on a
2-minute timer, reads the worker's session logs via ``get_worker_health_summary``,
accumulates observations in a continuous conversation context, and emits a
structured ``EscalationTicket`` when it detects a degradation pattern.
Usage::
from framework.monitoring import judge_graph, judge_goal, HEALTH_JUDGE_ENTRY_POINT
from framework.tools.worker_monitoring_tools import register_worker_monitoring_tools
# Register tools bound to the worker runtime's event bus
monitoring_registry = ToolRegistry()
register_worker_monitoring_tools(
monitoring_registry, worker_runtime._event_bus, storage_path
)
monitoring_tools = list(monitoring_registry.get_tools().values())
monitoring_executor = monitoring_registry.get_executor()
# Load judge as secondary graph on the worker runtime
await worker_runtime.add_graph(
graph_id="judge",
graph=judge_graph,
goal=judge_goal,
entry_points={"health_check": HEALTH_JUDGE_ENTRY_POINT},
storage_subpath="graphs/judge",
)
Design:
- ``isolation_level="isolated"`` the judge has its own memory, not
polluting the worker's shared memory namespace.
- ``conversation_mode="continuous"`` the judge's conversation carries
across timer ticks. The conversation IS the judge's memory. It tracks
trends by referring to its own prior messages ("Last check I saw 47
steps; now 52; 5 new steps, 3 RETRY").
- No shared memory keys. No external state files.
"""
from __future__ import annotations
from framework.graph import Constraint, Goal, NodeSpec, SuccessCriterion
from framework.graph.edge import AsyncEntryPointSpec, GraphSpec
# ---------------------------------------------------------------------------
# Goal
# ---------------------------------------------------------------------------
judge_goal = Goal(
id="worker-health-monitor",
name="Worker Health Monitor",
description=(
"Periodically assess the health of the worker agent by reading its "
"execution logs. Detect degradation patterns (excessive retries, "
"stalls, doom loops) and emit structured EscalationTickets when the "
"worker needs attention."
),
success_criteria=[
SuccessCriterion(
id="accurate-detection",
description="Only escalates genuine degradation, not normal retry cycles",
metric="false_positive_rate",
target="low",
weight=0.5,
),
SuccessCriterion(
id="timely-detection",
description="Detects genuine stalls within 2 timer ticks (≤4 minutes)",
metric="detection_latency_minutes",
target="<=4",
weight=0.5,
),
],
constraints=[
Constraint(
id="conservative-escalation",
description=(
"Do not escalate on a single bad verdict or a brief stall. "
"Require clear patterns (10+ consecutive bad verdicts or 4+ minute stall) "
"before creating a ticket."
),
constraint_type="hard",
category="quality",
),
Constraint(
id="complete-ticket",
description=(
"Every EscalationTicket must have all required fields filled. "
"Do not emit partial or placeholder tickets."
),
constraint_type="hard",
category="correctness",
),
],
)
# ---------------------------------------------------------------------------
# Node
# ---------------------------------------------------------------------------
judge_node = NodeSpec(
id="judge",
name="Worker Health Judge",
description=(
"Autonomous health monitor for worker agents. Reads execution logs "
"on each timer tick, compares to prior observations (via conversation "
"history), and emits a structured EscalationTicket when a genuine "
"degradation pattern is detected."
),
node_type="event_loop",
client_facing=False, # Autonomous monitor, not interactive
max_node_visits=0, # Unbounded — runs on every timer tick
input_keys=[],
output_keys=["health_verdict"],
nullable_output_keys=["health_verdict"],
success_criteria=(
"A clear health verdict is produced each check: either 'healthy' with "
"a brief observation, or a complete EscalationTicket is emitted via "
"emit_escalation_ticket and health_verdict describes the issue."
),
tools=[
"get_worker_health_summary",
"emit_escalation_ticket",
],
system_prompt="""\
You are the Worker Health Judge. You run every 2 minutes alongside a worker \
agent to monitor its execution health.
# Your Role
You observe the worker's iteration patterns over time and escalate only when \
you see genuine degradation not normal retry cycles. Your conversation history \
IS your memory. On each check, refer to your previous observations to track trends.
# Check Procedure
On each timer tick (every 2 minutes):
## Step 1: Read health snapshot
Call get_worker_health_summary() with no arguments to auto-discover the active \
session. This returns:
- worker_agent_id: the worker's agent name — use this for ticket identity fields
- worker_graph_id: the worker's primary graph ID — use this for ticket identity fields
- session_id: the session being monitored use this for worker_session_id in tickets
- total_steps: how many log steps have been recorded
- recent_verdicts: list of recent ACCEPT/RETRY/CONTINUE verdicts
- steps_since_last_accept: consecutive non-ACCEPT steps
- stall_minutes: wall-clock since last step (null if active)
- evidence_snippet: recent LLM output
## Step 2: Compare to prior check
Look at your conversation history. What was total_steps last time?
- If total_steps is UNCHANGED from prior check AND prior check was also unchanged:
STALL confirmed (worker has produced no new iterations in 4+ minutes).
Escalate with severity="high" or "critical" depending on stall duration.
- If total_steps increased: worker is making progress. Examine verdicts.
## Step 3: Analyze verdict pattern
- Healthy: Mix of ACCEPT and RETRY, steps_since_last_accept < 5. No action.
- Warning: steps_since_last_accept is 5-9. Note it, no escalation yet.
- Degraded: steps_since_last_accept >= 10. Examine evidence_snippet.
- If evidence shows the agent is making real progress (complex reasoning,
exploring solutions, productive tool use): may be a hard problem. Note it.
- If evidence shows a loop (same error, same tool call, no new information):
Escalate with severity="medium" or "high".
- Critical: steps_since_last_accept >= 20, OR stall_minutes >= 4.
Escalate with severity="critical".
## Step 4: Decide
### If healthy:
set_output("health_verdict", "healthy: <brief observation>")
Done.
### If escalating:
Build an EscalationTicket JSON string with ALL required fields:
{
"worker_agent_id": "<worker_agent_id from get_worker_health_summary>",
"worker_session_id": "<session_id from get_worker_health_summary>",
"worker_node_id": "<worker_graph_id from get_worker_health_summary>",
"worker_graph_id": "<worker_graph_id from get_worker_health_summary>",
"severity": "<low|medium|high|critical>",
"cause": "<what you observed — concrete, specific>",
"judge_reasoning": "<why you decided to escalate, not just dismiss>",
"suggested_action": "<what you recommend: restart, human review, etc.>",
"recent_verdicts": [<list from get_worker_health_summary>],
"total_steps_checked": <int>,
"steps_since_last_accept": <int>,
"stall_minutes": <float or null>,
"evidence_snippet": "<from get_worker_health_summary>"
}
Call: emit_escalation_ticket(ticket_json=<the JSON string above>)
Then: set_output("health_verdict", "escalated: <one-line summary>")
# Severity Guide
- low: Mild concern, worth noting. 5-9 consecutive bad verdicts.
- medium: Clear degradation pattern. 10-15 bad verdicts or brief stall (1-2 min).
- high: Serious issue. 15+ bad verdicts or stall 2-4 minutes or clear doom loop.
- critical: Worker is definitively stuck. 20+ bad verdicts or stall > 4 minutes.
# Conservative Bias
You MUST resist the urge to escalate prematurely. Worker agents naturally retry.
A node may legitimately need 5-8 retries before succeeding. Do not escalate unless:
1. The pattern is clear and sustained across your observation window, AND
2. The evidence shows no genuine progress
One missed escalation is less costly than two false alarms. The Queen will filter \
further. But do not be passive genuine stalls and doom loops must be caught.
# Rules
- Never escalate on the FIRST check unless stall_minutes > 4
- Always call get_worker_health_summary FIRST before deciding anything
- All ticket fields are REQUIRED do not submit partial tickets
- After any emit_escalation_ticket call, always set_output to complete the check
""",
)
# ---------------------------------------------------------------------------
# Entry Point
# ---------------------------------------------------------------------------
HEALTH_JUDGE_ENTRY_POINT = AsyncEntryPointSpec(
id="health_check",
name="Worker Health Check",
entry_node="judge",
trigger_type="timer",
trigger_config={
"interval_minutes": 2,
"run_immediately": True, # Fire immediately to establish a baseline
},
isolation_level="isolated", # Own memory namespace, not polluting worker's
)
# ---------------------------------------------------------------------------
# Graph
# ---------------------------------------------------------------------------
judge_graph = GraphSpec(
id="judge-graph",
goal_id=judge_goal.id,
version="1.0.0",
entry_node="judge",
entry_points={"health_check": "judge"},
terminal_nodes=["judge"], # Judge node can terminate after each check
pause_nodes=[],
nodes=[judge_node],
edges=[],
conversation_mode="continuous", # Conversation persists across timer ticks
async_entry_points=[HEALTH_JUDGE_ENTRY_POINT],
loop_config={
"max_iterations": 10, # One check shouldn't take many turns
"max_tool_calls_per_turn": 3, # get_summary + optionally emit_ticket
"max_history_tokens": 16000, # Compact — judge only needs recent context
},
)
+3 -2
View File
@@ -148,8 +148,9 @@ class HumanReadableFormatter(logging.Formatter):
if record_event is not None:
event = f" [{record_event}]"
# Format message: [LEVEL] [trace context] message
return f"{color}[{level}]{reset} {context_prefix}{record.getMessage()}{event}"
timestamp = self.formatTime(record, "%Y-%m-%d %H:%M:%S")
# Format message: TIMESTAMP [LEVEL] [trace context] message
return f"{timestamp} {color}[{level}]{reset} {context_prefix}{record.getMessage()}{event}"
def configure_logging(
+15 -15
View File
@@ -243,6 +243,8 @@ def register_commands(subparsers: argparse._SubParsersAction) -> None:
action="store_true",
help="Open dashboard in browser after server starts",
)
serve_parser.add_argument("--verbose", "-v", action="store_true", help="Enable INFO log level")
serve_parser.add_argument("--debug", action="store_true", help="Enable DEBUG log level")
serve_parser.set_defaults(func=cmd_serve)
# open command (serve + auto-open browser)
@@ -280,6 +282,8 @@ def register_commands(subparsers: argparse._SubParsersAction) -> None:
default=None,
help="LLM model for preloaded agents",
)
open_parser.add_argument("--verbose", "-v", action="store_true", help="Enable INFO log level")
open_parser.add_argument("--debug", action="store_true", help="Enable DEBUG log level")
open_parser.set_defaults(func=cmd_open)
@@ -375,18 +379,18 @@ def _prompt_before_start(agent_path: str, runner, model: str | None = None):
def cmd_run(args: argparse.Namespace) -> int:
"""Run an exported agent."""
import logging
from framework.credentials.models import CredentialError
from framework.observability import configure_logging
from framework.runner import AgentRunner
# Set logging level (quiet by default for cleaner output)
if args.quiet:
logging.basicConfig(level=logging.ERROR, format="%(message)s")
configure_logging(level="ERROR")
elif getattr(args, "verbose", False):
logging.basicConfig(level=logging.INFO, format="%(message)s")
configure_logging(level="INFO")
else:
logging.basicConfig(level=logging.WARNING, format="%(message)s")
configure_logging(level="WARNING")
# Load input context
context = {}
@@ -918,16 +922,12 @@ def _format_natural_language_to_json(
def cmd_shell(args: argparse.Namespace) -> int:
"""Start an interactive agent session."""
import logging
from framework.credentials.models import CredentialError
from framework.observability import configure_logging
from framework.runner import AgentRunner
# Configure logging to show runtime visibility
logging.basicConfig(
level=logging.INFO,
format="%(message)s", # Simple format for clean output
)
configure_logging(level="INFO")
agents_dir = Path(args.agents_dir)
@@ -1625,18 +1625,18 @@ def _build_frontend() -> bool:
def cmd_serve(args: argparse.Namespace) -> int:
"""Start the HTTP API server."""
import logging
from aiohttp import web
_build_frontend()
from framework.observability import configure_logging
from framework.server.app import create_app
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
if getattr(args, "debug", False):
configure_logging(level="DEBUG")
else:
configure_logging(level="INFO")
model = getattr(args, "model", None)
app = create_app(model=model)
+24 -67
View File
@@ -9,14 +9,13 @@ from datetime import UTC
from pathlib import Path
from typing import TYPE_CHECKING, Any
from framework.config import get_hive_config, get_preferred_model
from framework.config import get_hive_config, get_max_context_tokens, get_preferred_model
from framework.credentials.validation import (
ensure_credential_key_env as _ensure_credential_key_env,
)
from framework.graph import Goal
from framework.graph.edge import (
DEFAULT_MAX_TOKENS,
AsyncEntryPointSpec,
EdgeCondition,
EdgeSpec,
GraphSpec,
@@ -570,9 +569,6 @@ class AgentInfo:
constraints: list[dict]
required_tools: list[str]
has_tools_module: bool
# Multi-entry-point support
async_entry_points: list[dict] = field(default_factory=list)
is_multi_entry_point: bool = False
@dataclass
@@ -630,22 +626,6 @@ def load_agent_export(data: str | dict) -> tuple[GraphSpec, Goal]:
)
edges.append(edge)
# Build AsyncEntryPointSpec objects for multi-entry-point support
async_entry_points = []
for aep_data in graph_data.get("async_entry_points", []):
async_entry_points.append(
AsyncEntryPointSpec(
id=aep_data["id"],
name=aep_data.get("name", aep_data["id"]),
entry_node=aep_data["entry_node"],
trigger_type=aep_data.get("trigger_type", "manual"),
trigger_config=aep_data.get("trigger_config", {}),
isolation_level=aep_data.get("isolation_level", "shared"),
priority=aep_data.get("priority", 0),
max_concurrent=aep_data.get("max_concurrent", 10),
)
)
# Build GraphSpec
graph = GraphSpec(
id=graph_data.get("id", "agent-graph"),
@@ -653,7 +633,6 @@ def load_agent_export(data: str | dict) -> tuple[GraphSpec, Goal]:
version=graph_data.get("version", "1.0.0"),
entry_node=graph_data.get("entry_node", ""),
entry_points=graph_data.get("entry_points", {}), # Support pause/resume architecture
async_entry_points=async_entry_points, # Support multi-entry-point agents
terminal_nodes=graph_data.get("terminal_nodes", []),
pause_nodes=graph_data.get("pause_nodes", []), # Support pause/resume architecture
nodes=nodes,
@@ -805,8 +784,6 @@ class AgentRunner:
# AgentRuntime — unified execution path for all agents
self._agent_runtime: AgentRuntime | None = None
self._uses_async_entry_points = self.graph.has_async_entry_points()
# Pre-load validation: structural checks + credentials.
# Fails fast with actionable guidance — no MCP noise on screen.
run_preload_validation(
@@ -926,10 +903,32 @@ class AgentRunner:
if agent_config and hasattr(agent_config, "max_tokens"):
max_tokens = agent_config.max_tokens
logger.info(
"Agent default_config overrides max_tokens: %d "
"(configuration.json value ignored)",
max_tokens,
)
else:
hive_config = get_hive_config()
max_tokens = hive_config.get("llm", {}).get("max_tokens", DEFAULT_MAX_TOKENS)
# Resolve max_context_tokens with priority:
# 1. agent loop_config["max_context_tokens"] (explicit, wins silently)
# 2. agent default_config.max_context_tokens (logged)
# 3. configuration.json llm.max_context_tokens
# 4. hardcoded default (32_000)
agent_loop_config: dict = dict(getattr(agent_module, "loop_config", {}))
if "max_context_tokens" not in agent_loop_config:
if agent_config and hasattr(agent_config, "max_context_tokens"):
agent_loop_config["max_context_tokens"] = agent_config.max_context_tokens
logger.info(
"Agent default_config overrides max_context_tokens: %d"
" (configuration.json value ignored)",
agent_config.max_context_tokens,
)
else:
agent_loop_config["max_context_tokens"] = get_max_context_tokens()
# Read intro_message from agent metadata (shown on TUI load)
agent_metadata = getattr(agent_module, "metadata", None)
intro_message = ""
@@ -943,13 +942,12 @@ class AgentRunner:
"version": "1.0.0",
"entry_node": getattr(agent_module, "entry_node", nodes[0].id),
"entry_points": getattr(agent_module, "entry_points", {}),
"async_entry_points": getattr(agent_module, "async_entry_points", []),
"terminal_nodes": getattr(agent_module, "terminal_nodes", []),
"pause_nodes": getattr(agent_module, "pause_nodes", []),
"nodes": nodes,
"edges": edges,
"max_tokens": max_tokens,
"loop_config": getattr(agent_module, "loop_config", {}),
"loop_config": agent_loop_config,
}
# Only pass optional fields if explicitly defined by the agent module
conversation_mode = getattr(agent_module, "conversation_mode", None)
@@ -1429,21 +1427,7 @@ class AgentRunner:
event_bus=None,
) -> None:
"""Set up multi-entry-point execution using AgentRuntime."""
# Convert AsyncEntryPointSpec to EntryPointSpec for AgentRuntime
entry_points = []
for async_ep in self.graph.async_entry_points:
ep = EntryPointSpec(
id=async_ep.id,
name=async_ep.name,
entry_node=async_ep.entry_node,
trigger_type=async_ep.trigger_type,
trigger_config=async_ep.trigger_config,
isolation_level=async_ep.isolation_level,
priority=async_ep.priority,
max_concurrent=async_ep.max_concurrent,
max_resurrections=async_ep.max_resurrections,
)
entry_points.append(ep)
# Always create a primary entry point for the graph's entry node.
# For multi-entry-point agents this ensures the primary path (e.g.
@@ -1750,19 +1734,6 @@ class AgentRunner:
for edge in self.graph.edges
]
# Build async entry points info
async_entry_points_info = [
{
"id": ep.id,
"name": ep.name,
"entry_node": ep.entry_node,
"trigger_type": ep.trigger_type,
"isolation_level": ep.isolation_level,
"max_concurrent": ep.max_concurrent,
}
for ep in self.graph.async_entry_points
]
return AgentInfo(
name=self.graph.id,
description=self.graph.description,
@@ -1789,8 +1760,6 @@ class AgentRunner:
],
required_tools=sorted(required_tools),
has_tools_module=(self.agent_path / "tools.py").exists(),
async_entry_points=async_entry_points_info,
is_multi_entry_point=self._uses_async_entry_points,
)
def validate(self) -> ValidationResult:
@@ -2105,18 +2074,6 @@ Respond with JSON only:
trigger_type="manual",
isolation_level="shared",
)
for aep in runner.graph.async_entry_points:
entry_points[aep.id] = EntryPointSpec(
id=aep.id,
name=aep.name,
entry_node=aep.entry_node,
trigger_type=aep.trigger_type,
trigger_config=aep.trigger_config,
isolation_level=aep.isolation_level,
priority=aep.priority,
max_concurrent=aep.max_concurrent,
)
await runtime.add_graph(
graph_id=gid,
graph=runner.graph,
+2 -2
View File
@@ -454,11 +454,11 @@ An agent has requested handoff to the Hive Coder (via the `escalate` synthetic t
## Worker Health Monitoring
These events form the **judge → queen → operator** escalation pipeline.
These events form the **queen → operator** escalation pipeline.
### `worker_escalation_ticket`
The Worker Health Judge has detected a degradation pattern and is escalating to the Queen.
A worker degradation pattern has been detected and is being escalated to the Queen.
| Data Field | Type | Description |
| ---------- | ------ | ------------------------------------ |
+5 -3
View File
@@ -8,6 +8,7 @@ while preserving the goal-driven approach.
import asyncio
import logging
import time
import uuid
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime
@@ -822,7 +823,8 @@ class AgentRuntime:
if stream is None:
raise ValueError(f"Entry point '{entry_point_id}' not found")
return await stream.execute(input_data, correlation_id, session_state)
run_id = uuid.uuid4().hex[:12]
return await stream.execute(input_data, correlation_id, session_state, run_id=run_id)
async def trigger_and_wait(
self,
@@ -1359,8 +1361,8 @@ class AgentRuntime:
allowed_keys = set(entry_node.input_keys)
# Search primary graph's streams for an active session.
# Skip isolated streams (e.g. health judge) — they have their own
# session directories and must never be used as a shared session.
# Skip isolated streams — they have their own session directories
# and must never be used as a shared session.
all_streams: list[tuple[str, ExecutionStream]] = []
for _gid, reg in self._graphs.items():
for ep_id, stream in reg.streams.items():
+5 -5
View File
@@ -1,4 +1,4 @@
"""EscalationTicket — structured schema for worker health judge escalations."""
"""EscalationTicket — structured schema for worker health escalations."""
from __future__ import annotations
@@ -10,10 +10,10 @@ from pydantic import BaseModel, Field
class EscalationTicket(BaseModel):
"""Structured escalation report emitted by the Worker Health Judge.
"""Structured escalation report for worker health monitoring.
The judge must fill every field before calling emit_escalation_ticket.
Pydantic validation rejects partial tickets, preventing impulsive escalation.
All fields must be filled before calling emit_escalation_ticket.
Pydantic validation rejects partial tickets.
"""
ticket_id: str = Field(default_factory=lambda: str(uuid4()))
@@ -25,7 +25,7 @@ class EscalationTicket(BaseModel):
worker_node_id: str
worker_graph_id: str
# Problem characterization (filled by judge via LLM deliberation)
# Problem characterization
severity: Literal["low", "medium", "high", "critical"]
cause: str # Human-readable: "Node has produced 18 RETRY verdicts..."
judge_reasoning: str # Judge's own deliberation chain
+186 -6
View File
@@ -97,6 +97,7 @@ class EventType(StrEnum):
# Client I/O (client_facing=True nodes only)
CLIENT_OUTPUT_DELTA = "client_output_delta"
CLIENT_INPUT_REQUESTED = "client_input_requested"
CLIENT_INPUT_RECEIVED = "client_input_received"
# Internal node observability (client_facing=False nodes)
NODE_INTERNAL_OUTPUT = "node_internal_output"
@@ -104,7 +105,7 @@ class EventType(StrEnum):
NODE_STALLED = "node_stalled"
NODE_TOOL_DOOM_LOOP = "node_tool_doom_loop"
# Judge decisions
# Judge decisions (implicit judge in event loop nodes)
JUDGE_VERDICT = "judge_verdict"
# Output tracking
@@ -126,7 +127,7 @@ class EventType(StrEnum):
# Escalation (agent requests handoff to queen)
ESCALATION_REQUESTED = "escalation_requested"
# Worker health monitoring (judge → queen → operator)
# Worker health monitoring
WORKER_ESCALATION_TICKET = "worker_escalation_ticket"
QUEEN_INTERVENTION_REQUESTED = "queen_intervention_requested"
@@ -137,6 +138,12 @@ class EventType(StrEnum):
WORKER_LOADED = "worker_loaded"
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_CHANGED = "queen_phase_changed"
@@ -146,6 +153,13 @@ class EventType(StrEnum):
# Subagent reports (one-way progress updates from sub-agents)
SUBAGENT_REPORT = "subagent_report"
# Trigger lifecycle (queen-level triggers / heartbeats)
TRIGGER_AVAILABLE = "trigger_available"
TRIGGER_ACTIVATED = "trigger_activated"
TRIGGER_DEACTIVATED = "trigger_deactivated"
TRIGGER_FIRED = "trigger_fired"
TRIGGER_REMOVED = "trigger_removed"
@dataclass
class AgentEvent:
@@ -159,10 +173,11 @@ class AgentEvent:
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)
run_id: str | None = None # Unique ID per trigger() invocation — used for run dividers
def to_dict(self) -> dict:
"""Convert to dictionary for serialization."""
return {
d = {
"type": self.type.value,
"stream_id": self.stream_id,
"node_id": self.node_id,
@@ -172,6 +187,9 @@ class AgentEvent:
"correlation_id": self.correlation_id,
"graph_id": self.graph_id,
}
if self.run_id is not None:
d["run_id"] = self.run_id
return d
# Type for event handlers
@@ -240,6 +258,127 @@ class EventBus:
self._semaphore = asyncio.Semaphore(max_concurrent_handlers)
self._subscription_counter = 0
self._lock = asyncio.Lock()
# Per-session persistent event log (always-on, survives restarts)
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
self._pending_output_snapshots: dict[tuple, AgentEvent] = {}
def set_session_log(self, path: Path, *, iteration_offset: int = 0) -> None:
"""Enable per-session event persistence to a JSONL file.
Called once when the queen starts so that all events survive server
restarts and can be replayed to reconstruct the frontend state.
``iteration_offset`` is added to the ``iteration`` field in logged
events so that cold-resumed sessions produce monotonically increasing
iteration values preventing frontend message ID collisions between
the original run and resumed runs.
"""
if self._session_log is not None:
try:
self._session_log.close()
except Exception:
pass
path.parent.mkdir(parents=True, exist_ok=True)
self._session_log = open(path, "a", encoding="utf-8") # noqa: SIM115
self._session_log_iteration_offset = iteration_offset
logger.info("Session event log → %s (iteration_offset=%d)", path, iteration_offset)
def close_session_log(self) -> None:
"""Close the per-session event log file."""
# Flush any pending output snapshots before closing
self._flush_pending_snapshots()
if self._session_log is not None:
try:
self._session_log.close()
except Exception:
pass
self._session_log = None
# Event types that are high-frequency streaming deltas — accumulated rather
# than written individually to the session log.
_STREAMING_DELTA_TYPES = frozenset(
{
EventType.CLIENT_OUTPUT_DELTA,
EventType.LLM_TEXT_DELTA,
EventType.LLM_REASONING_DELTA,
}
)
def _write_session_log_event(self, event: AgentEvent) -> None:
"""Write an event to the per-session log with streaming coalescing.
Streaming deltas (client_output_delta, llm_text_delta) are accumulated
in memory. When llm_turn_complete fires, any pending snapshots for that
(stream_id, node_id, execution_id) are flushed as single consolidated
events before the turn-complete event itself is written.
Note: iteration offset is already applied in publish() before this is
called, so events here already have correct iteration values.
"""
if self._session_log is None:
return
if event.type in self._STREAMING_DELTA_TYPES:
# Accumulate — keep only the latest event (which carries the full snapshot)
key = (
event.stream_id,
event.node_id,
event.execution_id,
event.data.get("iteration"),
)
self._pending_output_snapshots[key] = event
return
# On turn-complete, flush accumulated snapshots for this stream first
if event.type == EventType.LLM_TURN_COMPLETE:
self._flush_pending_snapshots(
stream_id=event.stream_id,
node_id=event.node_id,
execution_id=event.execution_id,
)
line = json.dumps(event.to_dict(), default=str)
self._session_log.write(line + "\n")
self._session_log.flush()
def _flush_pending_snapshots(
self,
stream_id: str | None = None,
node_id: str | None = None,
execution_id: str | None = None,
) -> None:
"""Flush accumulated streaming snapshots to the session log.
When called with filters, only matching entries are flushed.
When called without filters (e.g. on close), everything is flushed.
"""
if self._session_log is None or not self._pending_output_snapshots:
return
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
if k_stream != stream_id or k_node != node_id or k_exec != execution_id:
continue
to_flush.append(key)
for key in to_flush:
evt = self._pending_output_snapshots.pop(key)
try:
line = json.dumps(evt.to_dict(), default=str)
self._session_log.write(line + "\n")
except Exception:
pass
if to_flush:
try:
self._session_log.flush()
except Exception:
pass
def subscribe(
self,
@@ -305,6 +444,19 @@ class EventBus:
Args:
event: Event to publish
"""
# Apply iteration offset at the source so ALL consumers (SSE subscribers,
# event history, session log) see the same monotonically increasing
# iteration values. Without this, live SSE would use raw iterations
# while events.jsonl would use offset iterations, causing ID collisions
# on the frontend when replaying after cold resume.
if (
self._session_log_iteration_offset
and isinstance(event.data, dict)
and "iteration" in event.data
):
offset = self._session_log_iteration_offset
event.data = {**event.data, "iteration": event.data["iteration"] + offset}
# Add to history
async with self._lock:
self._event_history.append(event)
@@ -325,6 +477,15 @@ class EventBus:
except Exception:
pass # never break event delivery
# Per-session persistent log (always-on when set_session_log was called).
# Streaming deltas are coalesced: client_output_delta and llm_text_delta
# are accumulated and flushed as a single snapshot event on llm_turn_complete.
if self._session_log is not None:
try:
self._write_session_log_event(event)
except Exception:
pass # never break event delivery
# Find matching subscriptions
matching_handlers: list[EventHandler] = []
@@ -385,6 +546,7 @@ class EventBus:
execution_id: str,
input_data: dict[str, Any] | None = None,
correlation_id: str | None = None,
run_id: str | None = None,
) -> None:
"""Emit execution started event."""
await self.publish(
@@ -394,6 +556,7 @@ class EventBus:
execution_id=execution_id,
data={"input": input_data or {}},
correlation_id=correlation_id,
run_id=run_id,
)
)
@@ -403,6 +566,7 @@ class EventBus:
execution_id: str,
output: dict[str, Any] | None = None,
correlation_id: str | None = None,
run_id: str | None = None,
) -> None:
"""Emit execution completed event."""
await self.publish(
@@ -412,6 +576,7 @@ class EventBus:
execution_id=execution_id,
data={"output": output or {}},
correlation_id=correlation_id,
run_id=run_id,
)
)
@@ -421,6 +586,7 @@ class EventBus:
execution_id: str,
error: str,
correlation_id: str | None = None,
run_id: str | None = None,
) -> None:
"""Emit execution failed event."""
await self.publish(
@@ -430,6 +596,7 @@ class EventBus:
execution_id=execution_id,
data={"error": error},
correlation_id=correlation_id,
run_id=run_id,
)
)
@@ -521,15 +688,19 @@ class EventBus:
node_id: str,
iteration: int,
execution_id: str | None = None,
extra_data: dict[str, Any] | None = None,
) -> None:
"""Emit node loop iteration event."""
data: dict[str, Any] = {"iteration": iteration}
if extra_data:
data.update(extra_data)
await self.publish(
AgentEvent(
type=EventType.NODE_LOOP_ITERATION,
stream_id=stream_id,
node_id=node_id,
execution_id=execution_id,
data={"iteration": iteration},
data=data,
)
)
@@ -616,6 +787,7 @@ class EventBus:
model: str,
input_tokens: int,
output_tokens: int,
cached_tokens: int = 0,
execution_id: str | None = None,
iteration: int | None = None,
) -> None:
@@ -625,6 +797,7 @@ class EventBus:
"model": model,
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"cached_tokens": cached_tokens,
}
if iteration is not None:
data["iteration"] = iteration
@@ -722,16 +895,23 @@ class EventBus:
prompt: str = "",
execution_id: str | None = None,
options: list[str] | None = None,
questions: list[dict] | None = None,
) -> None:
"""Emit client input requested event (client_facing=True nodes).
Args:
options: Optional predefined choices for the user (1-3 items).
The frontend appends an "Other" free-text option automatically.
The frontend appends an "Other" free-text option
automatically.
questions: Optional list of question dicts for multi-question
batches (from ask_user_multiple). Each dict has id,
prompt, and optional options.
"""
data: dict[str, Any] = {"prompt": prompt}
if options:
data["options"] = options
if questions:
data["questions"] = questions
await self.publish(
AgentEvent(
type=EventType.CLIENT_INPUT_REQUESTED,
@@ -994,7 +1174,7 @@ class EventBus:
ticket: dict,
execution_id: str | None = None,
) -> None:
"""Emitted by health judge when worker shows a degradation pattern."""
"""Emitted when worker shows a degradation pattern."""
await self.publish(
AgentEvent(
type=EventType.WORKER_ESCALATION_TICKET,
+81 -3
View File
@@ -127,6 +127,7 @@ class ExecutionContext:
input_data: dict[str, Any]
isolation_level: IsolationLevel
session_state: dict[str, Any] | None = None # For resuming from pause
run_id: str | None = None # Unique ID per trigger() invocation
started_at: datetime = field(default_factory=datetime.now)
completed_at: datetime | None = None
status: str = "pending" # pending, running, completed, failed, paused
@@ -425,11 +426,36 @@ class ExecutionStream:
return True
return False
async def inject_trigger(
self,
node_id: str,
trigger: Any,
) -> bool:
"""Inject a trigger event into a running queen EventLoopNode.
Searches active executors for a node matching ``node_id`` and calls
its ``inject_trigger()`` method to wake the queen.
Args:
node_id: The queen EventLoopNode ID.
trigger: A ``TriggerEvent`` instance (typed as Any to avoid
circular imports with graph layer).
Returns True if the trigger was delivered, False otherwise.
"""
for executor in self._active_executors.values():
node = executor.node_registry.get(node_id)
if node is not None and hasattr(node, "inject_trigger"):
await node.inject_trigger(trigger)
return True
return False
async def execute(
self,
input_data: dict[str, Any],
correlation_id: str | None = None,
session_state: dict[str, Any] | None = None,
run_id: str | None = None,
) -> str:
"""
Queue an execution and return its ID.
@@ -440,6 +466,7 @@ class ExecutionStream:
input_data: Input data for this execution
correlation_id: Optional ID to correlate related executions
session_state: Optional session state to resume from (with paused_at, memory)
run_id: Unique ID for this trigger invocation (for run dividers)
Returns:
Execution ID for tracking
@@ -500,6 +527,7 @@ class ExecutionStream:
input_data=input_data,
isolation_level=self.entry_spec.get_isolation_level(),
session_state=session_state,
run_id=run_id,
)
async with self._lock:
@@ -575,7 +603,9 @@ class ExecutionStream:
execution_id=execution_id,
input_data=ctx.input_data,
correlation_id=ctx.correlation_id,
run_id=ctx.run_id,
)
self._write_run_event(execution_id, ctx.run_id, "run_started")
# Create execution-scoped memory
self._state_manager.create_memory(
@@ -740,6 +770,7 @@ class ExecutionStream:
execution_id=execution_id,
output=result.output,
correlation_id=ctx.correlation_id,
run_id=ctx.run_id,
)
elif result.paused_at:
# The executor returns paused_at on CancelledError but
@@ -757,8 +788,22 @@ class ExecutionStream:
execution_id=execution_id,
error=result.error or "Unknown error",
correlation_id=ctx.correlation_id,
run_id=ctx.run_id,
)
# Write run event for historical restoration
if result.success:
self._write_run_event(execution_id, ctx.run_id, "run_completed")
elif result.paused_at:
self._write_run_event(execution_id, ctx.run_id, "run_paused")
else:
self._write_run_event(
execution_id,
ctx.run_id,
"run_failed",
{"error": result.error or "Unknown error"},
)
logger.debug(f"Execution {execution_id} completed: success={result.success}")
except asyncio.CancelledError:
@@ -818,8 +863,10 @@ class ExecutionStream:
execution_id=execution_id,
error=cancel_reason,
correlation_id=ctx.correlation_id,
run_id=ctx.run_id,
)
self._write_run_event(execution_id, ctx.run_id, "run_cancelled")
# Don't re-raise - we've handled it and saved state
except Exception as e:
@@ -856,7 +903,9 @@ class ExecutionStream:
execution_id=execution_id,
error=str(e),
correlation_id=ctx.correlation_id,
run_id=ctx.run_id,
)
self._write_run_event(execution_id, ctx.run_id, "run_failed", {"error": str(e)})
finally:
# Clean up state
@@ -872,6 +921,36 @@ class ExecutionStream:
self._completion_events.pop(execution_id, None)
self._execution_tasks.pop(execution_id, None)
def _write_run_event(
self,
execution_id: str,
run_id: str | None,
event: str,
extra: dict[str, Any] | None = None,
) -> None:
"""Append a run lifecycle event to runs.jsonl for historical restoration."""
if not self._session_store or not run_id:
return
import json as _json
session_dir = self._session_store.get_session_path(execution_id)
runs_file = session_dir / "runs.jsonl"
now = datetime.now()
record = {
"run_id": run_id,
"event": event,
"timestamp": now.isoformat(),
"created_at": now.timestamp(),
}
if extra:
record.update(extra)
try:
runs_file.parent.mkdir(parents=True, exist_ok=True)
with open(runs_file, "a", encoding="utf-8") as f:
f.write(_json.dumps(record) + "\n")
except OSError:
pass # Non-critical — don't break execution
async def _write_session_state(
self,
execution_id: str,
@@ -978,8 +1057,8 @@ class ExecutionStream:
def _create_modified_graph(self) -> "GraphSpec":
"""Create a graph with the entry point overridden.
Preserves the original graph's entry_points and async_entry_points
so that validation correctly considers ALL entry nodes reachable.
Preserves the original graph's entry_points so that validation
correctly considers ALL entry nodes reachable.
Each stream only executes from its own entry_node, but the full
graph must validate with all entry points accounted for.
"""
@@ -1004,7 +1083,6 @@ class ExecutionStream:
version=self.graph.version,
entry_node=self.entry_spec.entry_node, # Use our entry point
entry_points=merged_entry_points,
async_entry_points=self.graph.async_entry_points,
terminal_nodes=self.graph.terminal_nodes,
pause_nodes=self.graph.pause_nodes,
nodes=self.graph.nodes,
@@ -17,7 +17,7 @@ from pathlib import Path
import pytest
from framework.graph import Goal
from framework.graph.edge import AsyncEntryPointSpec, EdgeCondition, EdgeSpec, GraphSpec
from framework.graph.edge import EdgeCondition, EdgeSpec, GraphSpec
from framework.graph.goal import Constraint, SuccessCriterion
from framework.graph.node import NodeSpec
from framework.runtime.agent_runtime import AgentRuntime, create_agent_runtime
@@ -101,30 +101,12 @@ def sample_graph():
),
]
async_entry_points = [
AsyncEntryPointSpec(
id="webhook",
name="Webhook Handler",
entry_node="process-webhook",
trigger_type="webhook",
isolation_level="shared",
),
AsyncEntryPointSpec(
id="api",
name="API Handler",
entry_node="process-api",
trigger_type="api",
isolation_level="shared",
),
]
return GraphSpec(
id="test-graph",
goal_id="test-goal",
version="1.0.0",
entry_node="process-webhook",
entry_points={"start": "process-webhook"},
async_entry_points=async_entry_points,
terminal_nodes=["complete"],
pause_nodes=[],
nodes=nodes,
@@ -504,108 +486,6 @@ class TestAgentRuntime:
# === GraphSpec Validation Tests ===
class TestGraphSpecValidation:
"""Tests for GraphSpec with async_entry_points."""
def test_has_async_entry_points(self, sample_graph):
"""Test checking for async entry points."""
assert sample_graph.has_async_entry_points() is True
# Graph without async entry points
simple_graph = GraphSpec(
id="simple",
goal_id="goal",
entry_node="start",
nodes=[],
edges=[],
)
assert simple_graph.has_async_entry_points() is False
def test_get_async_entry_point(self, sample_graph):
"""Test getting async entry point by ID."""
ep = sample_graph.get_async_entry_point("webhook")
assert ep is not None
assert ep.id == "webhook"
assert ep.entry_node == "process-webhook"
ep_not_found = sample_graph.get_async_entry_point("nonexistent")
assert ep_not_found is None
def test_validate_async_entry_points(self):
"""Test validation catches async entry point errors."""
nodes = [
NodeSpec(
id="valid-node",
name="Valid Node",
description="A valid node",
node_type="event_loop",
input_keys=[],
output_keys=[],
),
]
# Invalid entry node
graph = GraphSpec(
id="test",
goal_id="goal",
entry_node="valid-node",
async_entry_points=[
AsyncEntryPointSpec(
id="invalid",
name="Invalid",
entry_node="nonexistent-node",
trigger_type="webhook",
),
],
nodes=nodes,
edges=[],
)
errors = graph.validate()["errors"]
assert any("nonexistent-node" in e for e in errors)
# Invalid isolation level
graph2 = GraphSpec(
id="test",
goal_id="goal",
entry_node="valid-node",
async_entry_points=[
AsyncEntryPointSpec(
id="bad-isolation",
name="Bad Isolation",
entry_node="valid-node",
trigger_type="webhook",
isolation_level="invalid",
),
],
nodes=nodes,
edges=[],
)
errors2 = graph2.validate()["errors"]
assert any("isolation_level" in e for e in errors2)
# Invalid trigger type
graph3 = GraphSpec(
id="test",
goal_id="goal",
entry_node="valid-node",
async_entry_points=[
AsyncEntryPointSpec(
id="bad-trigger",
name="Bad Trigger",
entry_node="valid-node",
trigger_type="invalid_trigger",
),
],
nodes=nodes,
edges=[],
)
errors3 = graph3.validate()["errors"]
assert any("trigger_type" in e for e in errors3)
# === Integration Tests ===
@@ -483,7 +483,6 @@ class TestEventDrivenEntryPoints:
version="1.0.0",
entry_node="process-event",
entry_points={"start": "process-event"},
async_entry_points=[],
terminal_nodes=[],
pause_nodes=[],
nodes=nodes,
+22
View File
@@ -0,0 +1,22 @@
"""Trigger definitions for queen-level heartbeats (timers, webhooks)."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class TriggerDefinition:
"""A registered trigger that can be activated on the queen runtime.
Trigger *definitions* come from the worker's ``triggers.json``.
Activation state is per-session (persisted in ``SessionState.active_triggers``).
"""
id: str
trigger_type: str # "timer" | "webhook"
trigger_config: dict[str, Any] = field(default_factory=dict)
description: str = ""
task: str = ""
active: bool = False
+7
View File
@@ -144,6 +144,13 @@ class SessionState(BaseModel):
checkpoint_enabled: bool = False
latest_checkpoint_id: str | None = None
# Trigger activation state (IDs of triggers the queen/user turned on)
active_triggers: list[str] = Field(default_factory=list)
# Per-trigger task strings (user overrides, keyed by trigger ID)
trigger_tasks: dict[str, str] = Field(default_factory=dict)
# True after first successful worker execution (gates trigger delivery on restart)
worker_configured: bool = Field(default=False)
model_config = {"extra": "allow"}
@computed_field
+23
View File
@@ -94,6 +94,29 @@ def sessions_dir(session: Session) -> Path:
return Path.home() / ".hive" / "agents" / agent_name / "sessions"
def cold_sessions_dir(session_id: str) -> Path | None:
"""Resolve the worker sessions directory from disk for a cold/stopped session.
Reads agent_path from the queen session's meta.json to find the agent name,
then returns ~/.hive/agents/{agent_name}/sessions/.
Returns None if meta.json is missing or has no agent_path.
"""
import json
meta_path = Path.home() / ".hive" / "queen" / "session" / session_id / "meta.json"
if not meta_path.exists():
return None
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
agent_path = meta.get("agent_path")
if not agent_path:
return None
agent_name = Path(agent_path).name
return Path.home() / ".hive" / "agents" / agent_name / "sessions"
except (json.JSONDecodeError, OSError):
return None
# Allowed CORS origins (localhost on any port)
_CORS_ORIGINS = {"http://localhost", "http://127.0.0.1"}
+27 -1
View File
@@ -90,6 +90,28 @@ async def create_queen(
phase_state = QueenPhaseState(phase=initial_phase, event_bus=session.event_bus)
session.phase_state = phase_state
# ---- Track ask rounds during planning ----------------------------
# Increment planning_ask_rounds each time the queen requests user
# input (ask_user or ask_user_multiple) while in the planning phase.
async def _track_planning_asks(event: AgentEvent) -> None:
if phase_state.phase != "planning":
return
# Only count explicit ask_user / ask_user_multiple calls, not
# auto-block (text-only turns emit CLIENT_INPUT_REQUESTED with
# an empty prompt and no options/questions).
data = event.data or {}
has_prompt = bool(data.get("prompt"))
has_questions = bool(data.get("questions"))
has_options = bool(data.get("options"))
if has_prompt or has_questions or has_options:
phase_state.planning_ask_rounds += 1
session.event_bus.subscribe(
[EventType.CLIENT_INPUT_REQUESTED],
_track_planning_asks,
filter_stream="queen",
)
# ---- Lifecycle tools (always registered) --------------------------
register_queen_lifecycle_tools(
queen_registry,
@@ -149,7 +171,8 @@ async def create_queen(
worker_identity = (
"\n\n# Worker Profile\n"
"No worker agent loaded. You are operating independently.\n"
"Handle all tasks directly using your coding tools."
"Design or build the agent to solve the user's problem "
"according to your current phase."
)
_planning_body = (
@@ -252,6 +275,7 @@ async def create_queen(
execution_id=session.id,
dynamic_tools_provider=phase_state.get_current_tools,
dynamic_prompt_provider=phase_state.get_current_prompt,
iteration_metadata_provider=lambda: {"phase": phase_state.phase},
)
session.queen_executor = executor
@@ -269,6 +293,8 @@ async def create_queen(
return
if phase_state.phase == "running":
if event.type == EventType.EXECUTION_COMPLETED:
# Mark worker as configured after first successful run
session.worker_configured = True
output = event.data.get("output", {})
output_summary = ""
if output:
+9
View File
@@ -15,6 +15,7 @@ logger = logging.getLogger(__name__)
DEFAULT_EVENT_TYPES = [
EventType.CLIENT_OUTPUT_DELTA,
EventType.CLIENT_INPUT_REQUESTED,
EventType.CLIENT_INPUT_RECEIVED,
EventType.LLM_TEXT_DELTA,
EventType.TOOL_CALL_STARTED,
EventType.TOOL_CALL_COMPLETED,
@@ -40,6 +41,12 @@ DEFAULT_EVENT_TYPES = [
EventType.CREDENTIALS_REQUIRED,
EventType.SUBAGENT_REPORT,
EventType.QUEEN_PHASE_CHANGED,
EventType.TRIGGER_AVAILABLE,
EventType.TRIGGER_ACTIVATED,
EventType.TRIGGER_DEACTIVATED,
EventType.TRIGGER_FIRED,
EventType.TRIGGER_REMOVED,
EventType.DRAFT_GRAPH_UPDATED,
]
# Keepalive interval in seconds
@@ -89,6 +96,7 @@ async def handle_events(request: web.Request) -> web.StreamResponse:
"execution_failed",
"execution_paused",
"client_input_requested",
"client_input_received",
"node_loop_iteration",
"node_loop_started",
"credentials_required",
@@ -142,6 +150,7 @@ async def handle_events(request: web.Request) -> web.StreamResponse:
EventType.CLIENT_OUTPUT_DELTA.value,
EventType.EXECUTION_STARTED.value,
EventType.CLIENT_INPUT_REQUESTED.value,
EventType.CLIENT_INPUT_RECEIVED.value,
}
event_type_values = {et.value for et in event_types}
replay_types = _REPLAY_TYPES & event_type_values
+12
View File
@@ -125,6 +125,18 @@ async def handle_chat(request: web.Request) -> web.Response:
node = queen_executor.node_registry.get("queen")
if node is not None and hasattr(node, "inject_event"):
await node.inject_event(message, is_client_input=True)
# Publish to EventBus so the session event log captures user messages
from framework.runtime.event_bus import AgentEvent, EventType
await session.event_bus.publish(
AgentEvent(
type=EventType.CLIENT_INPUT_RECEIVED,
stream_id="queen",
node_id="queen",
execution_id=session.id,
data={"content": message},
)
)
return web.json_response(
{
"status": "queen",
+80
View File
@@ -2,6 +2,7 @@
import json
import logging
import time
from aiohttp import web
@@ -116,6 +117,20 @@ async def handle_list_nodes(request: web.Request) -> web.Response:
}
for ep in reg.entry_points.values()
]
# Append triggers from triggers.json (stored on session)
for t in getattr(session, "available_triggers", {}).values():
entry = {
"id": t.id,
"name": t.description or t.id,
"entry_node": graph.entry_node,
"trigger_type": t.trigger_type,
"trigger_config": t.trigger_config,
"task": t.task,
}
mono = getattr(session, "trigger_next_fire", {}).get(t.id)
if mono is not None:
entry["next_fire_in"] = max(0.0, mono - time.monotonic())
entry_points.append(entry)
return web.json_response(
{
"nodes": nodes,
@@ -234,8 +249,73 @@ async def handle_node_tools(request: web.Request) -> web.Response:
return web.json_response({"tools": tools_out})
async def handle_draft_graph(request: web.Request) -> web.Response:
"""Return the current draft graph from planning phase (if any)."""
session, err = resolve_session(request)
if err:
return err
phase_state = getattr(session, "phase_state", None)
if phase_state is None or phase_state.draft_graph is None:
return web.json_response({"draft": None})
return web.json_response({"draft": phase_state.draft_graph})
async def handle_flowchart_map(request: web.Request) -> web.Response:
"""Return the flowchart→runtime node mapping and the original (pre-dissolution) draft.
Available after confirm_and_build() dissolves decision nodes, or loaded
from the agent's flowchart.json file, or synthesized from the runtime graph.
"""
session, err = resolve_session(request)
if err:
return err
phase_state = getattr(session, "phase_state", None)
# Fast path: already in memory
if phase_state is not None and phase_state.original_draft_graph is not None:
return web.json_response(
{
"map": phase_state.flowchart_map,
"original_draft": phase_state.original_draft_graph,
}
)
# Try loading from flowchart.json in the agent folder
worker_path = getattr(session, "worker_path", None)
if worker_path is not None:
from pathlib import Path
target = Path(worker_path) / "flowchart.json"
if target.is_file():
try:
data = json.loads(target.read_text(encoding="utf-8"))
original_draft = data.get("original_draft")
fmap = data.get("flowchart_map")
# Cache in phase_state for future requests
if phase_state is not None and original_draft:
phase_state.original_draft_graph = original_draft
phase_state.flowchart_map = fmap
return web.json_response(
{
"map": fmap,
"original_draft": original_draft,
}
)
except Exception:
logger.warning("Failed to read flowchart.json from %s", worker_path)
return web.json_response({"map": None, "original_draft": None})
def register_routes(app: web.Application) -> None:
"""Register graph/node inspection routes."""
# Draft graph (planning phase — visual only, no loaded worker required)
app.router.add_get("/api/sessions/{session_id}/draft-graph", handle_draft_graph)
# Flowchart map (post-dissolution — maps runtime nodes to original draft nodes)
app.router.add_get("/api/sessions/{session_id}/flowchart-map", handle_flowchart_map)
# Session-primary routes
app.router.add_get("/api/sessions/{session_id}/graphs/{graph_id}/nodes", handle_list_nodes)
app.router.add_get(
+216 -49
View File
@@ -9,8 +9,10 @@ Session-primary routes:
- DELETE /api/sessions/{session_id}/worker unload worker from session
- GET /api/sessions/{session_id}/stats runtime statistics
- GET /api/sessions/{session_id}/entry-points list entry points
- PATCH /api/sessions/{session_id}/triggers/{id} update trigger task
- GET /api/sessions/{session_id}/graphs list graph IDs
- GET /api/sessions/{session_id}/queen-messages queen conversation history
- GET /api/sessions/{session_id}/events/history persisted eventbus log (for replay)
Worker session browsing (persisted execution runs on disk):
- GET /api/sessions/{session_id}/worker-sessions list
@@ -31,6 +33,7 @@ from pathlib import Path
from aiohttp import web
from framework.server.app import (
cold_sessions_dir,
resolve_session,
safe_path_segment,
sessions_dir,
@@ -229,6 +232,22 @@ async def handle_get_live_session(request: web.Request) -> web.Response:
}
for ep in rt.get_entry_points()
]
# Append triggers from triggers.json (stored on session)
runner = getattr(session, "runner", None)
graph_entry = runner.graph.entry_node if runner else ""
for t in getattr(session, "available_triggers", {}).values():
entry = {
"id": t.id,
"name": t.description or t.id,
"entry_node": graph_entry,
"trigger_type": t.trigger_type,
"trigger_config": t.trigger_config,
"task": t.task,
}
mono = getattr(session, "trigger_next_fire", {}).get(t.id)
if mono is not None:
entry["next_fire_in"] = max(0.0, mono - time.monotonic())
data["entry_points"].append(entry)
data["graphs"] = session.worker_runtime.list_graphs()
return web.json_response(data)
@@ -352,23 +371,84 @@ async def handle_session_entry_points(request: web.Request) -> web.Response:
rt = session.worker_runtime
eps = rt.get_entry_points() if rt else []
entry_points = [
{
"id": ep.id,
"name": ep.name,
"entry_node": ep.entry_node,
"trigger_type": ep.trigger_type,
"trigger_config": ep.trigger_config,
**(
{"next_fire_in": nf}
if rt and (nf := rt.get_timer_next_fire_in(ep.id)) is not None
else {}
),
}
for ep in eps
]
# Append triggers from triggers.json (stored on session)
runner = getattr(session, "runner", None)
graph_entry = runner.graph.entry_node if runner else ""
for t in getattr(session, "available_triggers", {}).values():
entry = {
"id": t.id,
"name": t.description or t.id,
"entry_node": graph_entry,
"trigger_type": t.trigger_type,
"trigger_config": t.trigger_config,
"task": t.task,
}
mono = getattr(session, "trigger_next_fire", {}).get(t.id)
if mono is not None:
entry["next_fire_in"] = max(0.0, mono - time.monotonic())
entry_points.append(entry)
return web.json_response({"entry_points": entry_points})
async def handle_update_trigger_task(request: web.Request) -> web.Response:
"""PATCH /api/sessions/{session_id}/triggers/{trigger_id} — update trigger task."""
session, err = resolve_session(request)
if err:
return err
trigger_id = request.match_info["trigger_id"]
available = getattr(session, "available_triggers", {})
tdef = available.get(trigger_id)
if tdef is None:
return web.json_response(
{"error": f"Trigger '{trigger_id}' not found"},
status=404,
)
try:
body = await request.json()
except Exception:
return web.json_response({"error": "Invalid JSON body"}, status=400)
task = body.get("task")
if task is None:
return web.json_response({"error": "Missing 'task' field"}, status=400)
if not isinstance(task, str):
return web.json_response({"error": "'task' must be a string"}, status=400)
tdef.task = task
# Persist to session state and agent definition
from framework.tools.queen_lifecycle_tools import (
_persist_active_triggers,
_save_trigger_to_agent,
)
if trigger_id in getattr(session, "active_trigger_ids", set()):
session_id = request.match_info["session_id"]
await _persist_active_triggers(session, session_id)
_save_trigger_to_agent(session, trigger_id, tdef)
return web.json_response(
{
"entry_points": [
{
"id": ep.id,
"name": ep.name,
"entry_node": ep.entry_node,
"trigger_type": ep.trigger_type,
"trigger_config": ep.trigger_config,
**(
{"next_fire_in": nf}
if rt and (nf := rt.get_timer_next_fire_in(ep.id)) is not None
else {}
),
}
for ep in eps
]
"trigger_id": trigger_id,
"task": tdef.task,
}
)
@@ -398,12 +478,15 @@ async def handle_list_worker_sessions(request: web.Request) -> web.Response:
"""List worker sessions on disk."""
session, err = resolve_session(request)
if err:
return err
if not session.worker_path:
return web.json_response({"sessions": []})
sess_dir = sessions_dir(session)
# Fall back to cold session lookup from disk
sid = request.match_info["session_id"]
sess_dir = cold_sessions_dir(sid)
if sess_dir is None:
return err
else:
if not session.worker_path:
return web.json_response({"sessions": []})
sess_dir = sessions_dir(session)
if not sess_dir.exists():
return web.json_response({"sessions": []})
@@ -565,48 +648,85 @@ async def handle_messages(request: web.Request) -> web.Response:
"""Get messages for a worker session."""
session, err = resolve_session(request)
if err:
return err
if not session.worker_path:
return web.json_response({"error": "No worker loaded"}, status=503)
# Fall back to cold session lookup from disk
sid = request.match_info["session_id"]
sess_dir = cold_sessions_dir(sid)
if sess_dir is None:
return err
else:
if not session.worker_path:
return web.json_response({"error": "No worker loaded"}, status=503)
sess_dir = sessions_dir(session)
ws_id = request.match_info.get("ws_id") or request.match_info.get("session_id", "")
ws_id = safe_path_segment(ws_id)
convs_dir = sessions_dir(session) / ws_id / "conversations"
convs_dir = sess_dir / ws_id / "conversations"
if not convs_dir.exists():
return web.json_response({"messages": []})
filter_node = request.query.get("node_id")
all_messages = []
for node_dir in convs_dir.iterdir():
if not node_dir.is_dir():
continue
if filter_node and node_dir.name != filter_node:
continue
parts_dir = node_dir / "parts"
def _collect_msg_parts(parts_dir: Path, node_id: str) -> None:
if not parts_dir.exists():
continue
return
for part_file in sorted(parts_dir.iterdir()):
if part_file.suffix != ".json":
continue
try:
part = json.loads(part_file.read_text(encoding="utf-8"))
part["_node_id"] = node_dir.name
part["_node_id"] = node_id
part.setdefault("created_at", part_file.stat().st_mtime)
all_messages.append(part)
except (json.JSONDecodeError, OSError):
continue
# Flat layout: conversations/parts/*.json
if not filter_node:
_collect_msg_parts(convs_dir / "parts", "worker")
# Node-based layout: conversations/<node_id>/parts/*.json
for node_dir in convs_dir.iterdir():
if not node_dir.is_dir() or node_dir.name == "parts":
continue
if filter_node and node_dir.name != filter_node:
continue
_collect_msg_parts(node_dir / "parts", node_dir.name)
# Merge run lifecycle markers from runs.jsonl (for historical dividers)
runs_file = sess_dir / ws_id / "runs.jsonl"
if runs_file.exists():
try:
for line in runs_file.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
record = json.loads(line)
all_messages.append(
{
"seq": -1,
"role": "system",
"content": "",
"_node_id": "_run_marker",
"is_run_marker": True,
"run_id": record.get("run_id"),
"run_event": record.get("event"),
"created_at": record.get("created_at", 0),
}
)
except json.JSONDecodeError:
continue
except OSError:
pass
all_messages.sort(key=lambda m: m.get("created_at", m.get("seq", 0)))
client_only = request.query.get("client_only", "").lower() in ("true", "1")
if client_only:
client_facing_nodes: set[str] = set()
if session.runner and hasattr(session.runner, "graph"):
if session and session.runner and hasattr(session.runner, "graph"):
for node in session.runner.graph.nodes:
if node.client_facing:
client_facing_nodes.add(node.id)
@@ -615,12 +735,15 @@ async def handle_messages(request: web.Request) -> web.Response:
all_messages = [
m
for m in all_messages
if not m.get("is_transition_marker")
and m["role"] != "tool"
and not (m["role"] == "assistant" and m.get("tool_calls"))
and (
(m["role"] == "user" and m.get("is_client_input"))
or (m["role"] == "assistant" and m.get("_node_id") in client_facing_nodes)
if m.get("is_run_marker")
or (
not m.get("is_transition_marker")
and m["role"] != "tool"
and not (m["role"] == "assistant" and m.get("tool_calls"))
and (
(m["role"] == "user" and m.get("is_client_input"))
or (m["role"] == "assistant" and m.get("_node_id") in client_facing_nodes)
)
)
]
@@ -641,18 +764,16 @@ async def handle_queen_messages(request: web.Request) -> web.Response:
return web.json_response({"messages": [], "session_id": session_id})
all_messages: list[dict] = []
for node_dir in convs_dir.iterdir():
if not node_dir.is_dir():
continue
parts_dir = node_dir / "parts"
def _read_parts(parts_dir: Path, node_id: str) -> None:
if not parts_dir.exists():
continue
return
for part_file in sorted(parts_dir.iterdir()):
if part_file.suffix != ".json":
continue
try:
part = json.loads(part_file.read_text(encoding="utf-8"))
part["_node_id"] = node_dir.name
part["_node_id"] = node_id
# Use file mtime as created_at so frontend can order
# queen and worker messages chronologically.
part.setdefault("created_at", part_file.stat().st_mtime)
@@ -660,6 +781,15 @@ async def handle_queen_messages(request: web.Request) -> web.Response:
except (json.JSONDecodeError, OSError):
continue
# Flat layout: conversations/parts/*.json
_read_parts(convs_dir / "parts", "queen")
# Node-based layout: conversations/<node_id>/parts/*.json
for node_dir in convs_dir.iterdir():
if not node_dir.is_dir() or node_dir.name == "parts":
continue
_read_parts(node_dir / "parts", node_dir.name)
all_messages.sort(key=lambda m: m.get("created_at", m.get("seq", 0)))
# Filter to client-facing messages only
@@ -674,6 +804,38 @@ async def handle_queen_messages(request: web.Request) -> web.Response:
return web.json_response({"messages": all_messages, "session_id": session_id})
async def handle_session_events_history(request: web.Request) -> web.Response:
"""GET /api/sessions/{session_id}/events/history — persisted eventbus log.
Reads ``events.jsonl`` from the session directory on disk so it works for
both live sessions and cold (post-server-restart) sessions. The frontend
replays these events through ``sseEventToChatMessage`` to fully reconstruct
the UI state on resume.
"""
session_id = request.match_info["session_id"]
queen_dir = Path.home() / ".hive" / "queen" / "session" / session_id
events_path = queen_dir / "events.jsonl"
if not events_path.exists():
return web.json_response({"events": [], "session_id": session_id})
events: list[dict] = []
try:
with open(events_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
events.append(json.loads(line))
except json.JSONDecodeError:
continue
except OSError:
return web.json_response({"events": [], "session_id": session_id})
return web.json_response({"events": events, "session_id": session_id})
async def handle_session_history(request: web.Request) -> web.Response:
"""GET /api/sessions/history — all queen sessions on disk (live + cold).
@@ -747,6 +909,7 @@ async def handle_discover(request: web.Request) -> web.Response:
"description": entry.description,
"category": entry.category,
"session_count": entry.session_count,
"run_count": entry.run_count,
"node_count": entry.node_count,
"tool_count": entry.tool_count,
"tags": entry.tags,
@@ -784,8 +947,12 @@ def register_routes(app: web.Application) -> None:
# Session info
app.router.add_get("/api/sessions/{session_id}/stats", handle_session_stats)
app.router.add_get("/api/sessions/{session_id}/entry-points", handle_session_entry_points)
app.router.add_patch(
"/api/sessions/{session_id}/triggers/{trigger_id}", handle_update_trigger_task
)
app.router.add_get("/api/sessions/{session_id}/graphs", handle_session_graphs)
app.router.add_get("/api/sessions/{session_id}/queen-messages", handle_queen_messages)
app.router.add_get("/api/sessions/{session_id}/events/history", handle_session_events_history)
# Worker session browsing (session-primary)
app.router.add_get("/api/sessions/{session_id}/worker-sessions", handle_list_worker_sessions)
+250 -145
View File
@@ -7,7 +7,6 @@ Architecture:
- Session owns EventBus + LLM, shared with queen and worker
- Queen is always present once a session starts
- Worker is optional loaded into an existing session
- Judge is active only when a worker is loaded
"""
import asyncio
@@ -15,11 +14,13 @@ import json
import logging
import time
import uuid
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any
from framework.runtime.triggers import TriggerDefinition
logger = logging.getLogger(__name__)
@@ -42,12 +43,23 @@ class Session:
worker_info: Any | None = None # AgentInfo
# Queen phase state (building/staging/running)
phase_state: Any = None # QueenPhaseState
# Judge (active when worker is loaded)
judge_task: asyncio.Task | None = None
escalation_sub: str | None = None
# Worker handoff subscription
worker_handoff_sub: str | None = None
# Memory consolidation subscription (fires on CONTEXT_COMPACTED)
memory_consolidation_sub: str | None = None
# Trigger definitions loaded from agent's triggers.json (available but inactive)
available_triggers: dict[str, TriggerDefinition] = field(default_factory=dict)
# Active trigger tracking (IDs currently firing + their asyncio tasks)
active_trigger_ids: set[str] = field(default_factory=set)
active_timer_tasks: dict[str, asyncio.Task] = field(default_factory=dict)
# Queen-owned webhook server (lazy singleton, created on first webhook trigger activation)
queen_webhook_server: Any = None
# EventBus subscription IDs for active webhook triggers (trigger_id -> sub_id)
active_webhook_subs: dict[str, str] = field(default_factory=dict)
# True after first successful worker execution (gates trigger delivery)
worker_configured: bool = False
# Monotonic timestamps for next trigger fire (mirrors AgentRuntime._timer_next_fire)
trigger_next_fire: dict[str, float] = field(default_factory=dict)
# Session directory resumption:
# When set, _start_queen writes queen conversations to this existing session's
# directory instead of creating a new one. This lets cold-restores accumulate
@@ -130,7 +142,9 @@ class SessionManager:
to that existing session's directory instead of creating a new one.
This preserves full conversation history across server restarts.
"""
session = await self._create_session_core(session_id=session_id, model=model)
# Reuse the original session ID when cold-restoring
resolved_session_id = queen_resume_from or session_id
session = await self._create_session_core(session_id=resolved_session_id, model=model)
session.queen_resume_from = queen_resume_from
# Start queen immediately (queen-only, no worker tools yet)
@@ -154,15 +168,21 @@ class SessionManager:
) -> Session:
"""Create a session and load a worker in one step.
When ``queen_resume_from`` is set the queen writes conversation messages
to that existing session's directory instead of creating a new one.
When ``queen_resume_from`` is set the session reuses the original session
ID so the frontend sees a single continuous session. The queen writes
conversation messages to that existing directory, preserving full history.
"""
from framework.tools.queen_lifecycle_tools import build_worker_profile
agent_path = Path(agent_path)
resolved_worker_id = agent_id or agent_path.name
session = await self._create_session_core(session_id=session_id, model=model)
# Reuse the original session ID when cold-restoring so the frontend
# sees one continuous session instead of a new one each time.
session = await self._create_session_core(
session_id=queen_resume_from,
model=model,
)
session.queen_resume_from = queen_resume_from
try:
# Load worker FIRST (before queen) so queen gets full tools
@@ -202,8 +222,8 @@ class SessionManager:
) -> None:
"""Load a worker agent into a session (core logic).
Sets up the runner, runtime, and session fields. Does NOT start the
judge or notify the queen callers handle those steps.
Sets up the runner, runtime, and session fields. Does NOT notify
the queen callers handle that step.
"""
from framework.runner import AgentRunner
@@ -242,6 +262,25 @@ class SessionManager:
runtime = runner._agent_runtime
# Load triggers from the agent's triggers.json definition file.
from framework.tools.queen_lifecycle_tools import _read_agent_triggers_json
for tdata in _read_agent_triggers_json(agent_path):
tid = tdata.get("id", "")
ttype = tdata.get("trigger_type", "")
if tid and ttype in ("timer", "webhook"):
session.available_triggers[tid] = TriggerDefinition(
id=tid,
trigger_type=ttype,
trigger_config=tdata.get("trigger_config", {}),
description=tdata.get("name", tid),
task=tdata.get("task", ""),
)
logger.info("Loaded trigger '%s' (%s) from triggers.json", tid, ttype)
if session.available_triggers:
await self._emit_trigger_events(session, "available", session.available_triggers)
# Start runtime on event loop
if runtime and not runtime.is_running:
await runtime.start()
@@ -369,7 +408,7 @@ class SessionManager:
) -> Session:
"""Load a worker agent into an existing session (with running queen).
Starts the worker runtime, health judge, and notifies the queen.
Starts the worker runtime and notifies the queen.
"""
agent_path = Path(agent_path)
@@ -385,11 +424,48 @@ class SessionManager:
)
# Notify queen about the loaded worker (skip for queen itself).
# Health judge disabled for simplicity.
if agent_path.name != "queen" and session.worker_runtime:
# await self._start_judge(session, session.runner._storage_path)
await self._notify_queen_worker_loaded(session)
# Restore previously active triggers from persisted session state
if session.available_triggers and session.worker_runtime:
try:
store = session.worker_runtime._session_store
state = await store.read_state(session_id)
if state and state.active_triggers:
from framework.tools.queen_lifecycle_tools import (
_start_trigger_timer,
_start_trigger_webhook,
)
saved_tasks = getattr(state, "trigger_tasks", {}) or {}
for tid in state.active_triggers:
tdef = session.available_triggers.get(tid)
if tdef:
# Restore user-configured task override
saved_task = saved_tasks.get(tid, "")
if saved_task:
tdef.task = saved_task
tdef.active = True
session.active_trigger_ids.add(tid)
if tdef.trigger_type == "timer":
await _start_trigger_timer(session, tid, tdef)
logger.info("Restored trigger timer '%s'", tid)
elif tdef.trigger_type == "webhook":
await _start_trigger_webhook(session, tid, tdef)
logger.info("Restored webhook trigger '%s'", tid)
else:
logger.warning(
"Saved trigger '%s' not found in worker entry points, skipping",
tid,
)
# Restore worker_configured flag
if state and getattr(state, "worker_configured", False):
session.worker_configured = True
except Exception as e:
logger.warning("Failed to restore active triggers: %s", e)
# Emit SSE event so the frontend can update UI
await self._emit_worker_loaded(session)
@@ -403,9 +479,6 @@ class SessionManager:
if session.worker_runtime is None:
return False
# Stop judge + escalation
self._stop_judge(session)
# Cleanup worker
if session.runner:
try:
@@ -413,6 +486,26 @@ class SessionManager:
except Exception as e:
logger.error("Error cleaning up worker '%s': %s", session.worker_id, e)
# Cancel active trigger timers
for tid, task in session.active_timer_tasks.items():
task.cancel()
logger.info("Cancelled trigger timer '%s' on unload", tid)
session.active_timer_tasks.clear()
# Unsubscribe webhook handlers (server stays alive — queen-owned)
for sub_id in session.active_webhook_subs.values():
try:
session.event_bus.unsubscribe(sub_id)
except Exception:
pass
session.active_webhook_subs.clear()
session.active_trigger_ids.clear()
# Clean up triggers
if session.available_triggers:
await self._emit_trigger_events(session, "removed", session.available_triggers)
session.available_triggers.clear()
worker_id = session.worker_id
session.worker_id = None
session.worker_path = None
@@ -443,8 +536,6 @@ class SessionManager:
_storage_id = getattr(session, "queen_resume_from", None) or session_id
_session_dir = Path.home() / ".hive" / "queen" / "session" / _storage_id
# Stop judge
self._stop_judge(session)
if session.worker_handoff_sub is not None:
try:
session.event_bus.unsubscribe(session.worker_handoff_sub)
@@ -464,6 +555,25 @@ class SessionManager:
session.queen_task = None
session.queen_executor = None
# Cancel active trigger timers
for task in session.active_timer_tasks.values():
task.cancel()
session.active_timer_tasks.clear()
# Unsubscribe webhook handlers and stop queen webhook server
for sub_id in session.active_webhook_subs.values():
try:
session.event_bus.unsubscribe(sub_id)
except Exception:
pass
session.active_webhook_subs.clear()
if session.queen_webhook_server is not None:
try:
await session.queen_webhook_server.stop()
except Exception:
logger.error("Error stopping queen webhook server", exc_info=True)
session.queen_webhook_server = None
# Cleanup worker
if session.runner:
try:
@@ -482,6 +592,9 @@ class SessionManager:
name=f"queen-memory-consolidation-{session_id}",
)
# Close per-session event log
session.event_bus.close_session_log()
logger.info("Session '%s' stopped", session_id)
return True
@@ -491,7 +604,7 @@ class SessionManager:
async def _handle_worker_handoff(self, session: Session, executor: Any, event: Any) -> None:
"""Route worker escalation events into the queen conversation."""
if event.stream_id in ("queen", "judge"):
if event.stream_id == "queen":
return
reason = str(event.data.get("reason", "")).strip()
@@ -580,6 +693,39 @@ class SessionManager:
except OSError:
pass
# Enable per-session event persistence so that all eventbus events
# survive server restarts and can be replayed on cold-session resume.
# Scan the existing event log to find the max iteration ever written,
# then use max+1 as offset so resumed sessions produce monotonically
# increasing iteration values — preventing frontend message ID collisions.
iteration_offset = 0
events_path = queen_dir / "events.jsonl"
try:
if events_path.exists():
max_iter = -1
with open(events_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
evt = json.loads(line)
it = evt.get("data", {}).get("iteration")
if isinstance(it, int) and it > max_iter:
max_iter = it
except (json.JSONDecodeError, TypeError):
continue
if max_iter >= 0:
iteration_offset = max_iter + 1
logger.info(
"Session '%s' resuming with iteration_offset=%d (from events.jsonl max)",
session.id,
iteration_offset,
)
except OSError:
pass
session.event_bus.set_session_log(events_path, iteration_offset=iteration_offset)
session.queen_task = await create_queen(
session=session,
session_manager=self,
@@ -588,6 +734,22 @@ class SessionManager:
initial_prompt=initial_prompt,
)
# Auto-load worker on cold restore — the queen's conversation expects
# the agent to be loaded, but the new session has no worker.
if session.queen_resume_from and not session.worker_runtime:
meta_path = queen_dir / "meta.json"
if meta_path.exists():
try:
_meta = json.loads(meta_path.read_text(encoding="utf-8"))
_agent_path = _meta.get("agent_path")
if _agent_path and Path(_agent_path).exists():
await self.load_worker(session.id, _agent_path)
if session.phase_state:
await session.phase_state.switch_to_staging(source="auto")
logger.info("Cold restore: auto-loaded worker from %s", _agent_path)
except Exception:
logger.warning("Cold restore: failed to auto-load worker", exc_info=True)
# Memory consolidation — triggered by context compaction events.
# Compaction is a natural signal that "enough has happened to be worth remembering".
_consolidation_llm = session.llm
@@ -607,116 +769,6 @@ class SessionManager:
handler=_on_compaction,
)
# ------------------------------------------------------------------
# Judge startup / teardown
# ------------------------------------------------------------------
async def _start_judge(
self,
session: Session,
worker_storage_path: str | Path,
) -> None:
"""Start the health judge for a session's worker."""
from framework.graph.executor import GraphExecutor
from framework.monitoring import judge_goal, judge_graph
from framework.runner.tool_registry import ToolRegistry
from framework.runtime.core import Runtime
from framework.runtime.event_bus import EventType as _ET
from framework.tools.worker_monitoring_tools import register_worker_monitoring_tools
worker_storage_path = Path(worker_storage_path)
try:
# Monitoring tools
monitoring_registry = ToolRegistry()
register_worker_monitoring_tools(
monitoring_registry,
session.event_bus,
worker_storage_path,
worker_graph_id=session.worker_runtime._graph_id,
)
hive_home = Path.home() / ".hive"
judge_dir = hive_home / "judge" / "session" / session.id
judge_dir.mkdir(parents=True, exist_ok=True)
judge_runtime = Runtime(hive_home / "judge")
monitoring_tools = list(monitoring_registry.get_tools().values())
monitoring_executor = monitoring_registry.get_executor()
async def _judge_loop():
interval = 300 # 5 minutes between checks
# Wait before the first check — let the worker actually do something
await asyncio.sleep(interval)
while True:
try:
executor = GraphExecutor(
runtime=judge_runtime,
llm=session.llm,
tools=monitoring_tools,
tool_executor=monitoring_executor,
event_bus=session.event_bus,
stream_id="judge",
storage_path=judge_dir,
loop_config=judge_graph.loop_config,
)
await executor.execute(
graph=judge_graph,
goal=judge_goal,
input_data={
"event": {"source": "timer", "reason": "scheduled"},
},
session_state={"resume_session_id": session.id},
)
except Exception:
logger.error("Health judge tick failed", exc_info=True)
await asyncio.sleep(interval)
session.judge_task = asyncio.create_task(_judge_loop())
# Escalation: judge → queen
async def _on_escalation(event):
ticket = event.data.get("ticket", {})
executor = session.queen_executor
if executor is None:
logger.warning("Escalation received but queen executor is None")
return
node = executor.node_registry.get("queen")
if node is not None and hasattr(node, "inject_event"):
msg = "[ESCALATION TICKET from Health Judge]\n" + json.dumps(
ticket, indent=2, ensure_ascii=False
)
await node.inject_event(msg)
else:
logger.warning("Escalation received but queen node not ready")
session.escalation_sub = session.event_bus.subscribe(
event_types=[_ET.WORKER_ESCALATION_TICKET],
handler=_on_escalation,
)
logger.info("Judge started for session '%s'", session.id)
except Exception as e:
logger.error(
"Failed to start judge for session '%s': %s",
session.id,
e,
exc_info=True,
)
def _stop_judge(self, session: Session) -> None:
"""Cancel judge task and unsubscribe escalation events."""
if session.judge_task is not None:
session.judge_task.cancel()
session.judge_task = None
if session.escalation_sub is not None:
try:
session.event_bus.unsubscribe(session.escalation_sub)
except Exception:
pass
session.escalation_sub = None
# ------------------------------------------------------------------
# Queen notifications
# ------------------------------------------------------------------
@@ -733,7 +785,22 @@ class SessionManager:
return
profile = build_worker_profile(session.worker_runtime, agent_path=session.worker_path)
await node.inject_event(f"[SYSTEM] Worker loaded.{profile}")
# Append available trigger info so the queen knows what's schedulable
trigger_lines = ""
if session.available_triggers:
parts = []
for t in session.available_triggers.values():
cfg = t.trigger_config
detail = cfg.get("cron") or f"every {cfg.get('interval_minutes', '?')} min"
task_info = f' -> task: "{t.task}"' if t.task else " (no task configured)"
parts.append(f" - {t.id} ({t.trigger_type}: {detail}){task_info}")
trigger_lines = (
"\n\nAvailable triggers (inactive — use set_trigger to activate):\n"
+ "\n".join(parts)
)
await node.inject_event(f"[SYSTEM] Worker loaded.{profile}{trigger_lines}")
async def _emit_worker_loaded(self, session: Session) -> None:
"""Publish a WORKER_LOADED event so the frontend can update."""
@@ -765,9 +832,35 @@ class SessionManager:
await node.inject_event(
"[SYSTEM] Worker unloaded. You are now operating independently. "
"Handle all tasks directly using your coding tools."
"Design or build the agent to solve the user's problem "
"according to your current phase."
)
async def _emit_trigger_events(
self,
session: Session,
kind: str,
triggers: dict[str, TriggerDefinition],
) -> None:
"""Emit TRIGGER_AVAILABLE or TRIGGER_REMOVED events for each trigger."""
from framework.runtime.event_bus import AgentEvent, EventType
event_type = (
EventType.TRIGGER_AVAILABLE if kind == "available" else EventType.TRIGGER_REMOVED
)
for t in triggers.values():
await session.event_bus.publish(
AgentEvent(
type=event_type,
stream_id="queen",
data={
"trigger_id": t.id,
"trigger_type": t.trigger_type,
"trigger_config": t.trigger_config,
},
)
)
async def revive_queen(self, session: Session, initial_prompt: str | None = None) -> None:
"""Revive a dead queen executor on an existing session.
@@ -839,13 +932,19 @@ class SessionManager:
# Check whether any message part files are actually present
has_messages = False
try:
for node_dir in convs_dir.iterdir():
if not node_dir.is_dir():
continue
parts_dir = node_dir / "parts"
if parts_dir.exists() and any(f.suffix == ".json" for f in parts_dir.iterdir()):
has_messages = True
break
# Flat layout: conversations/parts/*.json
flat_parts = convs_dir / "parts"
if flat_parts.exists() and any(f.suffix == ".json" for f in flat_parts.iterdir()):
has_messages = True
else:
# Node-based layout: conversations/<node_id>/parts/*.json
for node_dir in convs_dir.iterdir():
if not node_dir.is_dir() or node_dir.name == "parts":
continue
parts_dir = node_dir / "parts"
if parts_dir.exists() and any(f.suffix == ".json" for f in parts_dir.iterdir()):
has_messages = True
break
except OSError:
pass
@@ -922,21 +1021,27 @@ class SessionManager:
if convs_dir.exists():
try:
all_parts: list[dict] = []
for node_dir in convs_dir.iterdir():
if not node_dir.is_dir():
continue
parts_dir = node_dir / "parts"
def _collect_parts(parts_dir: Path, _dest: list[dict] = all_parts) -> None:
if not parts_dir.exists():
continue
return
for part_file in sorted(parts_dir.iterdir()):
if part_file.suffix != ".json":
continue
try:
part = json.loads(part_file.read_text(encoding="utf-8"))
part.setdefault("created_at", part_file.stat().st_mtime)
all_parts.append(part)
_dest.append(part)
except (json.JSONDecodeError, OSError):
continue
# Flat layout: conversations/parts/*.json
_collect_parts(convs_dir / "parts")
# Node-based layout: conversations/<node_id>/parts/*.json
for node_dir in convs_dir.iterdir():
if not node_dir.is_dir() or node_dir.name == "parts":
continue
_collect_parts(node_dir / "parts")
# Filter to client-facing messages only
client_msgs = [
p
File diff suppressed because it is too large Load Diff
@@ -78,19 +78,6 @@ def register_graph_tools(registry: ToolRegistry, runtime: AgentRuntime) -> int:
isolation_level="shared",
)
# Async entry points
for aep in runner.graph.async_entry_points:
entry_points[aep.id] = EntryPointSpec(
id=aep.id,
name=aep.name,
entry_node=aep.entry_node,
trigger_type=aep.trigger_type,
trigger_config=aep.trigger_config,
isolation_level=aep.isolation_level,
priority=aep.priority,
max_concurrent=aep.max_concurrent,
)
await runtime.add_graph(
graph_id=graph_id,
graph=runner.graph,
@@ -1,20 +1,17 @@
"""Worker monitoring tools for the Health Judge and Queen triage agents.
"""Worker monitoring tools for Queen triage agents.
Three tools are registered by ``register_worker_monitoring_tools()``:
- ``get_worker_health_summary`` reads the worker's session log files and
returns a compact health snapshot (recent verdicts, step count, timing).
session_id is optional: if omitted, the most recent active session is
auto-discovered from storage. No agent-side configuration required.
Used by the Health Judge on every timer tick.
auto-discovered from storage.
- ``emit_escalation_ticket`` validates and publishes an EscalationTicket
to the shared EventBus as a WORKER_ESCALATION_TICKET event.
Used by the Health Judge when it decides to escalate.
- ``notify_operator`` emits a QUEEN_INTERVENTION_REQUESTED event so the TUI
can surface a non-disruptive operator notification.
Used by the Queen's ticket_triage_node when it decides to intervene.
Usage::
@@ -45,7 +42,7 @@ def register_worker_monitoring_tools(
registry: ToolRegistry,
event_bus: EventBus,
storage_path: Path,
stream_id: str = "judge",
stream_id: str = "monitoring",
worker_graph_id: str | None = None,
) -> int:
"""Register worker monitoring tools bound to *event_bus* and *storage_path*.
@@ -55,7 +52,7 @@ def register_worker_monitoring_tools(
event_bus: The shared EventBus for the worker runtime.
storage_path: Root storage path of the worker runtime
(e.g. ``~/.hive/agents/{name}``).
stream_id: Stream ID used when emitting events; defaults to judge's stream.
stream_id: Stream ID used when emitting events.
worker_graph_id: The primary worker graph's ID. Included in health summary
so the judge can populate ticket identity fields accurately.
@@ -65,7 +62,7 @@ def register_worker_monitoring_tools(
from framework.llm.provider import Tool
storage_path = Path(storage_path)
# Derive agent identity from storage path so the judge can fill ticket fields.
# Derive agent identity from storage path for ticket fields.
# storage_path is ~/.hive/agents/{agent_name} — the name is the last component.
_worker_agent_id: str = storage_path.name
_worker_graph_id: str = worker_graph_id or storage_path.name
@@ -201,10 +198,9 @@ def register_worker_monitoring_tools(
description=(
"Read the worker agent's execution logs and return a compact health snapshot. "
"Returns worker_agent_id and worker_graph_id (use these for ticket identity fields), "
"recent judge verdicts, step count, time since last step, and "
"recent verdicts, step count, time since last step, and "
"a snippet of the most recent LLM output. "
"session_id is optional — omit it to auto-discover the most recent active session. "
"Use this on every health check to observe trends."
"session_id is optional — omit it to auto-discover the most recent active session."
),
parameters={
"type": "object",
@@ -241,8 +237,7 @@ def register_worker_monitoring_tools(
"""Validate and publish an EscalationTicket to the shared EventBus.
ticket_json must be a JSON string containing all required EscalationTicket
fields. The ticket is validated before publishing this ensures the judge
has genuinely filled out all required evidence fields.
fields. The ticket is validated before publishing.
Returns a confirmation JSON with the ticket_id on success, or an error.
"""
@@ -257,7 +252,7 @@ def register_worker_monitoring_tools(
try:
await event_bus.emit_worker_escalation_ticket(
stream_id=stream_id,
node_id="judge",
node_id="monitoring",
ticket=ticket.model_dump(),
)
logger.info(
@@ -280,7 +275,6 @@ def register_worker_monitoring_tools(
name="emit_escalation_ticket",
description=(
"Validate and publish a structured EscalationTicket to the shared EventBus. "
"The Queen's ticket_receiver entry point will fire and triage the ticket. "
"ticket_json must be a JSON string with all required EscalationTicket fields: "
"worker_agent_id, worker_session_id, worker_node_id, worker_graph_id, "
"severity (low/medium/high/critical), cause, judge_reasoning, suggested_action, "
+5
View File
@@ -38,4 +38,9 @@ export const api = {
body: body ? JSON.stringify(body) : undefined,
}),
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
patch: <T>(path: string, body?: unknown) =>
request<T>(path, {
method: "PATCH",
body: body ? JSON.stringify(body) : undefined,
}),
};
+11 -1
View File
@@ -1,5 +1,5 @@
import { api } from "./client";
import type { GraphTopology, NodeDetail, NodeCriteria, ToolInfo } from "./types";
import type { GraphTopology, NodeDetail, NodeCriteria, ToolInfo, DraftGraph, FlowchartMap } from "./types";
export const graphsApi = {
nodes: (sessionId: string, graphId: string, workerSessionId?: string) =>
@@ -26,4 +26,14 @@ export const graphsApi = {
api.get<{ tools: ToolInfo[] }>(
`/sessions/${sessionId}/graphs/${graphId}/nodes/${nodeId}/tools`,
),
draftGraph: (sessionId: string) =>
api.get<{ draft: DraftGraph | null }>(
`/sessions/${sessionId}/draft-graph`,
),
flowchartMap: (sessionId: string) =>
api.get<FlowchartMap>(
`/sessions/${sessionId}/flowchart-map`,
),
};
+10 -12
View File
@@ -1,11 +1,11 @@
import { api } from "./client";
import type {
AgentEvent,
LiveSession,
LiveSessionDetail,
SessionSummary,
SessionDetail,
Checkpoint,
Message,
EntryPoint,
} from "./types";
@@ -64,12 +64,18 @@ export const sessionsApi = {
`/sessions/${sessionId}/entry-points`,
),
updateTriggerTask: (sessionId: string, triggerId: string, task: string) =>
api.patch<{ trigger_id: string; task: string }>(
`/sessions/${sessionId}/triggers/${triggerId}`,
{ task },
),
graphs: (sessionId: string) =>
api.get<{ graphs: string[] }>(`/sessions/${sessionId}/graphs`),
/** Get queen conversation history for a session (works for cold/post-restart sessions too). */
queenMessages: (sessionId: string) =>
api.get<{ messages: Message[]; session_id: string }>(`/sessions/${sessionId}/queen-messages`),
/** Get persisted eventbus log for a session (works for cold sessions — used for full UI replay). */
eventsHistory: (sessionId: string) =>
api.get<{ events: AgentEvent[]; session_id: string }>(`/sessions/${sessionId}/events/history`),
/** List all queen sessions on disk — live + cold (post-restart). */
history: () =>
@@ -105,12 +111,4 @@ export const sessionsApi = {
api.post<{ execution_id: string }>(
`/sessions/${sessionId}/worker-sessions/${wsId}/checkpoints/${checkpointId}/restore`,
),
messages: (sessionId: string, wsId: string, nodeId?: string) => {
const params = new URLSearchParams({ client_only: "true" });
if (nodeId) params.set("node_id", nodeId);
return api.get<{ messages: Message[] }>(
`/sessions/${sessionId}/worker-sessions/${wsId}/messages?${params}`,
);
},
};
+63 -1
View File
@@ -31,6 +31,8 @@ export interface EntryPoint {
entry_node: string;
trigger_type: string;
trigger_config?: Record<string, unknown>;
/** Worker task string when this trigger fires autonomously. */
task?: string;
/** Seconds until the next timer fire (only present for timer entry points). */
next_fire_in?: number;
}
@@ -41,6 +43,7 @@ export interface DiscoverEntry {
description: string;
category: string;
session_count: number;
run_count: number;
node_count: number;
tool_count: number;
tags: string[];
@@ -191,6 +194,56 @@ export interface GraphTopology {
entry_points?: EntryPoint[];
}
// --- Draft graph types (planning phase) ---
export interface DraftNode {
id: string;
name: string;
description: string;
node_type: string;
tools: string[];
input_keys: string[];
output_keys: string[];
success_criteria: string;
sub_agents: string[];
/** For decision nodes: the yes/no question evaluated during dissolution. */
decision_clause?: string;
flowchart_type: string;
flowchart_shape: string;
flowchart_color: string;
}
export interface DraftEdge {
id: string;
source: string;
target: string;
condition: string;
description: string;
/** Short label shown on the flowchart edge (e.g. "Yes", "No"). */
label?: string;
}
export interface DraftGraph {
agent_name: string;
goal: string;
description: string;
success_criteria: string[];
constraints: string[];
nodes: DraftNode[];
edges: DraftEdge[];
entry_node: string;
terminal_nodes: string[];
flowchart_legend: Record<string, { shape: string; color: string }>;
}
/** Mapping from runtime graph nodes → original flowchart draft nodes. */
export interface FlowchartMap {
/** runtime_node_id → list of original draft node IDs it absorbed. */
map: Record<string, string[]> | null;
/** Original draft graph preserved before planning-node dissolution (decision + subagent). */
original_draft: DraftGraph | null;
}
export interface NodeCriteria {
node_id: string;
success_criteria: string | null;
@@ -261,6 +314,7 @@ export type EventTypeName =
| "tool_call_completed"
| "client_output_delta"
| "client_input_requested"
| "client_input_received"
| "node_internal_output"
| "node_input_blocked"
| "node_stalled"
@@ -276,7 +330,14 @@ export type EventTypeName =
| "worker_loaded"
| "credentials_required"
| "queen_phase_changed"
| "subagent_report";
| "subagent_report"
| "draft_graph_updated"
| "flowchart_map_updated"
| "trigger_available"
| "trigger_activated"
| "trigger_deactivated"
| "trigger_fired"
| "trigger_removed";
export interface AgentEvent {
type: EventTypeName;
@@ -287,4 +348,5 @@ export interface AgentEvent {
timestamp: string;
correlation_id: string | null;
graph_id: string | null;
run_id?: string | null;
}
+228 -84
View File
@@ -1,4 +1,4 @@
import { memo, useMemo, useState, useRef } from "react";
import { memo, useMemo, useState, useRef, useEffect, useCallback } from "react";
import { Play, Pause, Loader2, CheckCircle2 } from "lucide-react";
export type NodeStatus = "running" | "complete" | "pending" | "error" | "looping";
@@ -20,7 +20,7 @@ export interface GraphNode {
edgeLabels?: Record<string, string>;
}
type RunState = "idle" | "deploying" | "running";
export type RunState = "idle" | "deploying" | "running";
interface AgentGraphProps {
nodes: GraphNode[];
@@ -35,7 +35,7 @@ interface AgentGraphProps {
}
// --- Extracted RunButton so hover state survives parent re-renders ---
interface RunButtonProps {
export interface RunButtonProps {
runState: RunState;
disabled: boolean;
onRun: () => void;
@@ -43,7 +43,7 @@ interface RunButtonProps {
btnRef: React.Ref<HTMLButtonElement>;
}
const RunButton = memo(function RunButton({ runState, disabled, onRun, onPause, btnRef }: RunButtonProps) {
export const RunButton = memo(function RunButton({ runState, disabled, onRun, onPause, btnRef }: RunButtonProps) {
const [hovered, setHovered] = useState(false);
const showPause = runState === "running" && hovered;
@@ -89,46 +89,94 @@ const MARGIN_RIGHT = 50; // space for back-edge arcs
const SVG_BASE_W = 320;
const GAP_X = 12;
// Unified amber/gold palette
const statusColors: Record<NodeStatus, { dot: string; bg: string; border: string; glow: string }> = {
running: {
dot: "hsl(45,95%,58%)",
bg: "hsl(45,95%,58%,0.08)",
border: "hsl(45,95%,58%,0.5)",
glow: "hsl(45,95%,58%,0.15)",
},
looping: {
dot: "hsl(38,90%,55%)",
bg: "hsl(38,90%,55%,0.08)",
border: "hsl(38,90%,55%,0.5)",
glow: "hsl(38,90%,55%,0.15)",
},
complete: {
dot: "hsl(43,70%,45%)",
bg: "hsl(43,70%,45%,0.05)",
border: "hsl(43,70%,45%,0.25)",
glow: "none",
},
pending: {
dot: "hsl(35,15%,28%)",
bg: "hsl(35,10%,12%)",
border: "hsl(35,10%,20%)",
glow: "none",
},
error: {
dot: "hsl(0,65%,55%)",
bg: "hsl(0,65%,55%,0.06)",
border: "hsl(0,65%,55%,0.3)",
glow: "hsl(0,65%,55%,0.1)",
},
};
// Read a CSS custom property value (space-separated HSL components)
function cssVar(name: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
// Trigger node palette — cool blue-gray, visually distinct from amber execution nodes
const triggerColors = {
bg: "hsl(210,25%,14%)",
border: "hsl(210,30%,30%)",
text: "hsl(210,30%,65%)",
icon: "hsl(210,40%,55%)",
type StatusColorSet = Record<NodeStatus, { dot: string; bg: string; border: string; glow: string }>;
type TriggerColorSet = { bg: string; border: string; text: string; icon: string };
function buildStatusColors(): StatusColorSet {
const running = cssVar("--node-running") || "45 95% 58%";
const looping = cssVar("--node-looping") || "38 90% 55%";
const complete = cssVar("--node-complete") || "43 70% 45%";
const pending = cssVar("--node-pending") || "35 15% 28%";
const pendingBg = cssVar("--node-pending-bg") || "35 10% 12%";
const pendingBorder = cssVar("--node-pending-border") || "35 10% 20%";
const error = cssVar("--node-error") || "0 65% 55%";
return {
running: {
dot: `hsl(${running})`,
bg: `hsl(${running} / 0.08)`,
border: `hsl(${running} / 0.5)`,
glow: `hsl(${running} / 0.15)`,
},
looping: {
dot: `hsl(${looping})`,
bg: `hsl(${looping} / 0.08)`,
border: `hsl(${looping} / 0.5)`,
glow: `hsl(${looping} / 0.15)`,
},
complete: {
dot: `hsl(${complete})`,
bg: `hsl(${complete} / 0.05)`,
border: `hsl(${complete} / 0.25)`,
glow: "none",
},
pending: {
dot: `hsl(${pending})`,
bg: `hsl(${pendingBg})`,
border: `hsl(${pendingBorder})`,
glow: "none",
},
error: {
dot: `hsl(${error})`,
bg: `hsl(${error} / 0.06)`,
border: `hsl(${error} / 0.3)`,
glow: `hsl(${error} / 0.1)`,
},
};
}
function buildTriggerColors(): TriggerColorSet {
const bg = cssVar("--trigger-bg") || "210 25% 14%";
const border = cssVar("--trigger-border") || "210 30% 30%";
const text = cssVar("--trigger-text") || "210 30% 65%";
const icon = cssVar("--trigger-icon") || "210 40% 55%";
return {
bg: `hsl(${bg})`,
border: `hsl(${border})`,
text: `hsl(${text})`,
icon: `hsl(${icon})`,
};
}
/** Hook that reads node/trigger colors from CSS vars and updates on theme changes. */
function useThemeColors() {
const [statusColors, setStatusColors] = useState<StatusColorSet>(buildStatusColors);
const [triggerColors, setTriggerColors] = useState<TriggerColorSet>(buildTriggerColors);
useEffect(() => {
const rebuild = () => {
setStatusColors(buildStatusColors());
setTriggerColors(buildTriggerColors());
};
const obs = new MutationObserver(rebuild);
obs.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"] });
return () => obs.disconnect();
}, []);
return { statusColors, triggerColors };
}
// Active trigger — brighter, more saturated blue
const activeTriggerColors = {
bg: "hsl(210,30%,18%)",
border: "hsl(210,50%,50%)",
text: "hsl(210,40%,75%)",
icon: "hsl(210,60%,65%)",
};
const triggerIcons: Record<string, string> = {
@@ -146,10 +194,96 @@ function truncateLabel(label: string, availablePx: number, fontSize: number): st
return label.slice(0, Math.max(maxChars - 1, 1)) + "\u2026";
}
// ─── Pan & Zoom wrapper ───
function PanZoomSvg({ svgW, svgH, className, children }: { svgW: number; svgH: number; className?: string; children: React.ReactNode }) {
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [dragging, setDragging] = useState(false);
const dragStart = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
const MIN_ZOOM = 0.4;
const MAX_ZOOM = 3;
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
setZoom(z => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z * delta)));
}, []);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return;
setDragging(true);
dragStart.current = { x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y };
}, [pan]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!dragging) return;
setPan({
x: dragStart.current.panX + (e.clientX - dragStart.current.x),
y: dragStart.current.panY + (e.clientY - dragStart.current.y),
});
}, [dragging]);
const handleMouseUp = useCallback(() => setDragging(false), []);
const resetView = useCallback(() => {
setZoom(1);
setPan({ x: 0, y: 0 });
}, []);
return (
<div className="flex-1 relative overflow-hidden px-1 pb-5">
<div
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
className="w-full h-full"
style={{ cursor: dragging ? "grabbing" : "grab" }}
>
<svg
width="100%"
viewBox={`0 0 ${svgW} ${svgH}`}
preserveAspectRatio="xMidYMin meet"
className={`select-none ${className || ""}`}
style={{
fontFamily: "'Inter', system-ui, sans-serif",
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transformOrigin: "center top",
}}
>
{children}
</svg>
</div>
{/* Zoom controls */}
<div className="absolute bottom-7 right-3 flex items-center gap-1 bg-card/80 backdrop-blur-sm border border-border/40 rounded-lg p-0.5 shadow-sm">
<button
onClick={() => setZoom(z => Math.min(MAX_ZOOM, z * 1.2))}
className="w-6 h-6 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors text-xs font-bold"
aria-label="Zoom in"
>+</button>
<button
onClick={resetView}
className="px-1.5 h-6 flex items-center justify-center rounded text-[10px] font-mono text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
aria-label="Reset zoom"
>{Math.round(zoom * 100)}%</button>
<button
onClick={() => setZoom(z => Math.max(MIN_ZOOM, z * 0.8))}
className="w-6 h-6 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors text-xs font-bold"
aria-label="Zoom out"
>{"\u2212"}</button>
</div>
</div>
);
}
export default function AgentGraph({ nodes, title: _title, onNodeClick, onRun, onPause, version, runState: externalRunState, building, queenPhase }: AgentGraphProps) {
const [localRunState, setLocalRunState] = useState<RunState>("idle");
const runState = externalRunState ?? localRunState;
const runBtnRef = useRef<HTMLButtonElement>(null);
const { statusColors, triggerColors } = useThemeColors();
const handleRun = () => {
if (runState !== "idle") return;
@@ -344,18 +478,21 @@ export default function AgentGraph({ nodes, title: _title, onNodeClick, onRun, o
let d: string;
if (skipsLayers && hasCollision(fromLayer, toLayer, from.x, to.x)) {
// Route around intermediate nodes: curve to the left
// Route around intermediate nodes: orthogonal detour to the left
const detourX = Math.min(from.x, to.x) - nodeW * 0.4;
d = `M ${startX} ${y1} C ${startX} ${y1 + 20}, ${detourX} ${y1 + 20}, ${detourX} ${midY} S ${toCenterX} ${y2 - 20} ${toCenterX} ${y2}`;
d = `M ${startX} ${y1} L ${startX} ${midY} L ${detourX} ${midY} L ${detourX} ${y2 - 10} L ${toCenterX} ${y2 - 10} L ${toCenterX} ${y2}`;
} else if (Math.abs(startX - toCenterX) < 2) {
// Straight vertical line when aligned
d = `M ${startX} ${y1} L ${toCenterX} ${y2}`;
} else {
// Standard bezier: from source bottom to target top
d = `M ${startX} ${y1} C ${startX} ${midY}, ${toCenterX} ${midY}, ${toCenterX} ${y2}`;
// Orthogonal: down, across, down
d = `M ${startX} ${y1} L ${startX} ${midY} L ${toCenterX} ${midY} L ${toCenterX} ${y2}`;
}
const fromNode = nodes[edge.fromIdx];
const isActive = fromNode.status === "complete" || fromNode.status === "running" || fromNode.status === "looping";
const strokeColor = isActive ? "hsl(43,70%,45%,0.35)" : "hsl(35,10%,20%)";
const arrowColor = isActive ? "hsl(43,70%,45%,0.5)" : "hsl(35,10%,22%)";
const strokeColor = isActive ? statusColors.complete.border : statusColors.pending.border;
const arrowColor = isActive ? statusColors.complete.dot : statusColors.pending.border;
return (
<g key={`fwd-${i}`}>
@@ -368,7 +505,7 @@ export default function AgentGraph({ nodes, title: _title, onNodeClick, onRun, o
<text
x={(startX + toCenterX) / 2 + 8}
y={midY - 2}
fill="hsl(35,15%,40%)"
fill={statusColors.pending.dot}
fontSize={9}
fontStyle="italic"
>
@@ -394,9 +531,9 @@ export default function AgentGraph({ nodes, title: _title, onNodeClick, onRun, o
const fromNode = nodes[edge.fromIdx];
const isActive = fromNode.status === "complete" || fromNode.status === "running" || fromNode.status === "looping";
const color = isActive ? "hsl(38,80%,50%,0.3)" : "hsl(35,10%,20%)";
const color = isActive ? statusColors.looping.border : statusColors.pending.border;
// Bezier curve with rounded corners
// Bezier curve with rounded corners (kept as curves for back edges)
const path = `M ${startX} ${startY} C ${startX + r} ${startY}, ${curveX} ${startY}, ${curveX} ${startY - r} L ${curveX} ${endY + r} C ${curveX} ${endY}, ${endX + r} ${endY}, ${endX + 6} ${endY}`;
return (
@@ -404,7 +541,7 @@ export default function AgentGraph({ nodes, title: _title, onNodeClick, onRun, o
<path d={path} fill="none" stroke={color} strokeWidth={1.5} strokeDasharray="4 3" />
<polygon
points={`${endX + 6},${endY - 3} ${endX + 6},${endY + 3} ${endX},${endY}`}
fill={isActive ? "hsl(38,80%,50%,0.45)" : "hsl(35,10%,22%)"}
fill={isActive ? statusColors.looping.dot : statusColors.pending.border}
/>
</g>
);
@@ -417,10 +554,12 @@ export default function AgentGraph({ nodes, title: _title, onNodeClick, onRun, o
const triggerAvailW = nodeW - 38;
const triggerDisplayLabel = truncateLabel(node.label, triggerAvailW, triggerFontSize);
const nextFireIn = node.triggerConfig?.next_fire_in as number | undefined;
const isActive = node.status === "running" || node.status === "complete";
const colors = isActive ? activeTriggerColors : triggerColors;
// Format countdown for display below node
let countdownLabel: string | null = null;
if (nextFireIn != null && nextFireIn > 0) {
if (isActive && nextFireIn != null && nextFireIn > 0) {
const h = Math.floor(nextFireIn / 3600);
const m = Math.floor((nextFireIn % 3600) / 60);
const s = Math.floor(nextFireIn % 60);
@@ -429,24 +568,28 @@ export default function AgentGraph({ nodes, title: _title, onNodeClick, onRun, o
: `next in ${m}m ${String(s).padStart(2, "0")}s`;
}
// Status label below countdown
const statusLabel = isActive ? "active" : "inactive";
const statusColor = isActive ? "hsl(140,40%,50%)" : "hsl(210,20%,40%)";
return (
<g key={node.id} onClick={() => onNodeClick?.(node)} style={{ cursor: onNodeClick ? "pointer" : "default" }}>
<title>{node.label}</title>
{/* Pill-shaped background with dashed border */}
{/* Pill-shaped background — solid border when active, dashed when inactive */}
<rect
x={pos.x} y={pos.y}
width={nodeW} height={NODE_H}
rx={NODE_H / 2}
fill={triggerColors.bg}
stroke={triggerColors.border}
strokeWidth={1}
strokeDasharray="4 2"
fill={colors.bg}
stroke={colors.border}
strokeWidth={isActive ? 1.5 : 1}
strokeDasharray={isActive ? undefined : "4 2"}
/>
{/* Trigger type icon */}
<text
x={pos.x + 18} y={pos.y + NODE_H / 2}
fill={triggerColors.icon} fontSize={13}
fill={colors.icon} fontSize={13}
textAnchor="middle" dominantBaseline="middle"
>
{icon}
@@ -455,7 +598,7 @@ export default function AgentGraph({ nodes, title: _title, onNodeClick, onRun, o
{/* Label */}
<text
x={pos.x + 32} y={pos.y + NODE_H / 2}
fill={triggerColors.text}
fill={colors.text}
fontSize={triggerFontSize}
fontWeight={500}
dominantBaseline="middle"
@@ -468,12 +611,21 @@ export default function AgentGraph({ nodes, title: _title, onNodeClick, onRun, o
{countdownLabel && (
<text
x={pos.x + nodeW / 2} y={pos.y + NODE_H + 13}
fill="hsl(210,30%,50%)" fontSize={9.5}
fill={triggerColors.text} fontSize={9.5}
textAnchor="middle" fontStyle="italic" opacity={0.7}
>
{countdownLabel}
</text>
)}
{/* Status label */}
<text
x={pos.x + nodeW / 2} y={pos.y + NODE_H + (countdownLabel ? 25 : 13)}
fill={statusColor} fontSize={9}
textAnchor="middle" opacity={0.8}
>
{statusLabel}
</text>
</g>
);
};
@@ -543,7 +695,7 @@ export default function AgentGraph({ nodes, title: _title, onNodeClick, onRun, o
{/* Label -- truncated with ellipsis for narrow nodes */}
<text
x={pos.x + 32} y={pos.y + NODE_H / 2}
fill={isActive ? "hsl(45,90%,85%)" : isDone ? "hsl(40,20%,75%)" : "hsl(35,10%,45%)"}
fill={isActive ? statusColors.running.dot : isDone ? statusColors.complete.dot : statusColors.pending.dot}
fontSize={fontSize}
fontWeight={isActive ? 600 : isDone ? 500 : 400}
dominantBaseline="middle"
@@ -556,7 +708,7 @@ export default function AgentGraph({ nodes, title: _title, onNodeClick, onRun, o
{node.statusLabel && isActive && (
<text
x={pos.x + nodeW + 10} y={pos.y + NODE_H / 2}
fill="hsl(45,80%,60%)" fontSize={10.5} fontStyle="italic"
fill={statusColors.running.dot} fontSize={10.5} fontStyle="italic"
dominantBaseline="middle" opacity={0.8}
>
{node.statusLabel}
@@ -600,27 +752,19 @@ export default function AgentGraph({ nodes, title: _title, onNodeClick, onRun, o
</div>
{/* Graph */}
<div className="flex-1 overflow-y-auto overflow-x-hidden px-3 pb-5 relative">
<svg
width={svgWidth}
height={svgHeight}
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
className={`select-none${building ? " opacity-30" : ""}`}
style={{ fontFamily: "'Inter', system-ui, sans-serif" }}
>
{forwardEdges.map((e, i) => renderForwardEdge(e, i))}
{backEdges.map((e, i) => renderBackEdge(e, i))}
{nodes.map((n, i) => renderNode(n, i))}
</svg>
{building && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="w-6 h-6 animate-spin text-primary/60" />
<p className="text-xs text-muted-foreground/80">Rebuilding agent...</p>
</div>
<PanZoomSvg svgW={svgWidth} svgH={svgHeight} className={building ? "opacity-30" : ""}>
{forwardEdges.map((e, i) => renderForwardEdge(e, i))}
{backEdges.map((e, i) => renderBackEdge(e, i))}
{nodes.map((n, i) => renderNode(n, i))}
</PanZoomSvg>
{building && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="w-6 h-6 animate-spin text-primary/60" />
<p className="text-xs text-muted-foreground/80">Rebuilding agent...</p>
</div>
)}
</div>
</div>
)}
</div>
);
}
+36 -11
View File
@@ -2,6 +2,7 @@ import { memo, useState, useRef, useEffect } from "react";
import { Send, Square, Crown, Cpu, Check, Loader2 } from "lucide-react";
import MarkdownContent from "@/components/MarkdownContent";
import QuestionWidget from "@/components/QuestionWidget";
import MultiQuestionWidget from "@/components/MultiQuestionWidget";
export interface ChatMessage {
id: string;
@@ -9,12 +10,14 @@ export interface ChatMessage {
agentColor: string;
content: string;
timestamp: string;
type?: "system" | "agent" | "user" | "tool_status" | "worker_input_request";
type?: "system" | "agent" | "user" | "tool_status" | "worker_input_request" | "run_divider";
role?: "queen" | "worker";
/** Which worker thread this message belongs to (worker agent name) */
thread?: string;
/** Epoch ms when this message was first created — used for ordering queen/worker interleaving */
createdAt?: number;
/** Queen phase active when this message was created */
phase?: "planning" | "building" | "staging" | "running";
}
interface ChatPanelProps {
@@ -34,8 +37,12 @@ interface ChatPanelProps {
pendingQuestion?: string | null;
/** Options for the pending question */
pendingOptions?: string[] | null;
/** Multiple questions from ask_user_multiple */
pendingQuestions?: { id: string; prompt: string; options?: string[] }[] | null;
/** Called when user submits an answer to the pending question */
onQuestionSubmit?: (answer: string, isOther: boolean) => void;
/** Called when user submits answers to multiple questions */
onMultiQuestionSubmit?: (answers: Record<string, string>) => void;
/** Called when user dismisses the pending question without answering */
onQuestionDismiss?: () => void;
/** Queen operating phase — shown as a tag on queen messages */
@@ -149,6 +156,18 @@ const MessageBubble = memo(function MessageBubble({ msg, queenPhase }: { msg: Ch
const isQueen = msg.role === "queen";
const color = getColor(msg.agent, msg.role);
if (msg.type === "run_divider") {
return (
<div className="flex items-center gap-3 py-2 my-1">
<div className="flex-1 h-px bg-border/60" />
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">
{msg.content}
</span>
<div className="flex-1 h-px bg-border/60" />
</div>
);
}
if (msg.type === "system") {
return (
<div className="flex justify-center py-1">
@@ -200,13 +219,13 @@ const MessageBubble = memo(function MessageBubble({ msg, queenPhase }: { msg: Ch
}`}
>
{isQueen
? queenPhase === "running"
? "running phase"
: queenPhase === "staging"
? "staging phase"
: queenPhase === "planning"
? "planning phase"
: "building phase"
? ((msg.phase ?? queenPhase) === "running"
? "running"
: (msg.phase ?? queenPhase) === "staging"
? "staging"
: (msg.phase ?? queenPhase) === "planning"
? "planning"
: "building")
: "Worker"}
</span>
</div>
@@ -220,9 +239,9 @@ const MessageBubble = memo(function MessageBubble({ msg, queenPhase }: { msg: Ch
</div>
</div>
);
}, (prev, next) => prev.msg.id === next.msg.id && prev.msg.content === next.msg.content && prev.queenPhase === next.queenPhase);
}, (prev, next) => prev.msg.id === next.msg.id && prev.msg.content === next.msg.content && prev.msg.phase === next.msg.phase && prev.queenPhase === next.queenPhase);
export default function ChatPanel({ messages, onSend, isWaiting, isWorkerWaiting, isBusy, activeThread, disabled, onCancel, pendingQuestion, pendingOptions, onQuestionSubmit, onQuestionDismiss, queenPhase }: ChatPanelProps) {
export default function ChatPanel({ messages, onSend, isWaiting, isWorkerWaiting, isBusy, activeThread, disabled, onCancel, pendingQuestion, pendingOptions, pendingQuestions, onQuestionSubmit, onMultiQuestionSubmit, onQuestionDismiss, queenPhase }: ChatPanelProps) {
const [input, setInput] = useState("");
const [readMap, setReadMap] = useState<Record<string, number>>({});
const bottomRef = useRef<HTMLDivElement>(null);
@@ -332,7 +351,13 @@ export default function ChatPanel({ messages, onSend, isWaiting, isWorkerWaiting
</div>
{/* Input area — question widget replaces textarea when a question is pending */}
{pendingQuestion && pendingOptions && onQuestionSubmit ? (
{pendingQuestions && pendingQuestions.length >= 2 && onMultiQuestionSubmit ? (
<MultiQuestionWidget
questions={pendingQuestions}
onSubmit={onMultiQuestionSubmit}
onDismiss={onQuestionDismiss}
/>
) : pendingQuestion && pendingOptions && onQuestionSubmit ? (
<QuestionWidget
question={pendingQuestion}
options={pendingOptions}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,215 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { Send, MessageCircleQuestion, X } from "lucide-react";
export interface QuestionItem {
id: string;
prompt: string;
options?: string[];
}
export interface MultiQuestionWidgetProps {
questions: QuestionItem[];
onSubmit: (answers: Record<string, string>) => void;
onDismiss?: () => void;
}
export default function MultiQuestionWidget({ questions, onSubmit, onDismiss }: MultiQuestionWidgetProps) {
// Per-question state: selected index (null = nothing, options.length = "Other")
const [selections, setSelections] = useState<(number | null)[]>(
() => questions.map(() => null),
);
const [customTexts, setCustomTexts] = useState<string[]>(
() => questions.map(() => ""),
);
const [submitted, setSubmitted] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Scroll the first unanswered question into view when it changes
useEffect(() => {
containerRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}, []);
const canSubmit = questions.every((q, i) => {
const sel = selections[i];
if (sel === null) return false;
const isOther = q.options ? sel === q.options.length : true;
if (isOther && !customTexts[i].trim()) return false;
return true;
});
const handleSubmit = useCallback(() => {
if (!canSubmit || submitted) return;
setSubmitted(true);
const answers: Record<string, string> = {};
for (let i = 0; i < questions.length; i++) {
const q = questions[i];
const sel = selections[i]!;
const isOther = q.options ? sel === q.options.length : true;
answers[q.id] = isOther ? customTexts[i].trim() : q.options![sel];
}
onSubmit(answers);
}, [canSubmit, submitted, questions, selections, customTexts, onSubmit]);
// Enter to submit (only when not focused on a text input)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (submitted) return;
const target = e.target as HTMLElement;
const inInput = target.tagName === "INPUT" || target.tagName === "TEXTAREA";
if (e.key === "Enter" && !e.shiftKey && !inInput) {
e.preventDefault();
handleSubmit();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleSubmit, submitted]);
if (submitted) return null;
const answeredCount = selections.filter((s) => s !== null).length;
return (
<div className="p-4">
<div className="bg-card border border-border rounded-xl shadow-sm overflow-hidden">
{/* Header */}
<div className="px-5 pt-4 pb-2 flex items-center gap-3">
<div className="w-7 h-7 rounded-lg bg-primary/10 border border-primary/20 flex items-center justify-center flex-shrink-0">
<MessageCircleQuestion className="w-3.5 h-3.5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">
{questions.length} questions
</p>
<p className="text-[11px] text-muted-foreground">
{answeredCount}/{questions.length} answered
</p>
</div>
{onDismiss && (
<button
onClick={onDismiss}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors flex-shrink-0"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Questions */}
<div
ref={containerRef}
className="px-5 pb-3 space-y-4 max-h-[400px] overflow-y-auto"
>
{questions.map((q, qi) => {
const sel = selections[qi];
const hasOptions = q.options && q.options.length >= 2;
const otherIndex = hasOptions ? q.options!.length : 0;
const isOtherSelected = sel === otherIndex;
return (
<div key={q.id} className="space-y-1.5">
<p className="text-sm font-medium text-foreground">
<span className="text-xs text-muted-foreground mr-1.5">
{qi + 1}.
</span>
{q.prompt}
</p>
{hasOptions ? (
<>
{q.options!.map((opt, oi) => (
<button
key={oi}
onClick={() => {
setSelections((prev) => {
const next = [...prev];
next[qi] = oi;
return next;
});
}}
className={`w-full text-left px-4 py-2 rounded-lg border text-sm transition-colors ${
sel === oi
? "border-primary bg-primary/10 text-foreground"
: "border-border/60 bg-muted/20 text-foreground hover:border-primary/40 hover:bg-muted/40"
}`}
>
{opt}
</button>
))}
<input
type="text"
value={customTexts[qi]}
onFocus={() => {
setSelections((prev) => {
const next = [...prev];
next[qi] = otherIndex;
return next;
});
}}
onChange={(e) => {
setSelections((prev) => {
const next = [...prev];
next[qi] = otherIndex;
return next;
});
setCustomTexts((prev) => {
const next = [...prev];
next[qi] = e.target.value;
return next;
});
}}
placeholder="Type a custom response..."
className={`w-full px-4 py-2 rounded-lg border border-dashed text-sm transition-colors bg-transparent placeholder:text-muted-foreground focus:outline-none ${
isOtherSelected
? "border-primary bg-primary/10 text-foreground"
: "border-border text-muted-foreground hover:border-primary/40"
}`}
/>
</>
) : (
<input
type="text"
value={customTexts[qi]}
onFocus={() => {
setSelections((prev) => {
const next = [...prev];
next[qi] = 0;
return next;
});
}}
onChange={(e) => {
setSelections((prev) => {
const next = [...prev];
next[qi] = 0;
return next;
});
setCustomTexts((prev) => {
const next = [...prev];
next[qi] = e.target.value;
return next;
});
}}
placeholder="Type your answer..."
className="w-full px-4 py-2 rounded-lg border text-sm transition-colors bg-transparent placeholder:text-muted-foreground focus:outline-none border-border text-foreground hover:border-primary/40 focus:border-primary"
/>
)}
</div>
);
})}
</div>
{/* Submit */}
<div className="px-5 pb-4">
<button
onClick={handleSubmit}
disabled={!canSubmit}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-lg text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<Send className="w-3.5 h-3.5" />
Submit All
</button>
</div>
</div>
</div>
);
}
@@ -299,13 +299,13 @@ function SubagentsTab({ subAgentIds, allNodeSpecs, subagentReports }: { subAgent
);
}
type Tab = "overview" | "tools" | "logs" | "prompt" | "subagents";
type Tab = "overview" | "breakdown" | "tools" | "logs" | "subagents";
const tabs: { id: Tab; label: string; Icon: React.FC<{ className?: string }> }[] = [
{ id: "overview", label: "Overview", Icon: ({ className }) => <GitBranch className={className} /> },
{ id: "breakdown", label: "Breakdown", Icon: ({ className }) => <BookOpen className={className} /> },
{ id: "tools", label: "Tools", Icon: ({ className }) => <Wrench className={className} /> },
{ id: "logs", label: "Logs", Icon: ({ className }) => <Terminal className={className} /> },
{ id: "prompt", label: "Prompt", Icon: ({ className }) => <BookOpen className={className} /> },
{ id: "subagents", label: "Subagents", Icon: ({ className }) => <Bot className={className} /> },
];
@@ -331,7 +331,7 @@ export default function NodeDetailPanel({ node, nodeSpec, allNodeSpecs, subagent
// Fetch real criteria when Overview tab is active and session is loaded
useEffect(() => {
if (activeTab === "overview" && sessionId && graphId && node) {
if (activeTab === "breakdown" && sessionId && graphId && node) {
graphsApi.nodeCriteria(sessionId, graphId, node.id, workerSessionId || undefined)
.then(r => setRealCriteria(r))
.catch(() => setRealCriteria(null));
@@ -410,6 +410,10 @@ export default function NodeDetailPanel({ node, nodeSpec, allNodeSpecs, subagent
{/* Tab content */}
<div className="flex-1 overflow-auto px-4 py-4 flex flex-col gap-3">
{activeTab === "overview" && (
<SystemPromptTab systemPrompt={nodeSpec?.system_prompt} />
)}
{activeTab === "breakdown" && (
<>
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Action Plan</p>
{actionPlan ? (
@@ -489,10 +493,6 @@ export default function NodeDetailPanel({ node, nodeSpec, allNodeSpecs, subagent
<LogsTab nodeId={node.id} isActive={isActive} sessionId={sessionId} graphId={graphId} workerSessionId={workerSessionId} nodeLogs={nodeLogs} />
)}
{activeTab === "prompt" && (
<SystemPromptTab systemPrompt={nodeSpec?.system_prompt} />
)}
{activeTab === "subagents" && nodeSpec?.sub_agents && (
<SubagentsTab
subAgentIds={nodeSpec.sub_agents}
+3 -3
View File
@@ -1,8 +1,8 @@
import { useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { Crown, X } from "lucide-react";
import { loadPersistedTabs, savePersistedTabs, TAB_STORAGE_KEY, type PersistedTabState } from "@/lib/tab-persistence";
import { sessionsApi } from "@/api/sessions";
import { loadPersistedTabs, savePersistedTabs, TAB_STORAGE_KEY, type PersistedTabState } from "@/lib/tab-persistence";
export interface TopBarTab {
agentType: string;
@@ -51,10 +51,10 @@ export default function TopBar({ tabs: tabsProp, onTabClick, onCloseTab, canClos
onCloseTab(agentType);
return;
}
// Kill the backend session (queen/judge/worker) even outside workspace
// Kill the backend session (queen/worker) even outside workspace
sessionsApi.list()
.then(({ sessions }) => {
const match = sessions.find(s => s.agent_path === agentType);
const match = sessions.find(s => s.agent_path.endsWith(agentType));
if (match) return sessionsApi.stop(match.session_id);
})
.catch(() => {}); // fire-and-forget
+27
View File
@@ -72,6 +72,33 @@
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 45 93% 47%;
/* Agent graph node status colors */
--node-running: 45 95% 58%;
--node-looping: 38 90% 55%;
--node-complete: 43 70% 45%;
--node-pending: 35 15% 28%;
--node-pending-bg: 35 10% 12%;
--node-pending-border: 35 10% 20%;
--node-error: 0 65% 55%;
/* Agent graph trigger node colors */
--trigger-bg: 210 25% 14%;
--trigger-border: 210 30% 30%;
--trigger-text: 210 30% 65%;
--trigger-icon: 210 40% 55%;
/* Draft graph chrome colors */
--draft-edge: 220 10% 30%;
--draft-edge-arrow: 220 10% 35%;
--draft-edge-label: 220 10% 45%;
--draft-back-edge: 220 10% 25%;
--draft-group-fill: 220 15% 18%;
--draft-group-stroke: 220 10% 40%;
--draft-chrome-text: 220 10% 50%;
--draft-chrome-text-dim: 220 10% 55%;
--draft-node-text: 0 0% 78%;
--draft-node-text-hover: 0 0% 92%;
}
}
+22 -65
View File
@@ -1,60 +1,6 @@
import { describe, it, expect } from "vitest";
import { backendMessageToChatMessage, sseEventToChatMessage, formatAgentDisplayName } from "./chat-helpers";
import type { AgentEvent, Message } from "@/api/types";
// ---------------------------------------------------------------------------
// backendMessageToChatMessage
// ---------------------------------------------------------------------------
describe("backendMessageToChatMessage", () => {
it("converts a user message", () => {
const msg: Message = { seq: 1, role: "user", content: "hello", _node_id: "chat" };
const result = backendMessageToChatMessage(msg, "inbox-management");
expect(result.type).toBe("user");
expect(result.agent).toBe("You");
expect(result.role).toBeUndefined();
expect(result.content).toBe("hello");
expect(result.thread).toBe("inbox-management");
});
it("converts an assistant message with node_id as agent", () => {
const msg: Message = { seq: 2, role: "assistant", content: "hi", _node_id: "intake" };
const result = backendMessageToChatMessage(msg, "inbox-management");
expect(result.agent).toBe("intake");
expect(result.role).toBe("worker");
expect(result.type).toBeUndefined();
});
it("defaults agent to 'Agent' when _node_id is empty", () => {
const msg: Message = { seq: 3, role: "assistant", content: "ok", _node_id: "" };
const result = backendMessageToChatMessage(msg, "inbox-management");
expect(result.agent).toBe("Agent");
});
it("produces deterministic ID from seq", () => {
const msg: Message = { seq: 42, role: "user", content: "test", _node_id: "x" };
const result = backendMessageToChatMessage(msg, "thread");
expect(result.id).toBe("backend-42");
});
it("passes through the thread parameter", () => {
const msg: Message = { seq: 1, role: "user", content: "hi", _node_id: "x" };
const result = backendMessageToChatMessage(msg, "my-thread");
expect(result.thread).toBe("my-thread");
});
it("uses agentDisplayName instead of node_id when provided", () => {
const msg: Message = { seq: 2, role: "assistant", content: "hi", _node_id: "intake" };
const result = backendMessageToChatMessage(msg, "thread", "Competitive Intel Agent");
expect(result.agent).toBe("Competitive Intel Agent");
});
it("still shows 'You' for user messages even when agentDisplayName is provided", () => {
const msg: Message = { seq: 1, role: "user", content: "hello", _node_id: "chat" };
const result = backendMessageToChatMessage(msg, "thread", "My Agent");
expect(result.agent).toBe("You");
});
});
import { sseEventToChatMessage, formatAgentDisplayName } from "./chat-helpers";
import type { AgentEvent } from "@/api/types";
// ---------------------------------------------------------------------------
// sseEventToChatMessage
@@ -261,25 +207,36 @@ describe("sseEventToChatMessage", () => {
expect(result!.id).toMatch(/^stream-t-\d+-chat$/);
});
it("converts client_input_requested with prompt to message", () => {
it("returns null for client_input_requested (handled in workspace.tsx)", () => {
const event = makeEvent({
type: "client_input_requested",
node_id: "chat",
execution_id: "abc",
data: { prompt: "What next?" },
});
const result = sseEventToChatMessage(event, "t");
expect(result).not.toBeNull();
expect(result!.content).toBe("What next?");
expect(result!.role).toBe("worker");
expect(sseEventToChatMessage(event, "t")).toBeNull();
});
it("returns null for client_input_requested without prompt", () => {
it("converts client_input_received to user message", () => {
const event = makeEvent({
type: "client_input_requested",
node_id: "chat",
type: "client_input_received",
node_id: "queen",
execution_id: "abc",
data: { prompt: "" },
data: { content: "do the thing" },
});
const result = sseEventToChatMessage(event, "t");
expect(result).not.toBeNull();
expect(result!.agent).toBe("You");
expect(result!.type).toBe("user");
expect(result!.content).toBe("do the thing");
});
it("returns null for client_input_received with empty content", () => {
const event = makeEvent({
type: "client_input_received",
node_id: "queen",
execution_id: "abc",
data: { content: "" },
});
expect(sseEventToChatMessage(event, "t")).toBeNull();
});
+39 -28
View File
@@ -1,10 +1,10 @@
/**
* Pure functions for converting backend messages and SSE events into ChatMessage objects.
* Pure functions for converting SSE events into ChatMessage objects.
* No React dependencies — just JSON in, object out.
*/
import type { ChatMessage } from "@/components/ChatPanel";
import type { AgentEvent, Message } from "@/api/types";
import type { AgentEvent } from "@/api/types";
/**
* Derive a human-readable display name from a raw agent identifier.
@@ -27,32 +27,6 @@ export function formatAgentDisplayName(raw: string): string {
.trim();
}
/**
* Convert a backend Message (from sessionsApi.messages()) into a ChatMessage.
* When agentDisplayName is provided, it is used as the sender for all agent
* messages instead of the raw node_id.
*/
export function backendMessageToChatMessage(
msg: Message,
thread: string,
agentDisplayName?: string,
): ChatMessage {
// Use file-mtime created_at (epoch seconds → ms) for cross-conversation
// ordering; fall back to seq for backwards compatibility.
const createdAt = msg.created_at ? msg.created_at * 1000 : msg.seq;
return {
id: `backend-${msg._node_id}-${msg.seq}`,
agent: msg.role === "user" ? "You" : agentDisplayName || msg._node_id || "Agent",
agentColor: "",
content: msg.content,
timestamp: "",
type: msg.role === "user" ? "user" : undefined,
role: msg.role === "user" ? undefined : "worker",
thread,
createdAt,
};
}
/**
* Convert an SSE AgentEvent into a ChatMessage, or null if the event
* doesn't produce a visible chat message.
@@ -101,6 +75,21 @@ export function sseEventToChatMessage(
// create a worker_input_request message and set awaitingInput state.
return null;
case "client_input_received": {
const userContent = (event.data?.content as string) || "";
if (!userContent) return null;
return {
id: `user-input-${event.timestamp}`,
agent: "You",
agentColor: "",
content: userContent,
timestamp: "",
type: "user",
thread,
createdAt,
};
}
case "llm_text_delta": {
const snapshot = (event.data?.snapshot as string) || (event.data?.content as string) || "";
if (!snapshot) return null;
@@ -148,3 +137,25 @@ export function sseEventToChatMessage(
return null;
}
}
type QueenPhase = "planning" | "building" | "staging" | "running";
const VALID_PHASES = new Set<string>(["planning", "building", "staging", "running"]);
/**
* Scan an array of persisted events and return the last queen phase seen,
* or null if no phase event exists. Reads both `queen_phase_changed` events
* and the per-iteration `phase` metadata on `node_loop_iteration` events.
*/
export function extractLastPhase(events: AgentEvent[]): QueenPhase | null {
let last: QueenPhase | null = null;
for (const evt of events) {
const phase =
evt.type === "queen_phase_changed" ? (evt.data?.phase as string) :
evt.type === "node_loop_iteration" ? (evt.data?.phase as string | undefined) :
undefined;
if (phase && VALID_PHASES.has(phase)) {
last = phase as QueenPhase;
}
}
return last;
}
+1
View File
@@ -51,6 +51,7 @@ export function topologyToGraphNodes(topology: GraphTopology): GraphNode[] {
triggerConfig: {
...ep.trigger_config,
...(ep.next_fire_in != null ? { next_fire_in: ep.next_fire_in } : {}),
...(ep.task ? { task: ep.task } : {}),
},
next: [ep.entry_node],
});
+1 -1
View File
@@ -113,7 +113,7 @@ export default function MyAgents() {
<div className="flex items-center gap-1">
<Activity className="w-3 h-3" />
<span>
{agent.session_count} session{agent.session_count !== 1 ? "s" : ""}
{agent.run_count} run{agent.run_count !== 1 ? "s" : ""}
</span>
</div>
<span>{agent.last_active ? timeAgo(agent.last_active) : "Never run"}</span>
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -572,7 +572,7 @@ async def test_event_loop_conversation_compaction():
judge = CountingJudge(retry_count=3)
node = EventLoopNode(
judge=judge,
config=LoopConfig(max_iterations=10, max_history_tokens=200),
config=LoopConfig(max_iterations=10, max_context_tokens=200),
)
result = await node.execute(ctx)
+9 -9
View File
@@ -204,8 +204,8 @@ class TestNodeConversation:
@pytest.mark.asyncio
async def test_usage_ratio(self):
"""usage_ratio returns estimate / max_history_tokens."""
conv = NodeConversation(max_history_tokens=1000)
"""usage_ratio returns estimate / max_context_tokens."""
conv = NodeConversation(max_context_tokens=1000)
await conv.add_user_message("a" * 400)
assert conv.usage_ratio() == pytest.approx(0.1) # 100/1000
@@ -214,15 +214,15 @@ class TestNodeConversation:
@pytest.mark.asyncio
async def test_usage_ratio_zero_budget(self):
"""usage_ratio returns 0 when max_history_tokens is 0 (unlimited)."""
conv = NodeConversation(max_history_tokens=0)
"""usage_ratio returns 0 when max_context_tokens is 0 (unlimited)."""
conv = NodeConversation(max_context_tokens=0)
await conv.add_user_message("a" * 400)
assert conv.usage_ratio() == 0.0
@pytest.mark.asyncio
async def test_needs_compaction_with_actual_tokens(self):
"""needs_compaction uses actual API token count when available."""
conv = NodeConversation(max_history_tokens=1000, compaction_threshold=0.8)
conv = NodeConversation(max_context_tokens=1000, compaction_threshold=0.8)
await conv.add_user_message("a" * 100) # chars/4 = 25, well under 800
assert conv.needs_compaction() is False
@@ -233,7 +233,7 @@ class TestNodeConversation:
@pytest.mark.asyncio
async def test_needs_compaction(self):
conv = NodeConversation(max_history_tokens=100, compaction_threshold=0.8)
conv = NodeConversation(max_context_tokens=100, compaction_threshold=0.8)
await conv.add_user_message("x" * 320)
assert conv.needs_compaction() is True
@@ -457,7 +457,7 @@ class TestPersistence:
store = MockConversationStore()
assert await NodeConversation.restore(store) is None
conv = NodeConversation(system_prompt="hello", max_history_tokens=500, store=store)
conv = NodeConversation(system_prompt="hello", max_context_tokens=500, store=store)
await conv.add_user_message("u1")
await conv.add_assistant_message("a1")
@@ -643,7 +643,7 @@ class TestConversationIntegration:
store = FileConversationStore(base)
conv = NodeConversation(
system_prompt="You are a helpful travel agent.",
max_history_tokens=16000,
max_context_tokens=16000,
store=store,
)
@@ -1314,7 +1314,7 @@ class TestLlmCompact:
"""Create a minimal EventLoopNode for testing."""
from framework.graph.event_loop_node import EventLoopNode, LoopConfig
config = LoopConfig(max_history_tokens=32000)
config = LoopConfig(max_context_tokens=32000)
node = EventLoopNode.__new__(EventLoopNode)
node._config = config
node._event_bus = None
@@ -50,7 +50,7 @@ async def test_worker_handoff_injects_formatted_request_into_queen() -> None:
@pytest.mark.asyncio
async def test_worker_handoff_ignores_queen_and_judge_streams() -> None:
async def test_worker_handoff_ignores_queen_stream() -> None:
bus = EventBus()
manager = SessionManager()
session = _make_session(bus)
@@ -63,11 +63,6 @@ async def test_worker_handoff_ignores_queen_and_judge_streams() -> None:
node_id="queen",
reason="should be ignored",
)
await bus.emit_escalation_requested(
stream_id="judge",
node_id="judge",
reason="should be ignored",
)
assert queen_node.inject_event.await_count == 0
+1
View File
@@ -240,6 +240,7 @@ class TestEventSerialization:
"stop_reason": "stop",
"input_tokens": 10,
"output_tokens": 20,
"cached_tokens": 0,
"model": "gpt-4",
}
+261
View File
@@ -0,0 +1,261 @@
"""Tests for queen-level trigger system.
Verifies that:
- Timer triggers fire inject_trigger() on the queen node
- Webhook triggers fire inject_trigger() via EventBus WEBHOOK_RECEIVED
- Queen node unavailable trigger skipped silently
- worker_runtime=None trigger discarded (gating)
- remove_trigger cleans up webhook subscription
- run_agent_with_input is in _QUEEN_RUNNING_TOOLS
- System prompts reference run_agent_with_input, not start_worker()
"""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from framework.runtime.event_bus import EventBus
from framework.runtime.triggers import TriggerDefinition
from framework.server.session_manager import Session
def _make_session(event_bus: EventBus, session_id: str = "session_trigger_test") -> Session:
return Session(id=session_id, event_bus=event_bus, llm=object(), loaded_at=0.0)
def _make_executor(queen_node) -> SimpleNamespace:
return SimpleNamespace(node_registry={"queen": queen_node})
@pytest.mark.asyncio
async def test_interval_timer_fires_inject_trigger_on_queen_node() -> None:
"""Timer with interval_minutes fires inject_trigger() on the queen node."""
from framework.graph.event_loop_node import TriggerEvent
from framework.tools.queen_lifecycle_tools import _start_trigger_timer
bus = EventBus()
session = _make_session(bus)
session.worker_runtime = object() # non-None → worker is loaded
queen_node = SimpleNamespace(inject_trigger=AsyncMock())
session.queen_executor = _make_executor(queen_node)
tdef = TriggerDefinition(
id="test-timer",
trigger_type="timer",
trigger_config={"interval_minutes": 0.001}, # ~60ms
task="run it",
)
await _start_trigger_timer(session, "test-timer", tdef)
# Let the timer fire at least once
await asyncio.sleep(0.15)
# Cancel the background task
task = session.active_timer_tasks.get("test-timer")
if task:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
assert queen_node.inject_trigger.await_count >= 1
# Inspect the TriggerEvent passed to inject_trigger
call_args = queen_node.inject_trigger.await_args_list[0]
trigger: TriggerEvent = call_args.args[0]
assert trigger.trigger_type == "timer"
assert trigger.source_id == "test-timer"
assert trigger.payload.get("task") == "run it"
@pytest.mark.asyncio
async def test_timer_skipped_when_queen_node_unavailable() -> None:
"""No inject_trigger call and no exception when queen executor is not set."""
from framework.tools.queen_lifecycle_tools import _start_trigger_timer
bus = EventBus()
session = _make_session(bus)
session.worker_runtime = object()
session.queen_executor = None # queen not ready
tdef = TriggerDefinition(
id="no-queen-timer",
trigger_type="timer",
trigger_config={"interval_minutes": 0.001},
task="should not fire",
)
await _start_trigger_timer(session, "no-queen-timer", tdef)
await asyncio.sleep(0.15)
task = session.active_timer_tasks.get("no-queen-timer")
if task:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
# No exception raised, nothing to assert beyond completion
@pytest.mark.asyncio
async def test_webhook_trigger_fires_inject_trigger() -> None:
"""WEBHOOK_RECEIVED on EventBus → inject_trigger() on the queen node."""
from framework.graph.event_loop_node import TriggerEvent
from framework.tools.queen_lifecycle_tools import _start_trigger_webhook
bus = EventBus()
session = _make_session(bus)
session.worker_runtime = object()
queen_node = SimpleNamespace(inject_trigger=AsyncMock())
session.queen_executor = _make_executor(queen_node)
tdef = TriggerDefinition(
id="test-webhook",
trigger_type="webhook",
trigger_config={"path": "/hooks/test", "methods": ["POST"]},
task="process it",
)
# Patch WebhookServer to avoid binding a real port
mock_server = MagicMock()
mock_server.is_running = False
mock_server.add_route = MagicMock()
mock_server.start = AsyncMock()
with patch("framework.runtime.webhook_server.WebhookServer", return_value=mock_server):
with patch("framework.runtime.webhook_server.WebhookServerConfig"):
await _start_trigger_webhook(session, "test-webhook", tdef)
# Simulate an incoming webhook event on the EventBus
await bus.emit_webhook_received(
source_id="test-webhook",
path="/hooks/test",
method="POST",
headers={},
payload={"event": "push"},
)
await asyncio.sleep(0.05) # let handler run
assert queen_node.inject_trigger.await_count == 1
trigger: TriggerEvent = queen_node.inject_trigger.await_args_list[0].args[0]
assert trigger.trigger_type == "webhook"
assert trigger.source_id == "test-webhook"
assert trigger.payload["method"] == "POST"
assert trigger.payload["path"] == "/hooks/test"
assert trigger.payload["task"] == "process it"
assert trigger.payload["payload"] == {"event": "push"}
@pytest.mark.asyncio
async def test_webhook_trigger_discarded_when_no_worker() -> None:
"""inject_trigger is NOT called when no worker is loaded."""
from framework.tools.queen_lifecycle_tools import _start_trigger_webhook
bus = EventBus()
session = _make_session(bus)
session.worker_runtime = None # no worker
queen_node = SimpleNamespace(inject_trigger=AsyncMock())
session.queen_executor = _make_executor(queen_node)
tdef = TriggerDefinition(
id="no-worker-webhook",
trigger_type="webhook",
trigger_config={"path": "/hooks/noop", "methods": ["POST"]},
task="should not fire",
)
mock_server = MagicMock()
mock_server.is_running = False
mock_server.add_route = MagicMock()
mock_server.start = AsyncMock()
with patch("framework.runtime.webhook_server.WebhookServer", return_value=mock_server):
with patch("framework.runtime.webhook_server.WebhookServerConfig"):
await _start_trigger_webhook(session, "no-worker-webhook", tdef)
await bus.emit_webhook_received(
source_id="no-worker-webhook",
path="/hooks/noop",
method="POST",
headers={},
payload={},
)
await asyncio.sleep(0.05)
assert queen_node.inject_trigger.await_count == 0
@pytest.mark.asyncio
async def test_remove_trigger_cleans_up_webhook_subscription() -> None:
"""After remove_trigger(), WEBHOOK_RECEIVED no longer calls inject_trigger."""
from framework.tools.queen_lifecycle_tools import _start_trigger_webhook
bus = EventBus()
session = _make_session(bus)
session.worker_runtime = object()
queen_node = SimpleNamespace(inject_trigger=AsyncMock())
session.queen_executor = _make_executor(queen_node)
tdef = TriggerDefinition(
id="removable-webhook",
trigger_type="webhook",
trigger_config={"path": "/hooks/removable", "methods": ["POST"]},
task="run it",
)
mock_server = MagicMock()
mock_server.is_running = False
mock_server.add_route = MagicMock()
mock_server.start = AsyncMock()
with patch("framework.runtime.webhook_server.WebhookServer", return_value=mock_server):
with patch("framework.runtime.webhook_server.WebhookServerConfig"):
await _start_trigger_webhook(session, "removable-webhook", tdef)
# Manually unsubscribe (mirrors what remove_trigger does)
sub_id = session.active_webhook_subs.pop("removable-webhook", None)
assert sub_id is not None
bus.unsubscribe(sub_id)
# Now fire — should NOT reach queen
await bus.emit_webhook_received(
source_id="removable-webhook",
path="/hooks/removable",
method="POST",
headers={},
payload={},
)
await asyncio.sleep(0.05)
assert queen_node.inject_trigger.await_count == 0
assert "removable-webhook" not in session.active_webhook_subs
def test_run_agent_with_input_in_running_tools() -> None:
"""run_agent_with_input must be available to the queen in RUNNING phase."""
from framework.agents.queen.nodes import _QUEEN_RUNNING_TOOLS
assert "run_agent_with_input" in _QUEEN_RUNNING_TOOLS
def test_system_prompt_uses_correct_tool_name() -> None:
"""Trigger handling rules must reference run_agent_with_input, not start_worker()."""
from framework.agents.queen.nodes import (
_queen_behavior_running,
_queen_behavior_staging,
)
assert "run_agent_with_input" in _queen_behavior_running
assert "start_worker()" not in _queen_behavior_running
assert "run_agent_with_input" in _queen_behavior_staging
assert "start_worker()" not in _queen_behavior_staging
+597
View File
@@ -0,0 +1,597 @@
# Draft Flowchart System — Complete Reference
The draft flowchart system bridges user-facing workflow design (planning phase) and the runtime agent graph (execution phase). During planning, the queen agent creates an ISO 5807 flowchart that the user reviews. On approval, decision nodes are dissolved into runtime-compatible structures, and the original flowchart is preserved for live status overlay during execution.
---
## Architecture Overview
```
Planning Phase Build Gate Runtime Phase
─────────────────────────────────────────────────────────────────────────────
Queen LLM confirm_and_build() Graph Executor
│ │ │
▼ ▼ ▼
save_agent_draft() ┌──────────────────────┐ Node execution
│ │ dissolve_decision_nodes│ with status
▼ │ │ │
DraftGraph (SSE) ────► │ Decision diamonds │ ▼
│ │ merged into │ Flowchart Map
▼ │ predecessor criteria │ inverts to
Frontend renders │ │ overlay status
ISO 5807 flowchart │ Original draft │ on original
with diamond │ preserved │ flowchart
decisions │ │
└──────────────────────┘
```
**Key files:**
- Backend: `core/framework/tools/queen_lifecycle_tools.py` — draft creation, classification, dissolution
- Backend: `core/framework/server/routes_graphs.py` — REST endpoints
- Frontend: `core/frontend/src/components/DraftGraph.tsx` — SVG flowchart renderer
- Frontend: `core/frontend/src/api/types.ts` — TypeScript interfaces
- Frontend: `core/frontend/src/pages/workspace.tsx` — state management and conditional rendering
---
## 1. JSON Schemas
### Tool: `save_agent_draft` — Input Schema
```json
{
"type": "object",
"required": ["agent_name", "goal", "nodes"],
"properties": {
"agent_name": {
"type": "string",
"description": "Snake_case name for the agent (e.g. 'lead_router_agent')"
},
"goal": {
"type": "string",
"description": "High-level goal description for the agent"
},
"description": {
"type": "string",
"description": "Brief description of what the agent does"
},
"nodes": {
"type": "array",
"description": "Graph nodes. Only 'id' is required; all other fields are optional hints.",
"items": { "$ref": "#/$defs/DraftNode" }
},
"edges": {
"type": "array",
"description": "Connections between nodes. Auto-generated as linear if omitted.",
"items": { "$ref": "#/$defs/DraftEdge" }
},
"terminal_nodes": {
"type": "array",
"items": { "type": "string" },
"description": "Node IDs that are terminal (end) nodes. Auto-detected from edges if omitted."
},
"success_criteria": {
"type": "array",
"items": { "type": "string" },
"description": "Agent-level success criteria"
},
"constraints": {
"type": "array",
"items": { "type": "string" },
"description": "Agent-level constraints"
}
}
}
```
### Node Schema (`DraftNode`)
```json
{
"type": "object",
"required": ["id"],
"properties": {
"id": {
"type": "string",
"description": "Kebab-case node identifier (e.g. 'enrich-lead')"
},
"name": {
"type": "string",
"description": "Human-readable display name. Defaults to id if omitted."
},
"description": {
"type": "string",
"description": "What this node does (business logic). Used for auto-classification."
},
"node_type": {
"type": "string",
"enum": ["event_loop", "gcu"],
"default": "event_loop",
"description": "Runtime node type. 'gcu' maps to browser automation."
},
"flowchart_type": {
"type": "string",
"enum": [
"start", "terminal", "process", "decision",
"io", "document", "multi_document",
"subprocess", "preparation",
"manual_input", "manual_operation",
"delay", "display",
"database", "stored_data", "internal_storage",
"connector", "offpage_connector",
"merge", "extract", "sort", "collate",
"summing_junction", "or",
"browser", "comment", "alternate_process"
],
"description": "ISO 5807 flowchart symbol. Auto-detected if omitted."
},
"tools": {
"type": "array",
"items": { "type": "string" },
"description": "Planned tool names (hints for scaffolder, not validated)"
},
"input_keys": {
"type": "array",
"items": { "type": "string" },
"description": "Expected input memory keys"
},
"output_keys": {
"type": "array",
"items": { "type": "string" },
"description": "Expected output memory keys"
},
"success_criteria": {
"type": "string",
"description": "What success looks like for this node"
},
"decision_clause": {
"type": "string",
"description": "For decision nodes only: the yes/no question to evaluate (e.g. 'Is amount > $100?'). During dissolution, this becomes the predecessor node's success_criteria."
}
}
}
```
### Edge Schema (`DraftEdge`)
```json
{
"type": "object",
"required": ["source", "target"],
"properties": {
"source": {
"type": "string",
"description": "Source node ID"
},
"target": {
"type": "string",
"description": "Target node ID"
},
"condition": {
"type": "string",
"enum": ["always", "on_success", "on_failure", "conditional", "llm_decide"],
"default": "on_success",
"description": "Edge traversal condition"
},
"description": {
"type": "string",
"description": "Human-readable description of when this edge is taken"
},
"label": {
"type": "string",
"description": "Short label shown on the flowchart edge (e.g. 'Yes', 'No', 'Retry')"
}
}
}
```
### Output: Enriched Draft Graph Object
After `save_agent_draft` processes the input, it stores and emits an enriched draft with auto-classified flowchart metadata. This is the structure sent via the `draft_graph_updated` SSE event and returned by `GET /api/sessions/{id}/draft-graph`.
```json
{
"agent_name": "lead_router_agent",
"goal": "Enrich and route incoming leads",
"description": "Automated lead enrichment and routing agent",
"success_criteria": ["Lead score calculated", "Correct tier assigned"],
"constraints": ["Apollo enrichment required before routing"],
"entry_node": "intake",
"terminal_nodes": ["route"],
"nodes": [
{
"id": "intake",
"name": "Intake",
"description": "Fetch contact from HubSpot",
"node_type": "event_loop",
"tools": ["hubspot_get_contact"],
"input_keys": ["contact_id"],
"output_keys": ["contact_data", "domain"],
"success_criteria": "Contact data retrieved",
"decision_clause": "",
"sub_agents": [],
"flowchart_type": "start",
"flowchart_shape": "stadium",
"flowchart_color": "#4CAF50"
},
{
"id": "check-tier",
"name": "Check Tier",
"description": "",
"node_type": "event_loop",
"decision_clause": "Is lead score > 80?",
"flowchart_type": "decision",
"flowchart_shape": "diamond",
"flowchart_color": "#FF9800"
}
],
"edges": [
{
"id": "edge-0",
"source": "intake",
"target": "check-tier",
"condition": "on_success",
"description": "",
"label": ""
},
{
"id": "edge-1",
"source": "check-tier",
"target": "enrich",
"condition": "on_success",
"description": "",
"label": "Yes"
},
{
"id": "edge-2",
"source": "check-tier",
"target": "route",
"condition": "on_failure",
"description": "",
"label": "No"
}
],
"flowchart_legend": {
"start": { "shape": "stadium", "color": "#4CAF50" },
"terminal": { "shape": "stadium", "color": "#F44336" },
"process": { "shape": "rectangle", "color": "#2196F3" },
"decision": { "shape": "diamond", "color": "#FF9800" }
}
}
```
**Enriched fields** (added by backend to every node during classification):
| Field | Type | Description |
|---|---|---|
| `flowchart_type` | `string` | The resolved ISO 5807 symbol type |
| `flowchart_shape` | `string` | SVG shape identifier for the frontend renderer |
| `flowchart_color` | `string` | Hex color code for the symbol |
### Flowchart Map Object
Returned by `GET /api/sessions/{id}/flowchart-map` after `confirm_and_build()` dissolves decision nodes:
```json
{
"map": {
"intake": ["intake", "check-tier"],
"enrich": ["enrich"],
"route": ["route"]
},
"original_draft": { "...original draft graph before dissolution..." }
}
```
- `map`: Keys are runtime node IDs, values are lists of original draft node IDs that the runtime node absorbed.
- `original_draft`: The complete draft graph as it existed before dissolution, preserved for flowchart display.
- Both fields are `null` if no dissolution has occurred yet.
---
## 2. ISO 5807 Flowchart Types
### Core Symbols
| Type | Shape | Color | SVG Primitive | Description |
|---|---|---|---|---|
| `start` | stadium | `#4CAF50` green | `<rect rx={h/2}>` | Entry point / start terminator |
| `terminal` | stadium | `#F44336` red | `<rect rx={h/2}>` | End point / stop terminator |
| `process` | rectangle | `#2196F3` blue | `<rect rx={4}>` | General processing step |
| `decision` | diamond | `#FF9800` amber | `<polygon>` 4-point | Branching / conditional logic |
| `io` | parallelogram | `#9C27B0` purple | `<polygon>` skewed | Data input or output |
| `document` | document | `#607D8B` blue-grey | `<path>` wavy bottom | Single document output |
| `multi_document` | multi_document | `#78909C` blue-grey | stacked `<rect>` + `<path>` | Multiple documents |
| `subprocess` | subroutine | `#009688` teal | `<rect>` + inner `<line>` | Predefined process / sub-agent |
| `preparation` | hexagon | `#795548` brown | `<polygon>` 6-point | Setup / initialization step |
| `manual_input` | manual_input | `#E91E63` pink | `<polygon>` sloped top | Manual data entry |
| `manual_operation` | trapezoid | `#AD1457` dark pink | `<polygon>` tapered bottom | Human-in-the-loop / approval |
| `delay` | delay | `#FF5722` deep orange | `<path>` D-shape | Wait / pause / cooldown |
| `display` | display | `#00BCD4` cyan | `<path>` pointed left | Display / render output |
### Data Storage Symbols
| Type | Shape | Color | SVG Primitive | Description |
|---|---|---|---|---|
| `database` | cylinder | `#8BC34A` light green | `<path>` + `<ellipse>` top/bottom | Database / direct access storage |
| `stored_data` | stored_data | `#CDDC39` lime | `<path>` curved left | Generic data store |
| `internal_storage` | internal_storage | `#FFC107` amber | `<rect>` + internal `<line>` grid | Internal memory / cache |
### Connectors
| Type | Shape | Color | SVG Primitive | Description |
|---|---|---|---|---|
| `connector` | circle | `#9E9E9E` grey | `<circle>` | On-page connector |
| `offpage_connector` | pentagon | `#757575` dark grey | `<polygon>` 5-point | Off-page connector |
### Flow Operations
| Type | Shape | Color | SVG Primitive | Description |
|---|---|---|---|---|
| `merge` | triangle_inv | `#3F51B5` indigo | `<polygon>` inverted | Merge multiple flows |
| `extract` | triangle | `#5C6BC0` indigo light | `<polygon>` upward | Extract / split flow |
| `sort` | hourglass | `#7986CB` indigo lighter | `<polygon>` X-shape | Sort operation |
| `collate` | hourglass_inv | `#9FA8DA` indigo lightest | `<polygon>` X-shape inv | Collate operation |
| `summing_junction` | circle_cross | `#F06292` pink light | `<circle>` + cross `<line>` | Summing junction |
| `or` | circle_bar | `#CE93D8` purple light | `<circle>` + plus `<line>` | Logical OR |
### Domain-Specific (Hive)
| Type | Shape | Color | SVG Primitive | Description |
|---|---|---|---|---|
| `browser` | hexagon | `#1A237E` dark indigo | `<polygon>` 6-point | Browser automation (GCU node) |
| `comment` | flag | `#BDBDBD` light grey | `<path>` notched right | Annotation / comment |
| `alternate_process` | rounded_rect | `#42A5F5` light blue | `<rect rx={12}>` | Alternate process variant |
---
## 3. Auto-Classification Priority
When `flowchart_type` is omitted from a node, the backend classifies it automatically using this priority (function `_classify_flowchart_node` in `queen_lifecycle_tools.py`):
1. **Explicit override** — if `flowchart_type` is set and valid, use it
2. **Node type**`gcu` nodes become `browser`
3. **Position** — first node becomes `start`
4. **Terminal detection** — nodes in `terminal_nodes` (or with no outgoing edges) become `terminal`
5. **Branching structure** — nodes with 2+ outgoing edges with different conditions become `decision`
6. **Sub-agents** — nodes with `sub_agents` become `subprocess`
7. **Tool heuristics** — tool names match known patterns:
- DB tools (`query_database`, `sql_query`, `read_table`, etc.) → `database`
- Doc tools (`generate_report`, `create_document`, etc.) → `document`
- I/O tools (`send_email`, `post_to_slack`, `fetch_url`, etc.) → `io`
- Display tools (`serve_file_to_user`, `display_results`) → `display`
8. **Description keyword heuristics**:
- `"manual"`, `"approval"`, `"human review"``manual_operation`
- `"setup"`, `"prepare"`, `"configure"``preparation`
- `"wait"`, `"delay"`, `"pause"``delay`
- `"merge"`, `"combine"`, `"aggregate"``merge`
- `"display"`, `"show"`, `"render"``display`
- `"database"`, `"data store"`, `"persist"``database`
- `"report"`, `"document"`, `"summary"``document`
- `"deliver"`, `"send"`, `"notify"``io`
9. **Default**`process` (blue rectangle)
---
## 4. Decision Node Dissolution
When `confirm_and_build()` is called, decision nodes (flowchart diamonds) are dissolved into runtime-compatible structures by `_dissolve_decision_nodes()`. Decision nodes are a **planning-only** concept — they don't exist in the runtime graph.
### Algorithm
```
For each decision node D (in topological order):
1. Find predecessors P via incoming edges
2. Find yes-target and no-target via outgoing edges
- Yes: edge with label "Yes"/"True"/"Pass" or condition "on_success"
- No: edge with label "No"/"False"/"Fail" or condition "on_failure"
- Fallback: first outgoing = yes, second = no
3. Get decision clause: D.decision_clause || D.description || D.name
4. For each predecessor P:
- Append clause to P.success_criteria
- Remove edge P → D
- Add edge P → yes_target (on_success)
- Add edge P → no_target (on_failure)
5. Remove D and all its edges from the graph
6. Record absorption: flowchart_map[P.id] = [P.id, D.id]
```
### Edge Cases
| Case | Behavior |
|---|---|
| **Decision at start** (no predecessor) | Converted to a process node with `success_criteria` = clause; outgoing edges rewired to `on_success`/`on_failure` |
| **Chained decisions** (A → D1 → D2 → B) | Processed in order. D1 dissolves into A. D2's predecessor is now A, so D2 also dissolves into A. Map: `A → [A, D1, D2]` |
| **Multiple predecessors** | Each predecessor gets its own copy of the yes/no edges |
| **Existing success_criteria on predecessor** | Appended with `"; then evaluate: <clause>"` |
| **Decision with >2 outgoing edges** | First classified yes/no pair is used; remaining edges are preserved |
### Example
**Input (planning flowchart):**
```
[Fetch Billing Data] → <Amount > $100?> → Yes → [Generate PDF Receipt]
→ No → [Draft Email Receipt]
```
**Output (runtime graph):**
```
[Fetch Billing Data] → on_success → [Generate PDF Receipt]
→ on_failure → [Draft Email Receipt]
success_criteria: "Amount > $100?"
```
**Flowchart map:**
```json
{
"fetch-billing-data": ["fetch-billing-data", "amount-gt-100"],
"generate-pdf-receipt": ["generate-pdf-receipt"],
"draft-email-receipt": ["draft-email-receipt"]
}
```
The runtime Level 2 judge evaluates the decision clause against the node's conversation. `NodeResult.success = true` routes via `on_success` (yes), `false` routes via `on_failure` (no).
---
## 5. Frontend Rendering
### Component: `DraftGraph.tsx`
An SVG-based flowchart renderer that operates in two modes:
1. **Planning mode** — renders the draft graph with ISO 5807 shapes during the planning phase
2. **Runtime overlay mode** — renders the original (pre-dissolution) draft with live execution status when `flowchartMap` and `runtimeNodes` props are provided
#### Props
```typescript
interface DraftGraphProps {
draft: DraftGraphData; // The draft graph to render
onNodeClick?: (node: DraftNode) => void; // Node click handler
flowchartMap?: Record<string, string[]>; // Runtime → draft node mapping
runtimeNodes?: GraphNode[]; // Live runtime graph nodes with status
}
```
#### Layout Engine
The layout algorithm arranges nodes in layers based on graph topology:
1. **Layer assignment**: Each node's layer = max(parent layers) + 1. Root nodes are layer 0.
2. **Column assignment**: Within each layer, nodes are sorted by parent column average and centered.
3. **Node sizing**: `nodeW = min(360, availableWidth / maxColumns)` — nodes fill available space up to 360px.
4. **Container measurement**: A `ResizeObserver` measures the actual container width so SVG viewBox coordinates match CSS pixels 1:1.
```
Constants:
NODE_H = 52px (node height)
GAP_Y = 48px (vertical gap between layers)
GAP_X = 16px (horizontal gap between columns)
MARGIN_X = 16px (left/right margin)
TOP_Y = 28px (top padding)
```
#### Shape Rendering
The `FlowchartShape` component renders each ISO 5807 shape as SVG primitives. Each shape receives:
- `x, y, w, h` — bounding box in SVG units
- `color` — the hex color from the flowchart type
- `selected` — hover state (increases fill opacity from 18% to 28%, brightens stroke)
All shapes use `strokeWidth={1.2}` to prevent overflow on hover.
#### Edge Rendering
**Forward edges** (source layer < target layer):
- Rendered as cubic bezier curves from source bottom-center to target top-center
- Fan-out: when a node has multiple outgoing edges, start points spread across 40% of node width
- Labels shown at the midpoint (from `edge.label`, or condition/description fallback)
**Back edges** (source layer >= target layer):
- Rendered as dashed arcs that loop right of the graph
- Each back edge gets a unique offset to prevent overlap
#### Node Labels
Each node displays two lines of text:
- **Primary**: Node name (font size 13, truncated to fit `nodeW - 28px`)
- **Secondary**: Node description or flowchart type (font size 9.5, truncated to fit `nodeW - 24px`)
Truncation uses `avgCharWidth = fontSize * 0.58` to estimate available characters.
#### Tooltip
An HTML overlay (not SVG) positioned below hovered nodes, showing:
- Node description
- Tools list (`Tools: tool_a, tool_b`)
- Success criteria (`Criteria: ...`)
#### Legend
A dynamic legend at the bottom of the SVG listing all flowchart types used in the current draft, with their shape and color.
### Runtime Status Overlay
When `flowchartMap` and `runtimeNodes` are provided, the component computes per-node statuses:
1. **Invert the map**: `flowchartMap` maps `runtime_id → [draft_ids]`; inversion gives `draft_id → runtime_id`
2. **Map runtime status**: For each runtime node, classify status as `running` (amber), `complete` (green), `error` (red), or `pending` (no overlay)
3. **Render overlays**:
- **Glow ring**: A pulsing amber `<rect>` around running nodes, solid green/red for complete/error
- **Status dot**: A small `<circle>` in the top-right corner with animated radius for running nodes
4. **Header**: Changes from "Draft / planning" to "Flowchart / live"
```typescript
// Status color mapping
const STATUS_COLORS = {
running: "#F59E0B", // amber — pulsing glow
complete: "#22C55E", // green — solid ring
error: "#EF4444", // red — solid ring
pending: "", // no overlay
};
```
### Workspace Integration (`workspace.tsx`)
The workspace conditionally renders `DraftGraph` in three scenarios:
| Condition | Renders | Panel Width |
|---|---|---|
| `queenPhase === "planning"` and `draftGraph` exists | `<DraftGraph draft={draftGraph} />` | 500px |
| `originalDraft` exists (post-planning) | `<DraftGraph draft={originalDraft} flowchartMap={...} runtimeNodes={...} />` | 500px |
| Neither | `<AgentGraph ... />` (runtime pipeline view) | 300px |
**State management:**
- `draftGraph`: Set by `draft_graph_updated` SSE event during planning; cleared on phase change
- `originalDraft` + `flowchartMap`: Fetched from `GET /api/sessions/{id}/flowchart-map` when phase transitions away from planning
---
## 6. Events & API
### SSE Event: `draft_graph_updated`
Emitted when `save_agent_draft` completes. The full draft graph object is the event `data` payload.
```
event: message
data: {"type": "draft_graph_updated", "stream_id": "queen", "data": { ...draft graph object... }, ...}
```
### REST Endpoints
**`GET /api/sessions/{session_id}/draft-graph`**
Returns the current draft graph from planning phase.
```json
{"draft": <DraftGraph object>}
// or
{"draft": null}
```
**`GET /api/sessions/{session_id}/flowchart-map`**
Returns the flowchart-to-runtime mapping and original draft (available after `confirm_and_build()`).
```json
{
"map": { "runtime-node-id": ["draft-node-a", "draft-node-b"], ... },
"original_draft": { ...original DraftGraph before dissolution... }
}
// or
{"map": null, "original_draft": null}
```
---
## 7. Phase Gate
The draft graph is part of a two-step gate controlling the planning → building transition:
1. **`save_agent_draft()`** — creates the draft, classifies nodes, emits `draft_graph_updated`
2. User reviews the rendered flowchart (with decision diamonds, edge labels, color-coded shapes)
3. **`confirm_and_build()`** — dissolves decision nodes, preserves original draft, builds flowchart map, sets `build_confirmed = true`
4. **`initialize_and_build_agent()`** — checks `build_confirmed` before proceeding; passes the dissolved (decision-free) draft to the scaffolder for pre-population
The scaffolder never sees decision nodes — it receives a clean graph with only runtime-compatible node types where branching is expressed through `success_criteria` + `on_success`/`on_failure` edges.
-111
View File
@@ -1,111 +0,0 @@
# Local credential parity: aliases, identity, status, and credential tester integration
## Summary
Gives local API key credentials (Brave Search, GitHub, Exa, Stripe, etc.) the same feature set as Aden OAuth credentials: named aliases, identity metadata, status tracking, CRUD management, and full visibility in the credential tester.
Fixes a bug where credentials configured with the existing `store_credential` MCP tool were invisible in the credential tester account picker.
---
## Changes
### New: `core/framework/credentials/local/`
**`models.py`** — `LocalAccountInfo` dataclass mirroring `AdenIntegrationInfo`:
- Fields: `credential_id`, `alias`, `status` (`active` / `failed` / `unknown`), `identity`, `last_validated`, `created_at`
- `storage_id` property returns `"{credential_id}/{alias}"` (e.g. `brave_search/work`)
- `to_account_dict()` returns same shape as Aden account dicts — feeds account picker without changes
**`registry.py`** — `LocalCredentialRegistry`, the core engine:
- `save_account(credential_id, alias, api_key)` — runs health check, extracts identity, stores at `{credential_id}/{alias}` in `EncryptedFileStorage`
- `list_accounts(credential_id=None)` — reads all `{x}/{y}` entries from storage
- `get_key(credential_id, alias)` — returns raw secret
- `delete_account(credential_id, alias)` — removes entry
- `validate_account(credential_id, alias)` — re-runs health check, updates `_status` and `last_refreshed` in-place
- `default()` classmethod — uses `~/.hive/credentials`
Storage convention: `{credential_id}/{alias}` as `CredentialObject.id`. Legacy flat entries (`brave_search`, no slash) continue to work — env var fallback is unchanged.
---
### Modified: `tools/src/aden_tools/credentials/store_adapter.py`
- `get(name, account=None)` — added `account=` param for per-call routing to a named local account; mirrors Aden `account=` routing
- `activate_local_account(credential_id, alias)` — injects a named account's key into `os.environ[spec.env_var]` for session-level activation
- `list_local_accounts(credential_id=None)` — delegates to `LocalCredentialRegistry`
---
### Modified: `core/framework/credentials/__init__.py`
Exports `LocalAccountInfo` and `LocalCredentialRegistry`.
---
### Modified: `core/framework/agents/credential_tester/agent.py`
Full rewrite of account listing and configuration:
- `_list_aden_accounts()` — extracted from old `list_connected_accounts()`
- `_list_local_accounts()` — uses `LocalCredentialRegistry`
- `_list_env_fallback_accounts()` — detects credentials configured via env var **or** in old flat encrypted format; fixes the invisible-credential bug
- `list_connected_accounts()` — combines all three, deduplicates
- `configure_for_account()` — branches on `source` field:
- `"aden"` → adds `get_account_info` tool, prompts with `account="alias"`
- `"local"` → calls `_activate_local_account()`, prompt has no `account=` param
- `_activate_local_account()` — handles three cases: named registry entry, old flat encrypted entry, env var already set; also handles grouped credentials (e.g. `google_custom_search` sets both `GOOGLE_API_KEY` and `GOOGLE_CSE_ID`)
- `get_tools_for_provider()` — fixed to match both `credential_id` AND `credential_group`
---
### Modified: `core/framework/builder/package_generator.py`
- `store_credential(name, value, alias="default", ...)` — added `alias` param; now delegates to `LocalCredentialRegistry.save_account()` with auto health check; returns `status` and `identity`
- `list_stored_credentials()` — delegates to `LocalCredentialRegistry.list_accounts()`; returns `credential_id`, `alias`, `status`, `identity`, `last_validated`
- `delete_stored_credential(name, alias="default")` — added `alias` param
- `validate_credential(name, alias="default")`**new tool** — re-runs health check via `LocalCredentialRegistry.validate_account()`, returns updated status and identity
---
### Modified: `core/framework/tui/screens/account_selection.py`
- Aden accounts rendered first, local accounts second
- Local accounts display a `[local]` badge
- Identity label shows email, username, or workspace when available
---
### New: `core/framework/tui/screens/add_local_credential.py`
Two-phase modal for adding a named local API key:
1. **Type selection** — filtered list of all `direct_api_key_supported=True` credentials
2. **Form** — alias input + password input → "Test & Save" runs health check inline, shows identity result, auto-dismisses on success
Exported from `core/framework/tui/screens/__init__.py`.
---
## Bug fix
**Credential tester not showing configured credentials** (e.g. Brave Search stored via `store_credential`):
- `_list_env_fallback_accounts()` previously used `CredentialStoreAdapter.with_env_storage()`, which only checked `os.environ`. Credentials stored in `EncryptedFileStorage` with the old flat format (`brave_search`, no slash) were invisible.
- `_activate_local_account()` early-returned when `alias == "default"`, assuming the env var was already set. Old flat encrypted credentials are not in `os.environ`.
**Fix**: `_list_env_fallback_accounts()` now also reads `EncryptedFileStorage.list_all()` and treats any flat entry (no `/`) as configured. `_activate_local_account()` now falls through to load from the flat encrypted entry when the env var is not set and the registry has no named entry.
---
## Test plan
- [ ] `store_credential("brave_search", "BSA-xxx", alias="work")` → health check runs, identity shown, stored as `brave_search/work`
- [ ] `list_stored_credentials()` → shows `credential_id`, `alias`, `status`, `identity`, `last_validated`
- [ ] `validate_credential("brave_search", "work")` → re-runs health check, updates status
- [ ] `delete_stored_credential("brave_search", alias="work")` → removes entry
- [ ] Credential tester account picker shows local accounts with `[local]` badge alongside Aden accounts
- [ ] Selecting a local account activates the key and tools work without `account=` param
- [ ] Selecting a legacy flat credential (stored before this PR) activates it correctly
- [ ] `AddLocalCredentialScreen` — select type, enter alias + key, health check runs inline, screen closes on success
- [ ] `CredentialStoreAdapter.get("brave_search", account="work")` returns key from registry
-56
View File
@@ -1,56 +0,0 @@
# feat(queen): Hive Queen Bee — native agent-building agent
## Summary
Introduces **Hive Coder** (codename "Queen Bee"), a framework-native coding agent that builds complete Hive agent packages from natural language descriptions. This is a single-node, forever-alive agent inspired by opencode's `while(true)` loop — one continuous conversation handles the full lifecycle: understand, qualify, design, implement, verify, and iterate.
The agent is deeply integrated with the framework: it can discover available MCP tools at runtime, inspect sessions and checkpoints of agents it builds, run their test suites, and self-verify its own output. It ships with a dedicated MCP tools server (`coder_tools_server.py`) providing rich file I/O, fuzzy-match editing, git snapshots, and shell execution — all scoped to a configurable project root.
## What's included
### New: `hive_coder` agent (`core/framework/agents/hive_coder/`)
- **`agent.py`** — Goal with 4 success criteria and 4 constraints, single-node graph, `HiveCoderAgent` class with full runtime lifecycle (start/stop/trigger_and_wait)
- **`nodes/__init__.py`** — Single `coder` EventLoopNode with a comprehensive system prompt covering coding mandates, tool discovery, meta-agent capabilities, node count rules, implementation templates, and a 6-phase workflow
- **`config.py`** — RuntimeConfig with auto-detection of preferred model from `~/.hive/configuration.json`
- **`__main__.py`** — Click CLI with `run`, `tui`, `info`, `validate`, and `shell` subcommands
- **`reference/`** — Framework guide, file templates, and anti-patterns docs embedded as agent reference material
### New: Coder Tools MCP Server (`tools/coder_tools_server.py`)
- 1500-line MCP server providing 13 tools: `read_file`, `write_file`, `edit_file` (with opencode-style 9-strategy fuzzy matching), `list_directory`, `search_files`, `run_command`, `undo_changes`, `discover_mcp_tools`, `list_agents`, `list_agent_sessions`, `list_agent_checkpoints`, `get_agent_checkpoint`, `run_agent_tests`
- Path-scoped security: all file operations sandboxed to project root
- Git-based undo: automatic snapshots before writes with `undo_changes` rollback
### Framework changes
- **`hive code` CLI command** — Direct launch shortcut for Hive Coder via `cmd_code` in `runner/cli.py`
- **`hive tui` updated** — Now discovers framework agents alongside exports/ and examples/
- **Cron timer support** — `AgentRuntime` now supports cron expressions (`croniter`) in addition to fixed-interval timers for async entry points
- **Datetime in system prompts** — `prompt_composer._with_datetime()` appends current datetime to all composed system prompts; EventLoopNode also applies it for isolated conversations
- **`max_node_visits` default → 0** — Changed from 1 to 0 (unbounded) across `NodeSpec` and executor, matching the forever-alive pattern as the standard default
- **TUI graph view** — Timer display updated to show cron expressions and hours in countdown
- **CredentialError handling** — `_setup()` calls in TUI launch paths now catch and display credential errors gracefully
### Tests
- New `test_agent_runtime.py` tests for cron-based timer scheduling
## Architecture
```
User ──▶ [coder] (EventLoopNode, client_facing, forever-alive)
│ Tools: coder_tools_server.py (file I/O, shell, git)
│ + meta-agent tools (discover, inspect, test)
└──▶ loops continuously until user exits
```
Single node. No edges. No terminal nodes. The agent stays alive and handles multiple build requests in one session — context accumulates across interactions.
## Test plan
- [ ] `hive code` launches Hive Coder TUI successfully
- [ ] `hive tui` shows "Framework Agents" as a source option
- [ ] Agent can discover tools via `discover_mcp_tools()`
- [ ] Agent generates a valid agent package from a natural language request
- [ ] Generated packages pass `AgentRunner.load()` validation
- [ ] Cron timer tests pass (`test_agent_runtime.py`)
- [ ] Existing tests unaffected by `max_node_visits` default change
@@ -12,7 +12,6 @@ from .agent import (
nodes,
edges,
loop_config,
async_entry_points,
entry_node,
entry_points,
pause_nodes,
@@ -31,7 +30,6 @@ __all__ = [
"nodes",
"edges",
"loop_config",
"async_entry_points",
"entry_node",
"entry_points",
"pause_nodes",
@@ -4,7 +4,7 @@ from pathlib import Path
from framework.graph import EdgeCondition, EdgeSpec, Goal, SuccessCriterion, Constraint
from framework.graph.checkpoint_config import CheckpointConfig
from framework.graph.edge import AsyncEntryPointSpec, GraphSpec
from framework.graph.edge import GraphSpec
from framework.graph.executor import ExecutionResult, GraphExecutor
from framework.llm import LiteLLMProvider
from framework.runner.tool_registry import ToolRegistry
@@ -152,17 +152,6 @@ edges = [
# Graph configuration
entry_node = "intake"
entry_points = {"start": "intake"}
async_entry_points = [
AsyncEntryPointSpec(
id="email-timer",
name="Scheduled Inbox Check",
entry_node="fetch-emails",
trigger_type="timer",
trigger_config={"interval_minutes": 5},
isolation_level="shared",
max_concurrent=1,
),
]
pause_nodes = []
terminal_nodes = []
loop_config = {
@@ -224,7 +213,6 @@ class EmailInboxManagementAgent:
loop_config=loop_config,
conversation_mode=conversation_mode,
identity_prompt=identity_prompt,
async_entry_points=async_entry_points,
)
def _setup(self, mock_mode=False) -> None:
@@ -275,16 +263,6 @@ class EmailInboxManagementAgent:
trigger_type="manual",
isolation_level="shared",
),
# Timer-driven entry point
EntryPointSpec(
id="email-timer",
name="Scheduled Inbox Check",
entry_node="fetch-emails",
trigger_type="timer",
trigger_config={"interval_minutes": 5},
isolation_level="shared",
max_concurrent=1,
),
]
self._agent_runtime = create_agent_runtime(
@@ -360,10 +338,6 @@ class EmailInboxManagementAgent:
"pause_nodes": self.pause_nodes,
"terminal_nodes": self.terminal_nodes,
"client_facing_nodes": [n.id for n in self.nodes if n.client_facing],
"async_entry_points": [
{"id": ep.id, "name": ep.name, "entry_node": ep.entry_node}
for ep in async_entry_points
],
}
def validate(self):
@@ -391,13 +365,6 @@ class EmailInboxManagementAgent:
f"Entry point '{ep_id}' references unknown node '{node_id}'"
)
# Validate async entry points
for ep in async_entry_points:
if ep.entry_node not in node_ids:
errors.append(
f"Async entry point '{ep.id}' references unknown node '{ep.entry_node}'"
)
return {
"valid": len(errors) == 0,
"errors": errors,
@@ -0,0 +1,11 @@
[
{
"id": "email-timer",
"name": "Scheduled Inbox Check",
"trigger_type": "timer",
"trigger_config": {
"interval_minutes": 5
},
"task": "Fetch and process inbox emails according to the user's rules"
}
]
+68 -12
View File
@@ -466,6 +466,23 @@ if [ "$USE_ASSOC_ARRAYS" = true ]; then
["cerebras:1"]=8192
)
# Max context tokens (input history budget) per model, based on actual context windows.
# Leave ~10% headroom for system prompt and output tokens.
declare -A MODEL_CHOICES_MAXCONTEXTTOKENS=(
["anthropic:0"]=180000 # Claude Haiku 4.5 — 200k context window
["anthropic:1"]=180000 # Claude Sonnet 4 — 200k context window
["anthropic:2"]=180000 # Claude Sonnet 4.5 — 200k context window
["anthropic:3"]=180000 # Claude Opus 4.6 — 200k context window
["openai:0"]=120000 # GPT-5 Mini — 128k context window
["openai:1"]=120000 # GPT-5.2 — 128k context window
["gemini:0"]=900000 # Gemini 3 Flash — 1M context window
["gemini:1"]=900000 # Gemini 3.1 Pro — 1M context window
["groq:0"]=120000 # Kimi K2 — 128k context window
["groq:1"]=120000 # GPT-OSS 120B — 128k context window
["cerebras:0"]=120000 # ZAI-GLM 4.7 — 128k context window
["cerebras:1"]=120000 # Qwen3 235B — 128k context window
)
declare -A MODEL_CHOICES_COUNT=(
["anthropic"]=4
["openai"]=2
@@ -502,6 +519,10 @@ if [ "$USE_ASSOC_ARRAYS" = true ]; then
get_model_choice_maxtokens() {
echo "${MODEL_CHOICES_MAXTOKENS[$1:$2]}"
}
get_model_choice_maxcontexttokens() {
echo "${MODEL_CHOICES_MAXCONTEXTTOKENS[$1:$2]}"
}
else
# Bash 3.2 - use parallel indexed arrays
PROVIDER_ENV_VARS=(ANTHROPIC_API_KEY OPENAI_API_KEY MINIMAX_API_KEY GEMINI_API_KEY GOOGLE_API_KEY GROQ_API_KEY CEREBRAS_API_KEY MISTRAL_API_KEY TOGETHER_API_KEY DEEPSEEK_API_KEY)
@@ -557,6 +578,9 @@ else
MC_IDS=("claude-haiku-4-5-20251001" "claude-sonnet-4-20250514" "claude-sonnet-4-5-20250929" "claude-opus-4-6" "gpt-5-mini" "gpt-5.2" "gemini-3-flash-preview" "gemini-3.1-pro-preview" "moonshotai/kimi-k2-instruct-0905" "openai/gpt-oss-120b" "zai-glm-4.7" "qwen3-235b-a22b-instruct-2507")
MC_LABELS=("Haiku 4.5 - Fast + cheap (recommended)" "Sonnet 4 - Fast + capable" "Sonnet 4.5 - Best balance" "Opus 4.6 - Most capable" "GPT-5 Mini - Fast + cheap (recommended)" "GPT-5.2 - Most capable" "Gemini 3 Flash - Fast (recommended)" "Gemini 3.1 Pro - Best quality" "Kimi K2 - Best quality (recommended)" "GPT-OSS 120B - Fast reasoning" "ZAI-GLM 4.7 - Best quality (recommended)" "Qwen3 235B - Frontier reasoning")
MC_MAXTOKENS=(8192 8192 16384 32768 16384 16384 8192 8192 8192 8192 8192 8192)
# Max context tokens per model (same order as MC_PROVIDERS/MC_IDS above)
# Based on actual context windows with ~10% headroom for system prompt + output.
MC_MAXCONTEXTTOKENS=(180000 180000 180000 180000 120000 120000 900000 900000 120000 120000 120000 120000)
# Helper: get number of model choices for a provider
get_model_choice_count() {
@@ -625,6 +649,24 @@ else
i=$((i + 1))
done
}
# Helper: get model choice max_context_tokens by provider and index
get_model_choice_maxcontexttokens() {
local provider_id="$1"
local idx="$2"
local count=0
local i=0
while [ $i -lt ${#MC_PROVIDERS[@]} ]; do
if [ "${MC_PROVIDERS[$i]}" = "$provider_id" ]; then
if [ $count -eq "$idx" ]; then
echo "${MC_MAXCONTEXTTOKENS[$i]}"
return
fi
count=$((count + 1))
fi
i=$((i + 1))
done
}
fi
# Configuration directory
@@ -664,7 +706,7 @@ SHELL_RC_FILE=$(detect_shell_rc)
SHELL_NAME=$(basename "$SHELL")
# Prompt the user to choose a model for their selected provider.
# Sets SELECTED_MODEL and SELECTED_MAX_TOKENS.
# Sets SELECTED_MODEL, SELECTED_MAX_TOKENS, and SELECTED_MAX_CONTEXT_TOKENS.
prompt_model_selection() {
local provider_id="$1"
local count
@@ -674,6 +716,7 @@ prompt_model_selection() {
# No curated choices for this provider (e.g. Mistral, DeepSeek)
SELECTED_MODEL="$(get_default_model "$provider_id")"
SELECTED_MAX_TOKENS=8192
SELECTED_MAX_CONTEXT_TOKENS=120000 # 128k context window (Mistral, DeepSeek, etc.)
return
fi
@@ -681,6 +724,7 @@ prompt_model_selection() {
# Only one choice — auto-select
SELECTED_MODEL="$(get_model_choice_id "$provider_id" 0)"
SELECTED_MAX_TOKENS="$(get_model_choice_maxtokens "$provider_id" 0)"
SELECTED_MAX_CONTEXT_TOKENS="$(get_model_choice_maxcontexttokens "$provider_id" 0)"
return
fi
@@ -726,6 +770,7 @@ prompt_model_selection() {
local idx=$((choice - 1))
SELECTED_MODEL="$(get_model_choice_id "$provider_id" "$idx")"
SELECTED_MAX_TOKENS="$(get_model_choice_maxtokens "$provider_id" "$idx")"
SELECTED_MAX_CONTEXT_TOKENS="$(get_model_choice_maxcontexttokens "$provider_id" "$idx")"
echo ""
echo -e "${GREEN}${NC} Model: ${DIM}$SELECTED_MODEL${NC}"
return
@@ -735,15 +780,16 @@ prompt_model_selection() {
}
# Function to save configuration
# Args: provider_id env_var model max_tokens [use_claude_code_sub] [api_base] [use_codex_sub]
# Args: provider_id env_var model max_tokens max_context_tokens [use_claude_code_sub] [api_base] [use_codex_sub]
save_configuration() {
local provider_id="$1"
local env_var="$2"
local model="$3"
local max_tokens="$4"
local use_claude_code_sub="${5:-}"
local api_base="${6:-}"
local use_codex_sub="${7:-}"
local max_context_tokens="$5"
local use_claude_code_sub="${6:-}"
local api_base="${7:-}"
local use_codex_sub="${8:-}"
# Fallbacks if not provided
if [ -z "$model" ]; then
@@ -752,6 +798,9 @@ save_configuration() {
if [ -z "$max_tokens" ]; then
max_tokens=8192
fi
if [ -z "$max_context_tokens" ]; then
max_context_tokens=120000
fi
mkdir -p "$HIVE_CONFIG_DIR"
@@ -762,6 +811,7 @@ config = {
'provider': '$provider_id',
'model': '$model',
'max_tokens': $max_tokens,
'max_context_tokens': $max_context_tokens,
'api_key_env_var': '$env_var'
},
'created_at': '$(date -u +"%Y-%m-%dT%H:%M:%S+00:00")'
@@ -796,7 +846,8 @@ FOUND_ENV_VARS=() # Corresponding env var names
SELECTED_PROVIDER_ID="" # Will hold the chosen provider ID
SELECTED_ENV_VAR="" # Will hold the chosen env var
SELECTED_MODEL="" # Will hold the chosen model ID
SELECTED_MAX_TOKENS=8192 # Will hold the chosen max_tokens
SELECTED_MAX_TOKENS=8192 # Will hold the chosen max_tokens (output limit)
SELECTED_MAX_CONTEXT_TOKENS=120000 # Will hold the chosen max_context_tokens (input history budget)
SUBSCRIPTION_MODE="" # "claude_code" | "codex" | "zai_code" | ""
# ── Credential detection (silent — just set flags) ───────────
@@ -1006,6 +1057,7 @@ case $choice in
SELECTED_PROVIDER_ID="anthropic"
SELECTED_MODEL="claude-opus-4-6"
SELECTED_MAX_TOKENS=32768
SELECTED_MAX_CONTEXT_TOKENS=180000 # Claude — 200k context window
echo ""
echo -e "${GREEN}${NC} Using Claude Code subscription"
fi
@@ -1017,6 +1069,7 @@ case $choice in
SELECTED_ENV_VAR="ZAI_API_KEY"
SELECTED_MODEL="glm-5"
SELECTED_MAX_TOKENS=32768
SELECTED_MAX_CONTEXT_TOKENS=120000 # GLM-5 — 128k context window
PROVIDER_NAME="ZAI"
echo ""
echo -e "${GREEN}${NC} Using ZAI Code subscription"
@@ -1047,6 +1100,7 @@ case $choice in
SELECTED_PROVIDER_ID="openai"
SELECTED_MODEL="gpt-5.3-codex"
SELECTED_MAX_TOKENS=16384
SELECTED_MAX_CONTEXT_TOKENS=120000 # GPT Codex — 128k context window
echo ""
echo -e "${GREEN}${NC} Using OpenAI Codex subscription"
fi
@@ -1058,6 +1112,7 @@ case $choice in
SELECTED_PROVIDER_ID="minimax"
SELECTED_MODEL="MiniMax-M2.5"
SELECTED_MAX_TOKENS=32768
SELECTED_MAX_CONTEXT_TOKENS=900000 # MiniMax M2.5 — 1M context window
SELECTED_API_BASE="https://api.minimax.io/v1"
PROVIDER_NAME="MiniMax"
SIGNUP_URL="https://platform.minimax.io/user-center/basic-information/interface-key"
@@ -1072,6 +1127,7 @@ case $choice in
SELECTED_ENV_VAR="KIMI_API_KEY"
SELECTED_MODEL="kimi-k2.5"
SELECTED_MAX_TOKENS=32768
SELECTED_MAX_CONTEXT_TOKENS=120000 # Kimi K2.5 — 128k context window
SELECTED_API_BASE="https://api.kimi.com/coding"
PROVIDER_NAME="Kimi"
SIGNUP_URL="https://www.kimi.com/code"
@@ -1263,17 +1319,17 @@ if [ -n "$SELECTED_PROVIDER_ID" ]; then
echo ""
echo -n " Saving configuration... "
if [ "$SUBSCRIPTION_MODE" = "claude_code" ]; then
save_configuration "$SELECTED_PROVIDER_ID" "" "$SELECTED_MODEL" "$SELECTED_MAX_TOKENS" "true" "" > /dev/null
save_configuration "$SELECTED_PROVIDER_ID" "" "$SELECTED_MODEL" "$SELECTED_MAX_TOKENS" "$SELECTED_MAX_CONTEXT_TOKENS" "true" "" > /dev/null
elif [ "$SUBSCRIPTION_MODE" = "codex" ]; then
save_configuration "$SELECTED_PROVIDER_ID" "" "$SELECTED_MODEL" "$SELECTED_MAX_TOKENS" "" "" "true" > /dev/null
save_configuration "$SELECTED_PROVIDER_ID" "" "$SELECTED_MODEL" "$SELECTED_MAX_TOKENS" "$SELECTED_MAX_CONTEXT_TOKENS" "" "" "true" > /dev/null
elif [ "$SUBSCRIPTION_MODE" = "zai_code" ]; then
save_configuration "$SELECTED_PROVIDER_ID" "$SELECTED_ENV_VAR" "$SELECTED_MODEL" "$SELECTED_MAX_TOKENS" "" "https://api.z.ai/api/coding/paas/v4" > /dev/null
save_configuration "$SELECTED_PROVIDER_ID" "$SELECTED_ENV_VAR" "$SELECTED_MODEL" "$SELECTED_MAX_TOKENS" "$SELECTED_MAX_CONTEXT_TOKENS" "" "https://api.z.ai/api/coding/paas/v4" > /dev/null
elif [ "$SUBSCRIPTION_MODE" = "minimax_code" ]; then
save_configuration "$SELECTED_PROVIDER_ID" "$SELECTED_ENV_VAR" "$SELECTED_MODEL" "$SELECTED_MAX_TOKENS" "" "$SELECTED_API_BASE" > /dev/null
save_configuration "$SELECTED_PROVIDER_ID" "$SELECTED_ENV_VAR" "$SELECTED_MODEL" "$SELECTED_MAX_TOKENS" "$SELECTED_MAX_CONTEXT_TOKENS" "" "$SELECTED_API_BASE" > /dev/null
elif [ "$SUBSCRIPTION_MODE" = "kimi_code" ]; then
save_configuration "$SELECTED_PROVIDER_ID" "$SELECTED_ENV_VAR" "$SELECTED_MODEL" "$SELECTED_MAX_TOKENS" "" "$SELECTED_API_BASE" > /dev/null
save_configuration "$SELECTED_PROVIDER_ID" "$SELECTED_ENV_VAR" "$SELECTED_MODEL" "$SELECTED_MAX_TOKENS" "$SELECTED_MAX_CONTEXT_TOKENS" "" "$SELECTED_API_BASE" > /dev/null
else
save_configuration "$SELECTED_PROVIDER_ID" "$SELECTED_ENV_VAR" "$SELECTED_MODEL" "$SELECTED_MAX_TOKENS" > /dev/null
save_configuration "$SELECTED_PROVIDER_ID" "$SELECTED_ENV_VAR" "$SELECTED_MODEL" "$SELECTED_MAX_TOKENS" "$SELECTED_MAX_CONTEXT_TOKENS" > /dev/null
fi
echo -e "${GREEN}${NC}"
echo -e " ${DIM}~/.hive/configuration.json${NC}"
+2 -1
View File
@@ -13,7 +13,8 @@ from framework.agents.queen.nodes import (
_DEFAULT_WORKER_IDENTITY = (
"\n\n# Worker Profile\n"
"No worker agent loaded. You are operating independently.\n"
"Handle all tasks directly using your coding tools."
"Design or build the agent to solve the user's problem "
"according to your current phase."
)
+332 -56
View File
@@ -334,8 +334,10 @@ def undo_changes(path: str = "") -> str:
@mcp.tool()
def list_agent_tools(
server_config_path: str = "",
output_schema: str = "simple",
output_schema: str = "summary",
group: str = "all",
credentials: str = "all",
service: str = "",
) -> str:
"""Discover tools available for agent building, grouped by provider.
@@ -343,22 +345,52 @@ def list_agent_tools(
BEFORE designing an agent to know exactly which tools exist. Only use
tools from this list in node definitions never guess or fabricate.
Progressive disclosure workflow (start narrow, drill in):
list_agent_tools() # provider summary
list_agent_tools(group="google", output_schema="summary") # service breakdown
list_agent_tools(group="google", service="gmail") # tool names for just gmail
list_agent_tools(group="google", service="gmail", output_schema="full") # full detail
Args:
server_config_path: Path to mcp_servers.json. Default: tools/mcp_servers.json
(the standard hive-tools server). Can also point to an agent's config
to see what tools that specific agent has access to.
output_schema: "simple" (default) returns name and description per tool.
"full" also includes server and input_schema.
output_schema: Controls verbosity of the response.
"summary" (default) provider list with tool counts + credential status. Very compact.
When group is specified, shows service-level breakdown within that provider.
"names" tool names only (no descriptions), grouped by provider.
"simple" names + truncated descriptions.
"full" names + descriptions + server + input_schema.
group: "all" (default) returns all providers. A provider like "google"
returns only that provider's tools. Legacy prefix filters (e.g. "gmail")
are still supported.
credentials: Filter by credential availability.
"all" (default) show every tool regardless of credential status.
"available" only tools whose credentials are already configured.
"unavailable" only tools that still need credential setup.
service: Filter to a specific service within a provider (e.g. service="gmail"
when group="google"). Matches tools whose name starts with "<service>_".
Returns:
JSON with tools grouped by provider.
"""
if output_schema not in ("simple", "full"):
if output_schema not in ("summary", "names", "simple", "full"):
return json.dumps(
{"error": f"Invalid output_schema: {output_schema!r}. Use 'simple' or 'full'."}
{
"error": (
f"Invalid output_schema: {output_schema!r}. "
"Use 'summary', 'names', 'simple', or 'full'."
)
}
)
if credentials not in ("all", "available", "unavailable"):
return json.dumps(
{
"error": (
f"Invalid credentials: {credentials!r}. "
"Use 'all', 'available', or 'unavailable'."
)
}
)
# Resolve config path
@@ -472,6 +504,33 @@ def list_agent_tools(
tool_provider_auth, tool_providers = _build_provider_metadata()
def _get_available_credential_names() -> set[str]:
"""Return set of credential spec keys whose env_var is set in the environment."""
try:
from framework.credentials.validation import ensure_credential_key_env
ensure_credential_key_env()
except Exception:
pass
try:
from aden_tools.credentials import CREDENTIAL_SPECS
except ImportError:
return set()
return {
cred_name
for cred_name, spec in CREDENTIAL_SPECS.items()
if spec.env_var and os.environ.get(spec.env_var)
}
def _tool_credentials_available(tool_name: str, available_creds: set[str]) -> bool:
"""True if all credentials required by tool_name are available (or tool needs none)."""
required = set()
for provider_creds in tool_provider_auth.get(tool_name, {}).values():
required.update(provider_creds.keys())
if not required:
return True # no credentials needed
return required.issubset(available_creds)
def _group_by_provider(tools: list[dict]) -> dict[str, dict]:
"""Group tools by provider, including auth metadata and providerless tools."""
groups: dict[str, dict] = {}
@@ -481,16 +540,20 @@ def list_agent_tools(
if not providers:
providers = ["no_provider"]
desc = t["description"]
if output_schema == "simple" and desc and len(desc) > 200:
desc = desc[:200].rsplit(" ", 1)[0] + "..."
tool_payload = {
"name": t["name"],
"description": desc,
}
if output_schema == "full":
tool_payload["server"] = t["server"]
tool_payload["input_schema"] = t["input_schema"]
if output_schema == "names":
# Store just the name string — will be collapsed to flat list below
tool_payload: dict | str = t["name"]
else:
desc = t["description"]
if output_schema == "simple" and desc and len(desc) > 200:
desc = desc[:200].rsplit(" ", 1)[0] + "..."
tool_payload = {
"name": t["name"],
"description": desc,
}
if output_schema == "full":
tool_payload["server"] = t["server"]
tool_payload["input_schema"] = t["input_schema"]
for provider in providers:
bucket = groups.setdefault(
@@ -502,17 +565,50 @@ def list_agent_tools(
)
bucket["tools"].append(tool_payload)
provider_auth = tool_provider_auth.get(t["name"], {}).get(provider, {})
for cred_name, auth in provider_auth.items():
bucket["authorization"][cred_name] = auth
# Only accumulate full auth metadata for simple/full schemas.
# summary/names use compact representations.
if output_schema not in ("summary", "names"):
provider_auth = tool_provider_auth.get(t["name"], {}).get(provider, {})
for cred_name, auth in provider_auth.items():
bucket["authorization"][cred_name] = auth
for _provider, bucket in groups.items():
bucket["tools"] = sorted(bucket["tools"], key=lambda x: x["name"])
bucket["authorization"] = dict(sorted(bucket["authorization"].items()))
for provider, bucket in groups.items():
if output_schema == "names":
# Collapse to compact structure: flat sorted name list + credential keys only
tool_names = sorted(set(bucket["tools"]))
cred_keys: set[str] = set()
for tn in tool_names:
for prov_creds in tool_provider_auth.get(tn, {}).values():
cred_keys.update(prov_creds.keys())
groups[provider] = {
"tool_count": len(tool_names),
"credentials_required": sorted(cred_keys),
"tool_names": tool_names,
}
else:
bucket["tools"] = sorted(bucket["tools"], key=lambda x: x["name"])
bucket["authorization"] = dict(sorted(bucket["authorization"].items()))
return dict(sorted(groups.items()))
provider_groups = _group_by_provider(all_tools)
# Compute credential availability once (used for filtering and summary)
available_creds: set[str] = (
_get_available_credential_names()
if credentials != "all" or output_schema == "summary"
else set()
)
# Apply credentials filter before grouping (filter tool list)
filtered_tools = all_tools
if credentials != "all":
filtered_tools = [
t
for t in all_tools
if (credentials == "available")
== _tool_credentials_available(t["name"], available_creds)
]
provider_groups = _group_by_provider(filtered_tools)
# Filter to a specific provider (preferred) or legacy prefix (fallback)
if group != "all":
@@ -520,20 +616,113 @@ def list_agent_tools(
provider_groups = {group: provider_groups[group]}
else:
prefixed_tools = []
for t in all_tools:
for t in filtered_tools:
parts = t["name"].split("_", 1)
prefix = parts[0] if len(parts) > 1 else "general"
if prefix == group:
prefixed_tools.append(t)
provider_groups = _group_by_provider(prefixed_tools)
all_names = sorted({t["name"] for p in provider_groups.values() for t in p["tools"]})
result: dict = {
"total": len(all_names),
"tools_by_provider": provider_groups,
"tools_by_category": provider_groups, # backward-compat alias
"all_tool_names": all_names,
}
# Apply service filter (tool name prefix within a provider, e.g. service="gmail")
if service:
service_prefix = service.rstrip("_") + "_"
service_filtered: list[dict] = []
for t in filtered_tools:
# Only include tools from the already-filtered provider set
tool_name = t["name"]
in_provider = any(
tool_name
in p.get(
"tool_names", [tool_entry.get("name") for tool_entry in p.get("tools", [])]
)
for p in provider_groups.values()
)
if in_provider and tool_name.startswith(service_prefix):
service_filtered.append(t)
provider_groups = _group_by_provider(service_filtered)
def _infer_service(tool_name: str) -> str:
"""Infer service name from tool name prefix (e.g. 'gmail' from 'gmail_send_message')."""
return tool_name.split("_", 1)[0]
# Summary mode: compact overview with counts + credential status
if output_schema == "summary":
if group == "all":
# Provider-level summary (default first call)
full_groups = _group_by_provider(all_tools) if credentials != "all" else provider_groups
summary_providers: dict = {}
for prov, bucket in full_groups.items():
cred_names = bucket.get(
"credentials_required", sorted(bucket.get("authorization", {}).keys())
)
creds_ok = all(c in available_creds for c in cred_names) if cred_names else True
summary_providers[prov] = {
"tool_count": len(bucket.get("tool_names", bucket.get("tools", []))),
"credentials_required": cred_names,
"credentials_available": creds_ok,
}
result: dict = {
"total_tools": sum(v["tool_count"] for v in summary_providers.values()),
"providers": summary_providers,
"hint": (
"Use list_agent_tools(group='<provider>', "
"output_schema='summary') for service breakdown, "
"list_agent_tools(group='<provider>', service='<service>') for tool names. "
"Filter by credentials='available' to see only ready-to-use tools."
),
}
else:
# Service-level breakdown within a specific provider
# Re-build from all filtered tools for this provider (ignore service filter for summary)
provider_tool_names: list[str] = []
for bucket in provider_groups.values():
provider_tool_names.extend(
bucket.get("tool_names", [e.get("name") for e in bucket.get("tools", [])])
)
services: dict = {}
for tn in sorted(set(provider_tool_names)):
svc = _infer_service(tn)
if svc not in services:
svc_creds: set[str] = set()
for prov_creds in tool_provider_auth.get(tn, {}).values():
svc_creds.update(prov_creds.keys())
services[svc] = {"tool_count": 0, "credentials_required": sorted(svc_creds)}
services[svc]["tool_count"] += 1
# Accumulate credentials for other tools in this service
for prov_creds in tool_provider_auth.get(tn, {}).values():
existing = set(services[svc]["credentials_required"])
existing.update(prov_creds.keys())
services[svc]["credentials_required"] = sorted(existing)
result = {
"provider": group,
"total_tools": len(provider_tool_names),
"services": services,
"hint": (
f"Use list_agent_tools(group='{group}', service='<service>') "
"for tool names within a service."
),
}
if errors:
result["errors"] = errors
return json.dumps(result, indent=2, default=str)
if output_schema == "names":
# Compact result: no duplication, no all_tool_names list
total = sum(p["tool_count"] for p in provider_groups.values())
result = {
"total": total,
"tools_by_provider": provider_groups,
}
else:
all_names = sorted({t["name"] for p in provider_groups.values() for t in p["tools"]})
result = {
"total": len(all_names),
"tools_by_provider": provider_groups,
"tools_by_category": provider_groups, # backward-compat alias
"all_tool_names": all_names,
}
if errors:
result["errors"] = errors
@@ -1483,7 +1672,11 @@ def _node_var_name(node_id: str) -> str:
@mcp.tool()
def initialize_and_build_agent(agent_name: str, nodes: str | None = None) -> str:
def initialize_and_build_agent(
agent_name: str,
nodes: str | None = None,
_draft: dict | None = None,
) -> str:
"""Scaffold a new agent package with placeholder files.
Creates exports/{agent_name}/ with all files needed for a runnable agent:
@@ -1500,6 +1693,8 @@ def initialize_and_build_agent(agent_name: str, nodes: str | None = None) -> str
nodes: Comma-separated node names (snake_case or kebab-case).
If omitted, a single 'start' node is created.
Example: 'intake,process,review'
_draft: Internal. Draft graph metadata from planning phase, used to
pre-populate descriptions, goals, and node metadata.
Returns:
JSON with files written and next steps.
@@ -1519,6 +1714,15 @@ def initialize_and_build_agent(agent_name: str, nodes: str | None = None) -> str
node_list = [n.strip() for n in nodes.split(",") if n.strip()] if nodes else ["start"]
# Build draft node lookup for pre-populating metadata from planning phase
_draft_nodes: dict[str, dict] = {}
if _draft and _draft.get("nodes"):
for dn in _draft["nodes"]:
_draft_nodes[dn.get("id", "")] = dn
# Extract top-level draft metadata early so it's available for all templates
_draft_desc = (_draft.get("description") or "") if _draft else ""
class_name = _snake_to_camel(agent_name)
human_name = agent_name.replace("_", " ").title()
entry_node = node_list[0]
@@ -1583,7 +1787,7 @@ default_config = RuntimeConfig()
class AgentMetadata:
name: str = "{human_name}"
version: str = "1.0.0"
description: str = "TODO: Add agent description."
description: str = "{_draft_desc or "TODO: Add agent description."}"
intro_message: str = "TODO: Add intro message."
@@ -1598,22 +1802,33 @@ metadata = AgentMetadata()
var = _node_var_name(node_id)
node_var_names.append(var)
is_first = node_id == entry_node
# Use draft metadata to pre-populate if available
dn = _draft_nodes.get(node_id, {})
node_name = dn.get("name") or node_id.replace("_", " ").replace("-", " ").title()
node_desc = dn.get("description") or "TODO: Describe what this node does."
node_type = dn.get("node_type") or "event_loop"
node_tools = dn.get("tools") or []
node_input_keys = dn.get("input_keys") or []
node_output_keys = dn.get("output_keys") or []
node_sc = dn.get("success_criteria") or "TODO: Define success criteria."
node_specs.append(f'''\
{var} = NodeSpec(
id="{node_id}",
name="{node_id.replace("_", " ").replace("-", " ").title()}",
description="TODO: Describe what this node does.",
node_type="event_loop",
name="{node_name}",
description="{node_desc}",
node_type="{node_type}",
client_facing={is_first},
max_node_visits=0,
input_keys=[],
output_keys=[],
input_keys={node_input_keys!r},
output_keys={node_output_keys!r},
nullable_output_keys=[],
success_criteria="TODO: Define success criteria.",
success_criteria="{node_sc}",
system_prompt="""\\
TODO: Add system prompt for this node.
""",
tools=[],
tools={node_tools!r},
)''')
nodes_init = f'''\
@@ -1631,10 +1846,29 @@ __all__ = {node_var_names!r}
node_imports = ", ".join(node_var_names)
nodes_list = ", ".join(node_var_names)
# Use draft edges if available, otherwise generate linear edges
_draft_edges = _draft.get("edges", []) if _draft else []
edge_defs = []
for i in range(len(node_list) - 1):
src, tgt = node_list[i], node_list[i + 1]
edge_defs.append(f"""\
if _draft_edges:
for de in _draft_edges:
eid = de.get("id", f"{de.get('source', '')}-to-{de.get('target', '')}")
src = de.get("source", "")
tgt = de.get("target", "")
cond = de.get("condition", "on_success").upper()
desc = de.get("description", "")
desc_line = f'\n description="{desc}",' if desc else ""
edge_defs.append(f"""\
EdgeSpec(
id="{eid}",
source="{src}",
target="{tgt}",
condition=EdgeCondition.{cond},{desc_line}
priority=1,
),""")
else:
for i in range(len(node_list) - 1):
src, tgt = node_list[i], node_list[i + 1]
edge_defs.append(f"""\
EdgeSpec(
id="{src}-to-{tgt}",
source="{src}",
@@ -1644,6 +1878,59 @@ __all__ = {node_var_names!r}
),""")
edges_str = "\n".join(edge_defs) if edge_defs else " # TODO: Add edges"
# Pre-populate goal from draft metadata
_draft_goal = (
(_draft.get("goal") or "TODO: Describe the agent's goal.")
if _draft
else "TODO: Describe the agent's goal."
)
_draft_sc = (_draft.get("success_criteria") or []) if _draft else []
_draft_constraints = (_draft.get("constraints") or []) if _draft else []
# Build success criteria entries
if _draft_sc:
sc_entries = "\n".join(
f"""\
SuccessCriterion(
id="sc-{i + 1}",
description="{sc}",
metric="TODO",
target="TODO",
weight=1.0,
),"""
for i, sc in enumerate(_draft_sc)
)
else:
sc_entries = """\
SuccessCriterion(
id="sc-1",
description="TODO: Define success criterion.",
metric="TODO",
target="TODO",
weight=1.0,
),"""
# Build constraint entries
if _draft_constraints:
constraint_entries = "\n".join(
f"""\
Constraint(
id="c-{i + 1}",
description="{c}",
constraint_type="hard",
category="functional",
),"""
for i, c in enumerate(_draft_constraints)
)
else:
constraint_entries = """\
Constraint(
id="c-1",
description="TODO: Define constraint.",
constraint_type="hard",
category="functional",
),"""
_write(
"agent.py",
f'''\
@@ -1667,23 +1954,12 @@ from .nodes import {node_imports}
goal = Goal(
id="{agent_name}-goal",
name="{human_name}",
description="TODO: Describe the agent's goal.",
description="{_draft_goal}",
success_criteria=[
SuccessCriterion(
id="sc-1",
description="TODO: Define success criterion.",
metric="TODO",
target="TODO",
weight=1.0,
),
{sc_entries}
],
constraints=[
Constraint(
id="c-1",
description="TODO: Define constraint.",
constraint_type="hard",
category="functional",
),
{constraint_entries}
],
)