nava-agent 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nava/agents/factory.py +141 -0
- nava/agents/planner.py +146 -0
- nava/agents/runtime/browser_agent.py +171 -0
- nava/agents/runtime/coding_agent.py +117 -0
- nava/agents/runtime/computer_agent.py +123 -0
- nava/agents/runtime/dynamic_agent.py +122 -0
- nava/agents/runtime/file_agent.py +127 -0
- nava/agents/runtime/file_agent_variants.py +24 -0
- nava/agents/runtime/nava_agent.py +212 -0
- nava/agents/runtime/research_agent.py +119 -0
- nava/agents/runtime/reviewer_agent.py +114 -0
- nava/agents/runtime/terminal_agent.py +113 -0
- nava/agents/templates.py +137 -0
- nava/cli.py +17 -0
- nava/core/boot.py +154 -0
- nava/core/ledger.py +145 -0
- nava/core/llm.py +51 -0
- nava/core/message_bus.py +174 -0
- nava/core/sanitizer.py +40 -0
- nava/core/schemas.py +246 -0
- nava/credentials/broker.py +102 -0
- nava/credentials/vault.py +133 -0
- nava/gateway/pipeline.py +314 -0
- nava/gateway/schema_validator.py +75 -0
- nava/governance/budget_engine.py +101 -0
- nava/governance/compensation_engine.py +94 -0
- nava/governance/dom_sanitizer.py +122 -0
- nava/governance/hitl_manager.py +74 -0
- nava/governance/lock_manager.py +161 -0
- nava/governance/policy_engine.py +65 -0
- nava/governance/risk_engine.py +128 -0
- nava/governance/rollback_engine.py +122 -0
- nava/governance/state_observer.py +50 -0
- nava/memory/ai_twin.py +105 -0
- nava/memory/bm25.py +129 -0
- nava/memory/embeddings.py +129 -0
- nava/memory/hybrid_rag.py +227 -0
- nava/memory/store.py +202 -0
- nava/orchestrator.py +1033 -0
- nava/prompts/browser_prompt.txt +93 -0
- nava/prompts/coding_agent_prompt.txt +99 -0
- nava/prompts/computer_agent_prompt.txt +15 -0
- nava/prompts/dynamic_agent_prompt.txt +74 -0
- nava/prompts/file_agent_prompt.txt +52 -0
- nava/prompts/nava_agent_prompt.txt +61 -0
- nava/prompts/planner_prompt.txt +65 -0
- nava/prompts/research_agent_prompt.txt +19 -0
- nava/prompts/reviewer_agent_prompt.txt +105 -0
- nava/prompts/terminal_agent_prompt.txt +12 -0
- nava/skills/manager.py +218 -0
- nava/skills/promotion.py +168 -0
- nava/tools/browser.py +232 -0
- nava/tools/desktop.py +187 -0
- nava/tools/executor.py +1050 -0
- nava/tools/mcp_client.py +368 -0
- nava/tools/mcp_gmail_server.py +130 -0
- nava/tools/registry.py +40 -0
- nava/ui/cowork_tui.py +376 -0
- nava/ui/terminal.py +245 -0
- nava/workspace/__init__.py +9 -0
- nava/workspace/indexer.py +170 -0
- nava/workspace/project_manager.py +340 -0
- nava_agent-0.2.0.dist-info/METADATA +548 -0
- nava_agent-0.2.0.dist-info/RECORD +67 -0
- nava_agent-0.2.0.dist-info/WHEEL +5 -0
- nava_agent-0.2.0.dist-info/entry_points.txt +2 -0
- nava_agent-0.2.0.dist-info/top_level.txt +1 -0
nava/agents/factory.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
import hashlib
|
|
3
|
+
from typing import Optional, List, Dict
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from nava.core.schemas import AgentSpec, AgentState, AgentType, AgentStatus, Outcome, ToolRequest
|
|
6
|
+
from nava.agents.templates import Templates, StaticAgentTemplate
|
|
7
|
+
from nava.tools.registry import ToolRegistry
|
|
8
|
+
from nava.gateway.pipeline import PolicyEngine, BudgetEngine
|
|
9
|
+
|
|
10
|
+
class AgentFactory:
|
|
11
|
+
def __init__(self, registry: ToolRegistry, policy_engine: PolicyEngine, budget_engine: BudgetEngine):
|
|
12
|
+
self.registry = registry
|
|
13
|
+
self.policy_engine = policy_engine
|
|
14
|
+
self.budget_engine = budget_engine
|
|
15
|
+
|
|
16
|
+
def spawn_agent(self, spec: AgentSpec, parent_state: AgentState) -> AgentState:
|
|
17
|
+
# 1. Budget & Depth Enforcement (Section 9.4)
|
|
18
|
+
if not self.budget_engine.check_spawn(parent_state):
|
|
19
|
+
raise RuntimeError(f"Budget exhausted for spawning new agents in task {parent_state.budget_ref}")
|
|
20
|
+
|
|
21
|
+
if spec.max_children > 0 and len(parent_state.child_agent_ids) >= spec.max_children:
|
|
22
|
+
raise ValueError(f"Exceeds max children limit of {spec.max_children}")
|
|
23
|
+
|
|
24
|
+
# 2. Template Resolution (Section 9.2)
|
|
25
|
+
template = Templates.get_template(spec.requested_role)
|
|
26
|
+
|
|
27
|
+
is_dynamic = template is None
|
|
28
|
+
agent_type = AgentType.DYNAMIC if is_dynamic else AgentType.STATIC
|
|
29
|
+
template_id = template.template_id if template else None
|
|
30
|
+
|
|
31
|
+
# 3. Dynamic Fallback & Scope Synthesis
|
|
32
|
+
base_permission_scope = []
|
|
33
|
+
base_tool_scope = []
|
|
34
|
+
|
|
35
|
+
if not is_dynamic:
|
|
36
|
+
base_permission_scope = template.permission_scope.copy()
|
|
37
|
+
base_tool_scope = spec.requested_tools.copy() # Templates don't explicitly list tool scopes, they list permission scopes.
|
|
38
|
+
else:
|
|
39
|
+
# Dynamically synthesize scope by introspecting requested tools
|
|
40
|
+
for tool_name in spec.requested_tools:
|
|
41
|
+
try:
|
|
42
|
+
t_def = self.registry.get_tool(tool_name)
|
|
43
|
+
if t_def:
|
|
44
|
+
base_tool_scope.append(tool_name)
|
|
45
|
+
base_permission_scope.extend(t_def.permissions_required)
|
|
46
|
+
except KeyError:
|
|
47
|
+
pass
|
|
48
|
+
base_permission_scope = list(set(base_permission_scope))
|
|
49
|
+
|
|
50
|
+
# 4. Permission Intersection (Section 9.3)
|
|
51
|
+
# child_scope = parent_scope ∩ requested_scope ∩ policy_allowed_scope
|
|
52
|
+
|
|
53
|
+
intersected_permissions = []
|
|
54
|
+
intersected_tools = []
|
|
55
|
+
|
|
56
|
+
# Helper to check scope match with wildcards (e.g. *, filesystem.*)
|
|
57
|
+
def scope_matches(scope_pattern: str, target: str) -> bool:
|
|
58
|
+
if scope_pattern == "*" or scope_pattern == target:
|
|
59
|
+
return True
|
|
60
|
+
if scope_pattern.endswith(".*"):
|
|
61
|
+
prefix = scope_pattern[:-2]
|
|
62
|
+
return target.startswith(prefix)
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
def is_in_scope_list(scope_list: List[str], target: str) -> bool:
|
|
66
|
+
return any(scope_matches(pattern, target) for pattern in scope_list)
|
|
67
|
+
|
|
68
|
+
# Helper to query policy
|
|
69
|
+
def is_allowed_by_policy(scope: str) -> bool:
|
|
70
|
+
mock_req = ToolRequest(
|
|
71
|
+
request_id="policy_check",
|
|
72
|
+
agent_id="factory",
|
|
73
|
+
tool_name=scope,
|
|
74
|
+
arguments={},
|
|
75
|
+
requested_scope=scope
|
|
76
|
+
)
|
|
77
|
+
eval_result = self.policy_engine.evaluate(mock_req, parent_state)
|
|
78
|
+
return eval_result == Outcome.ALLOW
|
|
79
|
+
|
|
80
|
+
# Intersect Permissions
|
|
81
|
+
for req_perm in spec.requested_permission_scope:
|
|
82
|
+
if is_in_scope_list(parent_state.permission_scope, req_perm) and is_in_scope_list(base_permission_scope, req_perm):
|
|
83
|
+
if is_allowed_by_policy(req_perm):
|
|
84
|
+
if req_perm not in intersected_permissions:
|
|
85
|
+
intersected_permissions.append(req_perm)
|
|
86
|
+
|
|
87
|
+
# Intersect Tools and their inherent permissions
|
|
88
|
+
for req_tool in spec.requested_tools:
|
|
89
|
+
if (req_tool in parent_state.tool_scope or is_in_scope_list(parent_state.tool_scope, req_tool)) and req_tool in base_tool_scope:
|
|
90
|
+
t_def = self.registry.get_tool(req_tool)
|
|
91
|
+
if not t_def:
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
# Enforce invariant: A tool can only be granted if ALL its required permissions
|
|
95
|
+
# fall within the agent's base template scope and parent scope.
|
|
96
|
+
tool_permitted = True
|
|
97
|
+
for p in t_def.permissions_required:
|
|
98
|
+
if not is_in_scope_list(base_permission_scope, p) or not is_in_scope_list(parent_state.permission_scope, p):
|
|
99
|
+
tool_permitted = False
|
|
100
|
+
break
|
|
101
|
+
|
|
102
|
+
if tool_permitted:
|
|
103
|
+
intersected_tools.append(req_tool)
|
|
104
|
+
|
|
105
|
+
# Auto-inject the tool's required permissions if permitted
|
|
106
|
+
for p in t_def.permissions_required:
|
|
107
|
+
if is_in_scope_list(parent_state.permission_scope, p) and p not in intersected_permissions:
|
|
108
|
+
if is_allowed_by_policy(p):
|
|
109
|
+
intersected_permissions.append(p)
|
|
110
|
+
|
|
111
|
+
# Intersect Credentials (Phase 6)
|
|
112
|
+
# Credential scope must be a subset of permissions per Section 17.3
|
|
113
|
+
intersected_credentials = []
|
|
114
|
+
for req_tool in intersected_tools:
|
|
115
|
+
t_def = self.registry.get_tool(req_tool)
|
|
116
|
+
if t_def and t_def.required_credentials:
|
|
117
|
+
for perm in t_def.permissions_required:
|
|
118
|
+
if is_in_scope_list(parent_state.credential_scope, perm) and perm not in intersected_credentials:
|
|
119
|
+
if perm in intersected_permissions: # Critical invariant check
|
|
120
|
+
intersected_credentials.append(perm)
|
|
121
|
+
|
|
122
|
+
# 5. State Initialization
|
|
123
|
+
# 5. Mint AgentState
|
|
124
|
+
state = AgentState(
|
|
125
|
+
agent_id=f"agt-{uuid.uuid4().hex[:8]}",
|
|
126
|
+
parent_agent_id=parent_state.agent_id if parent_state else None,
|
|
127
|
+
role=spec.requested_role,
|
|
128
|
+
display_label=spec.display_label,
|
|
129
|
+
type=agent_type,
|
|
130
|
+
template_id=template_id,
|
|
131
|
+
goal=spec.goal,
|
|
132
|
+
permission_scope=intersected_permissions,
|
|
133
|
+
credential_scope=intersected_credentials,
|
|
134
|
+
tool_scope=intersected_tools,
|
|
135
|
+
depth=(parent_state.depth + 1) if parent_state else 0,
|
|
136
|
+
status=AgentStatus.PENDING,
|
|
137
|
+
ttl=spec.ttl,
|
|
138
|
+
expires_at=datetime.utcnow() + spec.ttl,
|
|
139
|
+
budget_ref=parent_state.budget_ref
|
|
140
|
+
)
|
|
141
|
+
return state
|
nava/agents/planner.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import uuid
|
|
3
|
+
import hashlib
|
|
4
|
+
import datetime
|
|
5
|
+
from typing import List, Dict, Any, Optional
|
|
6
|
+
from langchain_core.messages import SystemMessage, HumanMessage
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
from nava.core.schemas import AgentSpec, Priority
|
|
9
|
+
from nava.core.llm import get_llm
|
|
10
|
+
|
|
11
|
+
class SubGoal(BaseModel):
|
|
12
|
+
role: str = Field(description="The formal role of the agent (must be a known template, or 'DynamicAgent' if none fit).")
|
|
13
|
+
display_label: Optional[str] = Field(None, description="A human-readable descriptive name (e.g. 'WebResearchAgent', 'DataAnalysisAgent', 'PDFAnalyzer') used only for audit logs.")
|
|
14
|
+
goal: str = Field(description="The specific goal for this sub-agent.")
|
|
15
|
+
required_tools: List[str] = Field(description="The tools this agent will need.")
|
|
16
|
+
required_permissions: List[str] = Field(description="The permissions this agent will need.")
|
|
17
|
+
stage: int = Field(default=1, description="The execution stage number (1, 2, 3...). Sub-goals with the same stage number execute concurrently in parallel.")
|
|
18
|
+
is_parallel: bool = Field(default=True, description="Whether this sub-goal can run concurrently with others in the same stage.")
|
|
19
|
+
|
|
20
|
+
class GoalPlan(BaseModel):
|
|
21
|
+
thoughts: str = Field(description="Your step-by-step reasoning for breaking down the objective into parallel and sequential execution stages.")
|
|
22
|
+
sub_goals: List[SubGoal] = Field(description="List of sub-goals organized by execution stages.")
|
|
23
|
+
|
|
24
|
+
def compute_dedup_hash(role: str, goal: str, tools: List[str]) -> str:
|
|
25
|
+
clean_goal = goal.strip().lower()
|
|
26
|
+
tools_str = ",".join(sorted(tools))
|
|
27
|
+
raw = f"{role}:{clean_goal}:{tools_str}".encode('utf-8')
|
|
28
|
+
return hashlib.sha256(raw).hexdigest()[:16]
|
|
29
|
+
|
|
30
|
+
class GoalPlanner:
|
|
31
|
+
def __init__(self, available_templates: List[str], ceiling_tools: List[str], ceiling_permissions: List[str], budget_engine=None, registry=None):
|
|
32
|
+
self.available_templates = available_templates
|
|
33
|
+
self.ceiling_tools = ceiling_tools
|
|
34
|
+
self.ceiling_permissions = ceiling_permissions
|
|
35
|
+
self.budget_engine = budget_engine
|
|
36
|
+
self.registry = registry
|
|
37
|
+
if os.environ.get("NAVA_TEST_MODE") == "1":
|
|
38
|
+
self.llm = None
|
|
39
|
+
else:
|
|
40
|
+
self.llm = get_llm()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def plan(self, objective: str, parent_id: str, budget_ref: str = None) -> List[AgentSpec]:
|
|
44
|
+
if os.environ.get("NAVA_TEST_MODE") == "1":
|
|
45
|
+
# Mock plan for testing
|
|
46
|
+
return [
|
|
47
|
+
AgentSpec(
|
|
48
|
+
request_id=f"req-{uuid.uuid4().hex[:8]}",
|
|
49
|
+
requested_role="DocumentAgent",
|
|
50
|
+
goal=objective,
|
|
51
|
+
parent_agent_id=parent_id,
|
|
52
|
+
requested_tools=["file.write"],
|
|
53
|
+
requested_permission_scope=["filesystem.write"],
|
|
54
|
+
ttl=datetime.timedelta(minutes=15),
|
|
55
|
+
max_steps=10,
|
|
56
|
+
max_tokens=5000,
|
|
57
|
+
max_children=2,
|
|
58
|
+
dedup_hash=compute_dedup_hash("DocumentAgent", objective, ["file.write"]),
|
|
59
|
+
stage=1,
|
|
60
|
+
is_parallel=True
|
|
61
|
+
)
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
prompt_path = os.path.join(os.path.dirname(__file__), "..", "prompts", "planner_prompt.txt")
|
|
65
|
+
with open(prompt_path, "r", encoding="utf-8") as f:
|
|
66
|
+
prompt_template = f.read()
|
|
67
|
+
|
|
68
|
+
system_prompt = prompt_template.format(
|
|
69
|
+
templates=self.available_templates,
|
|
70
|
+
tools=self.ceiling_tools,
|
|
71
|
+
permissions=self.ceiling_permissions
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
sys_msg = SystemMessage(content=system_prompt)
|
|
75
|
+
human_msg = HumanMessage(content=f"Objective: {objective}")
|
|
76
|
+
|
|
77
|
+
structured_llm = self.llm.with_structured_output(GoalPlan)
|
|
78
|
+
try:
|
|
79
|
+
if self.budget_engine and budget_ref:
|
|
80
|
+
self.budget_engine.consume_internal_llm_call(budget_ref, tokens=500)
|
|
81
|
+
plan: GoalPlan = structured_llm.invoke([sys_msg, human_msg])
|
|
82
|
+
except Exception as e:
|
|
83
|
+
print(f"Planner failed: {e}")
|
|
84
|
+
raise e
|
|
85
|
+
|
|
86
|
+
print(f"\n[Orchestrator Planner Thinking]:\n{plan.thoughts}\n")
|
|
87
|
+
print("[Orchestrator Plan]:")
|
|
88
|
+
for i, sg in enumerate(plan.sub_goals):
|
|
89
|
+
print(f" [Stage {sg.stage}] {sg.role} ({sg.display_label or sg.role}) → {sg.goal}")
|
|
90
|
+
print(f" Tools: {sg.required_tools}")
|
|
91
|
+
|
|
92
|
+
specs = []
|
|
93
|
+
for sg in plan.sub_goals:
|
|
94
|
+
req_tools = list(sg.required_tools or [])
|
|
95
|
+
# Ensure baseline tools for specialized roles (guards against small LLM tool omissions)
|
|
96
|
+
if sg.role == "CodingAgent":
|
|
97
|
+
for bt in ["file.write", "file.read", "code.replace_content", "code.search", "test.run"]:
|
|
98
|
+
if bt not in req_tools and (not self.ceiling_tools or bt in self.ceiling_tools or "*" in self.ceiling_tools):
|
|
99
|
+
req_tools.append(bt)
|
|
100
|
+
elif sg.role == "ResearchAgent":
|
|
101
|
+
for bt in ["search.web", "browser.navigate", "browser.extract_text", "file.write", "file.read"]:
|
|
102
|
+
if bt not in req_tools and (not self.ceiling_tools or bt in self.ceiling_tools or "*" in self.ceiling_tools):
|
|
103
|
+
req_tools.append(bt)
|
|
104
|
+
elif sg.role == "ComputerAgent":
|
|
105
|
+
for bt in ["desktop.screenshot", "desktop.click", "desktop.type", "desktop.get_screen_size"]:
|
|
106
|
+
if bt not in req_tools and (not self.ceiling_tools or bt in self.ceiling_tools or "*" in self.ceiling_tools):
|
|
107
|
+
req_tools.append(bt)
|
|
108
|
+
elif sg.role == "TerminalAgent":
|
|
109
|
+
for bt in ["terminal.execute", "file.read"]:
|
|
110
|
+
if bt not in req_tools and (not self.ceiling_tools or bt in self.ceiling_tools or "*" in self.ceiling_tools):
|
|
111
|
+
req_tools.append(bt)
|
|
112
|
+
|
|
113
|
+
dedup = compute_dedup_hash(sg.role, sg.goal, req_tools)
|
|
114
|
+
|
|
115
|
+
# Auto-synthesize requested_permission_scope from required_tools if registry is available
|
|
116
|
+
derived_perms = list(sg.required_permissions or [])
|
|
117
|
+
if self.registry:
|
|
118
|
+
for t_name in req_tools:
|
|
119
|
+
try:
|
|
120
|
+
t_def = self.registry.get_tool(t_name)
|
|
121
|
+
if t_def:
|
|
122
|
+
for p in t_def.permissions_required:
|
|
123
|
+
if p not in derived_perms:
|
|
124
|
+
derived_perms.append(p)
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
specs.append(AgentSpec(
|
|
129
|
+
request_id=f"req-{uuid.uuid4().hex[:8]}",
|
|
130
|
+
requested_role=sg.role,
|
|
131
|
+
display_label=sg.display_label or sg.role,
|
|
132
|
+
goal=sg.goal,
|
|
133
|
+
parent_agent_id=parent_id,
|
|
134
|
+
requested_tools=req_tools,
|
|
135
|
+
requested_permission_scope=derived_perms,
|
|
136
|
+
ttl=datetime.timedelta(minutes=30),
|
|
137
|
+
max_steps=20,
|
|
138
|
+
max_tokens=10000,
|
|
139
|
+
max_children=2,
|
|
140
|
+
dedup_hash=dedup,
|
|
141
|
+
stage=sg.stage,
|
|
142
|
+
is_parallel=sg.is_parallel
|
|
143
|
+
))
|
|
144
|
+
|
|
145
|
+
return specs
|
|
146
|
+
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
from typing import Dict, Any, List
|
|
4
|
+
from langgraph.graph import StateGraph, END
|
|
5
|
+
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
from nava.core.schemas import AgentState, ToolRequest
|
|
8
|
+
from nava.core.llm import get_llm
|
|
9
|
+
from nava.governance.dom_sanitizer import sanitize_dom
|
|
10
|
+
|
|
11
|
+
# Max history entries to keep (sliding window = last N message pairs)
|
|
12
|
+
_MAX_HISTORY_PAIRS = 4 # 4 pairs = 8 messages = last 4 rounds of thought
|
|
13
|
+
|
|
14
|
+
# Pattern to strip sensitive data from error messages
|
|
15
|
+
_SENSITIVE_ERR_PATTERN = re.compile(
|
|
16
|
+
r'(api[_-]?key|token|password|secret|credential)[=:]\s*\S+',
|
|
17
|
+
re.IGNORECASE
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
def _sanitize_error(error_str: str) -> str:
|
|
21
|
+
"""Strip API keys, tokens, and internal paths from error messages."""
|
|
22
|
+
sanitized = _SENSITIVE_ERR_PATTERN.sub('[REDACTED]', error_str)
|
|
23
|
+
# Also strip absolute Windows/Unix paths
|
|
24
|
+
sanitized = re.sub(r'[A-Z]:\\[\w\\]+', '[PATH]', sanitized)
|
|
25
|
+
sanitized = re.sub(r'/(?:home|usr|etc|var|tmp)/[\w/]+', '[PATH]', sanitized)
|
|
26
|
+
return sanitized
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BrowserPlan(BaseModel):
|
|
30
|
+
thoughts: str
|
|
31
|
+
tool_name: str
|
|
32
|
+
arguments: dict
|
|
33
|
+
|
|
34
|
+
def build_browser_agent(registry=None) -> StateGraph:
|
|
35
|
+
"""Builds the Tier 2 cyclic BrowserAgent execution graph."""
|
|
36
|
+
|
|
37
|
+
workflow = StateGraph(dict)
|
|
38
|
+
|
|
39
|
+
def plan_node(state: dict):
|
|
40
|
+
agent_state: AgentState = state["agent_state"]
|
|
41
|
+
observation = state.get("observation", "No observation yet.")
|
|
42
|
+
consumed = state.get("consumed_steps", 0)
|
|
43
|
+
max_steps = state.get("max_steps", 15)
|
|
44
|
+
|
|
45
|
+
if consumed >= max_steps:
|
|
46
|
+
state["plan"] = "FINISH"
|
|
47
|
+
state["error"] = "LOOP_BUDGET_EXHAUSTED"
|
|
48
|
+
return state
|
|
49
|
+
|
|
50
|
+
# If observation is raw HTML, sanitize it before feeding to LLM
|
|
51
|
+
if isinstance(observation, dict) and "html" in observation:
|
|
52
|
+
sanitized_html, is_flagged = sanitize_dom(
|
|
53
|
+
observation["html"],
|
|
54
|
+
ledger=state.get("gateway").audit_ledger if state.get("gateway") else None
|
|
55
|
+
)
|
|
56
|
+
observation = f"Sanitized DOM (flagged={is_flagged}):\n{sanitized_html[:5000]}..."
|
|
57
|
+
|
|
58
|
+
# If observation is extracted text, just truncate it
|
|
59
|
+
if isinstance(observation, dict) and "text" in observation:
|
|
60
|
+
text = observation["text"]
|
|
61
|
+
observation = f"Extracted Page Text:\n{text[:8000]}"
|
|
62
|
+
if len(text) > 8000:
|
|
63
|
+
observation += "\n... [TRUNCATED — use browser.save_to_scratch to save the full text]"
|
|
64
|
+
|
|
65
|
+
# Inject tool schemas
|
|
66
|
+
tool_schemas = []
|
|
67
|
+
if registry:
|
|
68
|
+
for t_name in agent_state.tool_scope:
|
|
69
|
+
t_def = registry.get_tool(t_name)
|
|
70
|
+
if t_def:
|
|
71
|
+
tool_schemas.append(f"- {t_name}: {t_def.description}\n Schema: {json.dumps(t_def.input_schema)}")
|
|
72
|
+
tool_schemas_str = "\n".join(tool_schemas)
|
|
73
|
+
|
|
74
|
+
llm = get_llm()
|
|
75
|
+
structured_llm = llm.with_structured_output(BrowserPlan)
|
|
76
|
+
|
|
77
|
+
import os
|
|
78
|
+
prompt_path = os.path.join(os.path.dirname(__file__), "..", "..", "prompts", "browser_prompt.txt")
|
|
79
|
+
with open(prompt_path, "r", encoding="utf-8") as f:
|
|
80
|
+
prompt_template = f.read()
|
|
81
|
+
|
|
82
|
+
system_prompt = prompt_template.format(
|
|
83
|
+
goal=agent_state.goal,
|
|
84
|
+
tool_schemas=tool_schemas_str
|
|
85
|
+
)
|
|
86
|
+
messages = [SystemMessage(content=system_prompt)]
|
|
87
|
+
|
|
88
|
+
# Sliding window history — keep only the last N pairs
|
|
89
|
+
history = state.get("history", [])
|
|
90
|
+
if len(history) > _MAX_HISTORY_PAIRS * 2:
|
|
91
|
+
# Summarize older history into a single message
|
|
92
|
+
old_messages = history[:-((_MAX_HISTORY_PAIRS * 2))]
|
|
93
|
+
summary_parts = []
|
|
94
|
+
for msg in old_messages:
|
|
95
|
+
content = msg.content if hasattr(msg, 'content') else str(msg)
|
|
96
|
+
summary_parts.append(content[:200])
|
|
97
|
+
summary = "Summary of earlier actions:\n" + "\n".join(summary_parts)
|
|
98
|
+
messages.append(HumanMessage(content=summary))
|
|
99
|
+
# Keep recent history
|
|
100
|
+
history = history[-((_MAX_HISTORY_PAIRS * 2)):]
|
|
101
|
+
|
|
102
|
+
for h in history:
|
|
103
|
+
messages.append(h)
|
|
104
|
+
|
|
105
|
+
messages.append(HumanMessage(content=f"Current Observation:\n{observation}\n\nWhat is your next action?"))
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
plan = structured_llm.invoke(messages)
|
|
109
|
+
|
|
110
|
+
# Record our own thought/action to history for the next iteration
|
|
111
|
+
# Truncate observation in history to save context
|
|
112
|
+
obs_summary = str(observation)[:500]
|
|
113
|
+
history.append(HumanMessage(content=f"Observation: {obs_summary}"))
|
|
114
|
+
history.append(AIMessage(content=f"Thought: {plan.thoughts}\nAction: {plan.tool_name}({json.dumps(plan.arguments)})"))
|
|
115
|
+
state["history"] = history
|
|
116
|
+
|
|
117
|
+
if plan.tool_name == "finish" or plan.tool_name.lower() == "none" or plan.tool_name.lower() == "stop":
|
|
118
|
+
state["plan"] = "FINISH"
|
|
119
|
+
state["final_answer"] = plan.thoughts
|
|
120
|
+
state["tool_request"] = None
|
|
121
|
+
else:
|
|
122
|
+
import uuid
|
|
123
|
+
state["plan"] = plan.thoughts
|
|
124
|
+
state["tool_request"] = ToolRequest(
|
|
125
|
+
request_id=f"req-{uuid.uuid4().hex[:8]}",
|
|
126
|
+
agent_id=agent_state.agent_id,
|
|
127
|
+
tool_name=plan.tool_name,
|
|
128
|
+
arguments=plan.arguments,
|
|
129
|
+
requested_scope=plan.tool_name
|
|
130
|
+
)
|
|
131
|
+
except Exception as e:
|
|
132
|
+
sanitized_err = _sanitize_error(str(e))
|
|
133
|
+
print(f"[BrowserAgent] Failed to plan: {sanitized_err}")
|
|
134
|
+
state["plan"] = "FINISH"
|
|
135
|
+
state["error"] = sanitized_err
|
|
136
|
+
state["tool_request"] = None
|
|
137
|
+
|
|
138
|
+
state["consumed_steps"] = consumed + 1
|
|
139
|
+
return state
|
|
140
|
+
|
|
141
|
+
def execute_node(state: dict):
|
|
142
|
+
req = state.get("tool_request")
|
|
143
|
+
if not req:
|
|
144
|
+
return state
|
|
145
|
+
|
|
146
|
+
gateway = state.get("gateway")
|
|
147
|
+
if not gateway:
|
|
148
|
+
state["observation"] = "Error: Gateway not available."
|
|
149
|
+
return state
|
|
150
|
+
|
|
151
|
+
print(f"\n[BrowserAgent] Executing: {req.tool_name}({req.arguments})")
|
|
152
|
+
print(f"[BrowserAgent] Thoughts: {state.get('plan')}")
|
|
153
|
+
|
|
154
|
+
result = gateway.process_request(req)
|
|
155
|
+
state["receipt"] = result
|
|
156
|
+
state["observation"] = result.result_data
|
|
157
|
+
return state
|
|
158
|
+
|
|
159
|
+
def should_continue(state: dict):
|
|
160
|
+
if state.get("plan") == "FINISH" or state.get("error"):
|
|
161
|
+
return "end"
|
|
162
|
+
return "execute"
|
|
163
|
+
|
|
164
|
+
workflow.add_node("plan", plan_node)
|
|
165
|
+
workflow.add_node("execute", execute_node)
|
|
166
|
+
|
|
167
|
+
workflow.set_entry_point("plan")
|
|
168
|
+
workflow.add_conditional_edges("plan", should_continue, {"execute": "execute", "end": END})
|
|
169
|
+
workflow.add_edge("execute", "plan")
|
|
170
|
+
|
|
171
|
+
return workflow.compile()
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import uuid
|
|
4
|
+
from typing import TypedDict, Optional
|
|
5
|
+
from langgraph.graph import StateGraph, END
|
|
6
|
+
from langchain_core.messages import SystemMessage, HumanMessage
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
from nava.core.schemas import AgentState, AgentStatus, ToolRequest
|
|
9
|
+
from nava.core.llm import get_llm
|
|
10
|
+
|
|
11
|
+
class CodingPlan(BaseModel):
|
|
12
|
+
thoughts: str
|
|
13
|
+
tool_name: str
|
|
14
|
+
arguments: dict
|
|
15
|
+
|
|
16
|
+
def build_coding_agent(registry=None) -> StateGraph:
|
|
17
|
+
"""Builds the Tier 2 cyclic coding agent execution graph."""
|
|
18
|
+
|
|
19
|
+
workflow = StateGraph(dict)
|
|
20
|
+
|
|
21
|
+
def plan_node(state: dict):
|
|
22
|
+
agent_state: AgentState = state["agent_state"]
|
|
23
|
+
payload = state.get("payload", {})
|
|
24
|
+
observation = state.get("observation")
|
|
25
|
+
|
|
26
|
+
if agent_state.status != AgentStatus.RUNNING:
|
|
27
|
+
agent_state.status = AgentStatus.RUNNING
|
|
28
|
+
|
|
29
|
+
tool_schemas_str = "No tools available."
|
|
30
|
+
if registry:
|
|
31
|
+
tool_schemas = []
|
|
32
|
+
for t_name in agent_state.tool_scope:
|
|
33
|
+
t_def = registry.get_tool(t_name)
|
|
34
|
+
if t_def:
|
|
35
|
+
tool_schemas.append(f"- {t_name}: {t_def.description}\n Schema: {json.dumps(t_def.input_schema)}")
|
|
36
|
+
if tool_schemas:
|
|
37
|
+
tool_schemas_str = "\n".join(tool_schemas)
|
|
38
|
+
|
|
39
|
+
llm = get_llm()
|
|
40
|
+
structured_llm = llm.with_structured_output(CodingPlan)
|
|
41
|
+
|
|
42
|
+
prompt_path = os.path.join(os.path.dirname(__file__), "..", "..", "prompts", "coding_agent_prompt.txt")
|
|
43
|
+
with open(prompt_path, "r", encoding="utf-8") as f:
|
|
44
|
+
prompt_template = f.read()
|
|
45
|
+
|
|
46
|
+
# Extract skill catalog from orchestrator payload (injected by SkillManager)
|
|
47
|
+
skill_catalog = payload.get("context", "")
|
|
48
|
+
|
|
49
|
+
system_prompt = prompt_template.format(
|
|
50
|
+
goal=agent_state.goal,
|
|
51
|
+
tool_schemas_str=tool_schemas_str,
|
|
52
|
+
skill_catalog=skill_catalog
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
sys_msg = SystemMessage(content=system_prompt)
|
|
56
|
+
|
|
57
|
+
history = state.get("history", [])
|
|
58
|
+
|
|
59
|
+
content = f"Payload: {json.dumps(payload)}\n"
|
|
60
|
+
if history:
|
|
61
|
+
content += "\n[YOUR PREVIOUS ACTIONS & OBSERVATIONS]\n" + "\n".join(history) + "\n"
|
|
62
|
+
|
|
63
|
+
if observation:
|
|
64
|
+
content += f"\n[LATEST OBSERVATION]\nResult: {json.dumps(observation)}\n"
|
|
65
|
+
|
|
66
|
+
human_msg = HumanMessage(content=content)
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
decision = structured_llm.invoke([sys_msg, human_msg])
|
|
70
|
+
print(f"\n[CodingAgent Thinking]:\n{decision.thoughts}\n")
|
|
71
|
+
except Exception as e:
|
|
72
|
+
print(f"\n[CodingAgent Error]: LLM generation or parsing failed: {e}")
|
|
73
|
+
decision = CodingPlan(thoughts=f"Fatal error parsing structured output: {e}", tool_name="FINISH", arguments={})
|
|
74
|
+
|
|
75
|
+
new_history = history.copy()
|
|
76
|
+
if observation:
|
|
77
|
+
# Truncate large observations before appending to history so we don't blow up the context window
|
|
78
|
+
obs_str = json.dumps(observation)
|
|
79
|
+
if len(obs_str) > 1500:
|
|
80
|
+
obs_str = obs_str[:1500] + "... [TRUNCATED for history context]"
|
|
81
|
+
new_history.append(f"Observation: {obs_str}")
|
|
82
|
+
|
|
83
|
+
new_history.append(f"Action taken: {decision.tool_name}, args: {json.dumps(decision.arguments)}")
|
|
84
|
+
state["history"] = new_history
|
|
85
|
+
|
|
86
|
+
if decision.tool_name == "FINISH":
|
|
87
|
+
state["tool_request"] = None
|
|
88
|
+
state["plan"] = "FINISH"
|
|
89
|
+
return state
|
|
90
|
+
|
|
91
|
+
# Resolve correct scope for this specific tool
|
|
92
|
+
resolved_scope = agent_state.permission_scope[0] if agent_state.permission_scope else ""
|
|
93
|
+
if registry:
|
|
94
|
+
try:
|
|
95
|
+
t_def = registry.get_tool(decision.tool_name)
|
|
96
|
+
if t_def and t_def.permissions_required:
|
|
97
|
+
resolved_scope = t_def.permissions_required[0]
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
req = ToolRequest(
|
|
102
|
+
request_id=f"req-{uuid.uuid4().hex[:8]}",
|
|
103
|
+
agent_id=agent_state.agent_id,
|
|
104
|
+
tool_name=decision.tool_name,
|
|
105
|
+
arguments=decision.arguments,
|
|
106
|
+
requested_scope=resolved_scope
|
|
107
|
+
)
|
|
108
|
+
state["tool_request"] = req
|
|
109
|
+
state["plan"] = decision.tool_name
|
|
110
|
+
return state
|
|
111
|
+
|
|
112
|
+
workflow.add_node("plan", plan_node)
|
|
113
|
+
|
|
114
|
+
workflow.set_entry_point("plan")
|
|
115
|
+
workflow.add_edge("plan", END)
|
|
116
|
+
|
|
117
|
+
return workflow.compile()
|