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.
Files changed (67) hide show
  1. nava/agents/factory.py +141 -0
  2. nava/agents/planner.py +146 -0
  3. nava/agents/runtime/browser_agent.py +171 -0
  4. nava/agents/runtime/coding_agent.py +117 -0
  5. nava/agents/runtime/computer_agent.py +123 -0
  6. nava/agents/runtime/dynamic_agent.py +122 -0
  7. nava/agents/runtime/file_agent.py +127 -0
  8. nava/agents/runtime/file_agent_variants.py +24 -0
  9. nava/agents/runtime/nava_agent.py +212 -0
  10. nava/agents/runtime/research_agent.py +119 -0
  11. nava/agents/runtime/reviewer_agent.py +114 -0
  12. nava/agents/runtime/terminal_agent.py +113 -0
  13. nava/agents/templates.py +137 -0
  14. nava/cli.py +17 -0
  15. nava/core/boot.py +154 -0
  16. nava/core/ledger.py +145 -0
  17. nava/core/llm.py +51 -0
  18. nava/core/message_bus.py +174 -0
  19. nava/core/sanitizer.py +40 -0
  20. nava/core/schemas.py +246 -0
  21. nava/credentials/broker.py +102 -0
  22. nava/credentials/vault.py +133 -0
  23. nava/gateway/pipeline.py +314 -0
  24. nava/gateway/schema_validator.py +75 -0
  25. nava/governance/budget_engine.py +101 -0
  26. nava/governance/compensation_engine.py +94 -0
  27. nava/governance/dom_sanitizer.py +122 -0
  28. nava/governance/hitl_manager.py +74 -0
  29. nava/governance/lock_manager.py +161 -0
  30. nava/governance/policy_engine.py +65 -0
  31. nava/governance/risk_engine.py +128 -0
  32. nava/governance/rollback_engine.py +122 -0
  33. nava/governance/state_observer.py +50 -0
  34. nava/memory/ai_twin.py +105 -0
  35. nava/memory/bm25.py +129 -0
  36. nava/memory/embeddings.py +129 -0
  37. nava/memory/hybrid_rag.py +227 -0
  38. nava/memory/store.py +202 -0
  39. nava/orchestrator.py +1033 -0
  40. nava/prompts/browser_prompt.txt +93 -0
  41. nava/prompts/coding_agent_prompt.txt +99 -0
  42. nava/prompts/computer_agent_prompt.txt +15 -0
  43. nava/prompts/dynamic_agent_prompt.txt +74 -0
  44. nava/prompts/file_agent_prompt.txt +52 -0
  45. nava/prompts/nava_agent_prompt.txt +61 -0
  46. nava/prompts/planner_prompt.txt +65 -0
  47. nava/prompts/research_agent_prompt.txt +19 -0
  48. nava/prompts/reviewer_agent_prompt.txt +105 -0
  49. nava/prompts/terminal_agent_prompt.txt +12 -0
  50. nava/skills/manager.py +218 -0
  51. nava/skills/promotion.py +168 -0
  52. nava/tools/browser.py +232 -0
  53. nava/tools/desktop.py +187 -0
  54. nava/tools/executor.py +1050 -0
  55. nava/tools/mcp_client.py +368 -0
  56. nava/tools/mcp_gmail_server.py +130 -0
  57. nava/tools/registry.py +40 -0
  58. nava/ui/cowork_tui.py +376 -0
  59. nava/ui/terminal.py +245 -0
  60. nava/workspace/__init__.py +9 -0
  61. nava/workspace/indexer.py +170 -0
  62. nava/workspace/project_manager.py +340 -0
  63. nava_agent-0.2.0.dist-info/METADATA +548 -0
  64. nava_agent-0.2.0.dist-info/RECORD +67 -0
  65. nava_agent-0.2.0.dist-info/WHEEL +5 -0
  66. nava_agent-0.2.0.dist-info/entry_points.txt +2 -0
  67. 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()