astra-cli-agent 0.1.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 (86) hide show
  1. astra/__init__.py +1 -0
  2. astra/agents/__init__.py +0 -0
  3. astra/agents/base.py +44 -0
  4. astra/agents/coder_agent.py +39 -0
  5. astra/agents/debug_agent.py +41 -0
  6. astra/agents/docker_agent.py +139 -0
  7. astra/agents/documentation_agent.py +41 -0
  8. astra/agents/file_agent.py +176 -0
  9. astra/agents/git_agent.py +163 -0
  10. astra/agents/kubernetes_agent.py +150 -0
  11. astra/agents/mcp_agent.py +135 -0
  12. astra/agents/memory_agent.py +35 -0
  13. astra/agents/planner_agent.py +149 -0
  14. astra/agents/research_agent.py +121 -0
  15. astra/agents/retrieve_agent.py +52 -0
  16. astra/agents/reviewer_agent.py +133 -0
  17. astra/cli/__init__.py +0 -0
  18. astra/cli/app.py +175 -0
  19. astra/cli/chat.py +145 -0
  20. astra/cli/ingest.py +57 -0
  21. astra/cli/mcp.py +65 -0
  22. astra/cli/memory.py +197 -0
  23. astra/cli/rendering.py +50 -0
  24. astra/cli/setup.py +206 -0
  25. astra/cli/single.py +35 -0
  26. astra/config/__init__.py +0 -0
  27. astra/config/defaults.yaml +37 -0
  28. astra/config/profiles/.gitkeep +0 -0
  29. astra/core/__init__.py +0 -0
  30. astra/core/confirm.py +18 -0
  31. astra/core/events.py +18 -0
  32. astra/core/exceptions.py +15 -0
  33. astra/core/graph.py +141 -0
  34. astra/core/json_stream.py +101 -0
  35. astra/core/onboarding.py +37 -0
  36. astra/core/settings.py +139 -0
  37. astra/core/state.py +32 -0
  38. astra/llm/__init__.py +0 -0
  39. astra/llm/cost.py +27 -0
  40. astra/llm/litellm_client.py +138 -0
  41. astra/llm/ollama_client.py +139 -0
  42. astra/llm/provider.py +88 -0
  43. astra/llm/router.py +201 -0
  44. astra/memory/__init__.py +0 -0
  45. astra/memory/chunking.py +77 -0
  46. astra/memory/embeddings.py +42 -0
  47. astra/memory/models.py +48 -0
  48. astra/memory/retriever.py +16 -0
  49. astra/memory/store.py +219 -0
  50. astra/memory/vector_store.py +102 -0
  51. astra/models/__init__.py +0 -0
  52. astra/models/tool_result.py +11 -0
  53. astra/prompts/.gitkeep +0 -0
  54. astra/tools/__init__.py +0 -0
  55. astra/tools/code/__init__.py +0 -0
  56. astra/tools/code/backend.py +76 -0
  57. astra/tools/code/tools.py +38 -0
  58. astra/tools/docker/__init__.py +0 -0
  59. astra/tools/docker/backend.py +122 -0
  60. astra/tools/docker/tools.py +53 -0
  61. astra/tools/filesystem/__init__.py +0 -0
  62. astra/tools/filesystem/backend.py +121 -0
  63. astra/tools/filesystem/tools.py +46 -0
  64. astra/tools/git/__init__.py +0 -0
  65. astra/tools/git/backend.py +110 -0
  66. astra/tools/git/tools.py +55 -0
  67. astra/tools/kubernetes/__init__.py +0 -0
  68. astra/tools/kubernetes/backend.py +101 -0
  69. astra/tools/kubernetes/tools.py +47 -0
  70. astra/tools/mcp/__init__.py +0 -0
  71. astra/tools/mcp/backend.py +103 -0
  72. astra/tools/mcp/client.py +249 -0
  73. astra/tools/mcp/discovery.py +137 -0
  74. astra/tools/registry.py +48 -0
  75. astra/tools/shell/__init__.py +0 -0
  76. astra/tools/shell/backend.py +116 -0
  77. astra/tools/shell/tools.py +32 -0
  78. astra/tools/web/__init__.py +0 -0
  79. astra/tools/web/backend.py +186 -0
  80. astra/tools/web/tools.py +58 -0
  81. astra/utils/__init__.py +0 -0
  82. astra/utils/logging.py +43 -0
  83. astra_cli_agent-0.1.0.dist-info/METADATA +130 -0
  84. astra_cli_agent-0.1.0.dist-info/RECORD +86 -0
  85. astra_cli_agent-0.1.0.dist-info/WHEEL +4 -0
  86. astra_cli_agent-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,150 @@
1
+ """Kubernetes Agent: translates an instruction into one kubectl tool call, executes it,
2
+ reports back. Same one-LLM-call JSON tool-selection dispatch `DockerAgent`/`GitAgent`/
3
+ `FileAgent` use, plus two behaviors specific to this domain:
4
+
5
+ - **Ambient apply path**: `k8s_apply` takes a `path` arg (the manifest file); if the LLM
6
+ omits it and a `k8s.yaml`/`k8s.yml`/`manifest.yaml` file exists in the current project
7
+ directory, the agent defaults to it — same "ambient root, not LLM-chosen" trust level
8
+ `DockerAgent` gives `docker_build`'s `path` arg.
9
+ - **The interactive confirm gate** (§8 of the architecture doc): a confirm-required tool call
10
+ (`k8s_delete`, `k8s_exec` — permanent deletion or arbitrary in-pod command execution) is
11
+ blocked on `self._confirm` before it runs, unless `state.assume_yes` is set. Declining
12
+ cancels the step without ever calling the tool — same gate every other worker agent applies.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from astra.agents.base import BaseAgent
22
+ from astra.core.confirm import ConfirmFn, interactive_confirm
23
+ from astra.core.events import Event, record
24
+ from astra.core.state import AgentState, PlanStep
25
+ from astra.llm.provider import LLMResponse
26
+ from astra.llm.router import FallbackRouter
27
+ from astra.tools.registry import ToolRegistry
28
+
29
+ _AMBIENT_MANIFEST_NAMES = ("k8s.yaml", "k8s.yml", "manifest.yaml", "manifest.yml")
30
+
31
+
32
+ def _strip_code_fence(text: str) -> str:
33
+ text = text.strip()
34
+ if text.startswith("```"):
35
+ lines = text.splitlines()
36
+ lines = lines[1:] if lines else lines
37
+ if lines and lines[-1].strip().startswith("```"):
38
+ lines = lines[:-1]
39
+ text = "\n".join(lines).strip()
40
+ return text
41
+
42
+
43
+ class KubernetesAgent(BaseAgent):
44
+ name = "kubernetes"
45
+ description = (
46
+ "Manages Kubernetes resources in the current cluster context: list, describe, apply "
47
+ "manifests, scale, restart rollouts, expose, delete, and exec into pods."
48
+ )
49
+
50
+ def __init__(
51
+ self,
52
+ router: FallbackRouter,
53
+ tool_registry: ToolRegistry,
54
+ confirm: ConfirmFn = interactive_confirm,
55
+ ) -> None:
56
+ super().__init__(router)
57
+ self._tools = tool_registry
58
+ self._confirm = confirm
59
+ catalog_lines = "\n".join(f"- {t.name}: {t.description}" for t in tool_registry.by_tag(self.name))
60
+ self.system_prompt = (
61
+ "You are Astra's kubernetes agent. Given a developer instruction, choose exactly "
62
+ "one tool from the catalog and the arguments to call it with. Reply with ONLY a "
63
+ 'single JSON object: {"tool": "<tool_name>", "args": {"<arg>": "<value>", ...}}. '
64
+ "Omit an arg entirely rather than inventing a value you weren't given — in "
65
+ 'particular, omit "path" for k8s_apply and "namespace" for any tool if no '
66
+ "namespace was specified.\n\n"
67
+ f"Catalog:\n{catalog_lines}"
68
+ )
69
+
70
+ def run(self, state: AgentState) -> dict[str, Any]:
71
+ updated_plan = list(state.plan)
72
+ events = state.events
73
+ total_cost = state.total_cost_usd
74
+ root = Path.cwd().resolve()
75
+
76
+ for i, step in enumerate(updated_plan):
77
+ if step.agent != self.name or step.status != "pending":
78
+ continue
79
+ new_step, cost, events = self._execute_step(step, root, events, state.assume_yes)
80
+ updated_plan[i] = new_step
81
+ total_cost += cost
82
+
83
+ return {"plan": updated_plan, "events": events, "total_cost_usd": total_cost}
84
+
85
+ def _execute_step(
86
+ self, step: PlanStep, root: Path, events: list[Event], assume_yes: bool
87
+ ) -> tuple[PlanStep, float, list[Event]]:
88
+ selection_response = self.complete(step.instruction, temperature=0.0)
89
+ cost = selection_response.estimated_cost_usd
90
+
91
+ try:
92
+ tool_name, args = self._parse_selection(selection_response)
93
+ except ValueError as exc:
94
+ events = record(events, self.name, f"could not parse tool selection: {exc}")
95
+ return step.model_copy(update={"status": "failed", "result": str(exc)}), cost, events
96
+
97
+ tool = self._tools.get(tool_name)
98
+ if tool is None:
99
+ error = f"selected unknown kubernetes tool: {tool_name}"
100
+ events = record(events, self.name, error)
101
+ return step.model_copy(update={"status": "failed", "result": error}), cost, events
102
+
103
+ if tool_name == "k8s_apply" and not args.get("path"):
104
+ default_manifest = self._find_ambient_manifest(root)
105
+ if default_manifest is not None:
106
+ args["path"] = str(default_manifest)
107
+
108
+ is_risky = "confirm-required" in tool.tags
109
+ if is_risky and not assume_yes:
110
+ approved = self._confirm(f"Allow risky kubernetes operation ({tool_name}) with args={args}?")
111
+ if not approved:
112
+ message = f"cancelled by user (risky operation not confirmed): {tool_name}"
113
+ events = record(events, self.name, message)
114
+ return step.model_copy(update={"status": "failed", "result": message}), cost, events
115
+
116
+ try:
117
+ result = tool.func(**args)
118
+ except Exception as exc: # LLM-supplied tool args are untrusted input; never crash the graph
119
+ error = f"kubernetes agent call to {tool_name} failed: {exc}"
120
+ events = record(events, self.name, error)
121
+ return step.model_copy(update={"status": "failed", "result": error}), cost, events
122
+
123
+ output = result.output or result.error or "(no output)"
124
+ if is_risky:
125
+ output = f"⚠️ risky operation ({tool_name}): {output}"
126
+ events = record(events, self.name, f"risky operation executed: {tool_name}")
127
+ events = record(events, self.name, f"executed step {step.step_id} via {tool_name}")
128
+
129
+ status = "done" if result.success else "failed"
130
+ return step.model_copy(update={"status": status, "result": output}), cost, events
131
+
132
+ def _find_ambient_manifest(self, root: Path) -> Path | None:
133
+ for name in _AMBIENT_MANIFEST_NAMES:
134
+ candidate = root / name
135
+ if candidate.is_file():
136
+ return candidate
137
+ return None
138
+
139
+ def _parse_selection(self, response: LLMResponse) -> tuple[str, dict[str, Any]]:
140
+ raw = _strip_code_fence(response.content)
141
+ try:
142
+ parsed = json.loads(raw)
143
+ except json.JSONDecodeError as exc:
144
+ raise ValueError(f"kubernetes agent produced non-JSON tool selection: {raw!r}") from exc
145
+
146
+ tool_name = parsed.get("tool")
147
+ args = parsed.get("args") or {}
148
+ if not isinstance(tool_name, str) or not isinstance(args, dict):
149
+ raise ValueError(f"kubernetes agent produced a malformed tool selection: {parsed!r}")
150
+ return tool_name, dict(args)
@@ -0,0 +1,135 @@
1
+ """MCP Agent: translates an instruction into one call against a discovered MCP tool
2
+ (or resource/prompt meta-tool) and executes it. Same one-LLM-call JSON tool-selection
3
+ dispatch every other worker agent uses (`GitAgent`/`DockerAgent`/`KubernetesAgent`), plus
4
+ the same interactive confirm gate for anything tagged `confirm-required` — which, for this
5
+ domain, is most discovered tools by default (see `tools/mcp/discovery.py`'s risk-tagging
6
+ rationale: an MCP server's own `readOnlyHint` annotation is trusted only when explicitly
7
+ `True`, since third-party MCP servers are the least-trusted tool domain in this project).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from typing import Any
14
+
15
+ from astra.agents.base import BaseAgent
16
+ from astra.core.confirm import ConfirmFn, interactive_confirm
17
+ from astra.core.events import Event, record
18
+ from astra.core.state import AgentState, PlanStep
19
+ from astra.llm.provider import LLMResponse
20
+ from astra.llm.router import FallbackRouter
21
+ from astra.tools.registry import ToolRegistry
22
+
23
+
24
+ def _strip_code_fence(text: str) -> str:
25
+ text = text.strip()
26
+ if text.startswith("```"):
27
+ lines = text.splitlines()
28
+ lines = lines[1:] if lines else lines
29
+ if lines and lines[-1].strip().startswith("```"):
30
+ lines = lines[:-1]
31
+ text = "\n".join(lines).strip()
32
+ return text
33
+
34
+
35
+ class MCPAgent(BaseAgent):
36
+ name = "mcp"
37
+ description = (
38
+ "Calls tools, reads resources, and renders prompts exposed by configured MCP "
39
+ "(Model Context Protocol) servers — third-party plugins outside astra's built-in "
40
+ "tool domains."
41
+ )
42
+
43
+ def __init__(
44
+ self,
45
+ router: FallbackRouter,
46
+ tool_registry: ToolRegistry,
47
+ confirm: ConfirmFn = interactive_confirm,
48
+ ) -> None:
49
+ super().__init__(router)
50
+ self._tools = tool_registry
51
+ self._confirm = confirm
52
+ catalog_lines = "\n".join(f"- {t.name}: {t.description}" for t in tool_registry.by_tag(self.name))
53
+ self.system_prompt = (
54
+ "You are Astra's MCP agent. Given a developer instruction, choose exactly one "
55
+ "tool from the catalog and the arguments to call it with. Reply with ONLY a single "
56
+ 'JSON object: {"tool": "<tool_name>", "args": {"<arg>": "<value>", ...}}. Omit an '
57
+ "arg entirely rather than inventing a value you weren't given. For a "
58
+ '"*_get_prompt" tool, "arguments" (if any) must be a JSON object encoded as a '
59
+ "string, e.g. \"{\\\"topic\\\": \\\"python\\\"}\".\n\n"
60
+ f"Catalog:\n{catalog_lines}"
61
+ )
62
+
63
+ def run(self, state: AgentState) -> dict[str, Any]:
64
+ updated_plan = list(state.plan)
65
+ events = state.events
66
+ total_cost = state.total_cost_usd
67
+
68
+ for i, step in enumerate(updated_plan):
69
+ if step.agent != self.name or step.status != "pending":
70
+ continue
71
+ new_step, cost, events = self._execute_step(step, events, state.assume_yes)
72
+ updated_plan[i] = new_step
73
+ total_cost += cost
74
+
75
+ return {"plan": updated_plan, "events": events, "total_cost_usd": total_cost}
76
+
77
+ def _execute_step(
78
+ self, step: PlanStep, events: list[Event], assume_yes: bool
79
+ ) -> tuple[PlanStep, float, list[Event]]:
80
+ if not self._tools.by_tag(self.name):
81
+ error = "no MCP tools available (no server configured, or discovery failed for all of them)"
82
+ events = record(events, self.name, error)
83
+ return step.model_copy(update={"status": "failed", "result": error}), 0.0, events
84
+
85
+ selection_response = self.complete(step.instruction, temperature=0.0)
86
+ cost = selection_response.estimated_cost_usd
87
+
88
+ try:
89
+ tool_name, args = self._parse_selection(selection_response)
90
+ except ValueError as exc:
91
+ events = record(events, self.name, f"could not parse tool selection: {exc}")
92
+ return step.model_copy(update={"status": "failed", "result": str(exc)}), cost, events
93
+
94
+ tool = self._tools.get(tool_name)
95
+ if tool is None:
96
+ error = f"selected unknown mcp tool: {tool_name}"
97
+ events = record(events, self.name, error)
98
+ return step.model_copy(update={"status": "failed", "result": error}), cost, events
99
+
100
+ is_risky = "confirm-required" in tool.tags
101
+ if is_risky and not assume_yes:
102
+ approved = self._confirm(f"Allow risky MCP operation ({tool_name}) with args={args}?")
103
+ if not approved:
104
+ message = f"cancelled by user (risky operation not confirmed): {tool_name}"
105
+ events = record(events, self.name, message)
106
+ return step.model_copy(update={"status": "failed", "result": message}), cost, events
107
+
108
+ try:
109
+ result = tool.func(**args)
110
+ except Exception as exc: # LLM-supplied tool args are untrusted input; never crash the graph
111
+ error = f"mcp agent call to {tool_name} failed: {exc}"
112
+ events = record(events, self.name, error)
113
+ return step.model_copy(update={"status": "failed", "result": error}), cost, events
114
+
115
+ output = result.output or result.error or "(no output)"
116
+ if is_risky:
117
+ output = f"⚠️ risky operation ({tool_name}): {output}"
118
+ events = record(events, self.name, f"risky operation executed: {tool_name}")
119
+ events = record(events, self.name, f"executed step {step.step_id} via {tool_name}")
120
+
121
+ status = "done" if result.success else "failed"
122
+ return step.model_copy(update={"status": status, "result": output}), cost, events
123
+
124
+ def _parse_selection(self, response: LLMResponse) -> tuple[str, dict[str, Any]]:
125
+ raw = _strip_code_fence(response.content)
126
+ try:
127
+ parsed = json.loads(raw)
128
+ except json.JSONDecodeError as exc:
129
+ raise ValueError(f"mcp agent produced non-JSON tool selection: {raw!r}") from exc
130
+
131
+ tool_name = parsed.get("tool")
132
+ args = parsed.get("args") or {}
133
+ if not isinstance(tool_name, str) or not isinstance(args, dict):
134
+ raise ValueError(f"mcp agent produced a malformed tool selection: {parsed!r}")
135
+ return tool_name, dict(args)
@@ -0,0 +1,35 @@
1
+ """Memory Agent: the only agent that touches the SQLite store directly.
2
+
3
+ Runs as the final graph node, persisting the completed request/response pair.
4
+ Other agents that need history or preferences must go through this agent's
5
+ `MemoryStore` rather than opening their own connection.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from astra.core.events import record
13
+ from astra.core.state import AgentState
14
+ from astra.llm.router import FallbackRouter
15
+ from astra.memory.store import MemoryStore
16
+
17
+
18
+ class MemoryAgent:
19
+ name = "memory"
20
+
21
+ def __init__(self, store: MemoryStore, llm_router: FallbackRouter) -> None:
22
+ self._store = store
23
+ self._router = llm_router
24
+
25
+ def run(self, state: AgentState) -> dict[str, Any]:
26
+ conversation = self._store.save_conversation(
27
+ request=state.request,
28
+ response=state.final_response or "",
29
+ cost_usd=state.total_cost_usd,
30
+ )
31
+ # Drains (not just reads) the router's call log — see `drain_call_log`'s docstring
32
+ # for why: this agent runs once per request, and draining keeps a future multi-request
33
+ # process (the Phase 16 REPL) from re-persisting a previous request's calls.
34
+ self._store.save_llm_calls(conversation.id, self._router.drain_call_log())
35
+ return {"events": record(state.events, self.name, "saved conversation to memory")}
@@ -0,0 +1,149 @@
1
+ """Planner Agent: in a single LLM call, both *routes* (picks which agent handles each step)
2
+ and *plans* (rewrites the request into precise, ordered, imperative instructions).
3
+
4
+ This merges what used to be two separate LLM calls — a `RouterAgent` that classified the
5
+ request to exactly one agent, followed by a `PlannerAgent` that rewrote it into one
6
+ instruction. Folding them into one call cuts a full LLM round-trip off every request, and
7
+ lets the same call emit a *multi-step, multi-agent* plan (e.g. "commit my changes and then
8
+ explain the diff" -> a git step followed by a coder step) instead of the previous
9
+ always-exactly-one-step limitation.
10
+
11
+ Output is a JSON object `{"steps": [{"agent": "<name>", "instruction": "<imperative>"}, ...]}`.
12
+ Unknown agent names the LLM invents are coerced to the first catalog entry (so `execute_plan`
13
+ never sees an unregistered agent — its `NoAgentAvailableError` is a defense-in-depth invariant,
14
+ not a normal-path branch). Non-JSON / malformed output degrades gracefully to a single step
15
+ routed to the fallback agent with the raw request as the instruction — the same "never crash
16
+ the graph on unexpected LLM output" tolerance every other agent has.
17
+
18
+ On a revision pass (`state.revision_feedback` set by the Reviewer), a single follow-up step is
19
+ *appended* re-targeting the last step's agent with the reviewer's feedback folded in — prior
20
+ steps stay in the plan for history, same as before the merge.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ from typing import Any
27
+
28
+ from astra.agents.base import BaseAgent
29
+ from astra.core.events import record
30
+ from astra.core.state import AgentState, PlanStep
31
+ from astra.llm.router import FallbackRouter
32
+
33
+
34
+ def _strip_code_fence(text: str) -> str:
35
+ text = text.strip()
36
+ if text.startswith("```"):
37
+ lines = text.splitlines()
38
+ lines = lines[1:] if lines else lines
39
+ if lines and lines[-1].strip().startswith("```"):
40
+ lines = lines[:-1]
41
+ text = "\n".join(lines).strip()
42
+ return text
43
+
44
+
45
+ class PlannerAgent(BaseAgent):
46
+ name = "planner"
47
+
48
+ def __init__(self, router: FallbackRouter, agent_catalog: dict[str, str]) -> None:
49
+ super().__init__(router)
50
+ if not agent_catalog:
51
+ raise ValueError("agent_catalog must contain at least one agent")
52
+ self._catalog = agent_catalog
53
+ self._fallback_agent = next(iter(agent_catalog))
54
+ catalog_lines = "\n".join(f"- {name}: {desc}" for name, desc in agent_catalog.items())
55
+ self.system_prompt = (
56
+ "You are Astra's planning agent. You both choose which agent handles the work and "
57
+ "rewrite the developer's request into precise, imperative instructions. Reply with "
58
+ "ONLY a single JSON object of this exact shape:\n"
59
+ '{"steps": [{"agent": "<agent name from the catalog>", "instruction": '
60
+ '"<one precise imperative instruction>"}, ...]}\n'
61
+ "Use exactly one step for a simple request. Use multiple ordered steps only when the "
62
+ "request genuinely needs different agents or sequential actions (e.g. commit changes, "
63
+ "then explain the diff). Each 'agent' must be one of the catalog names below, "
64
+ "lowercase. Do not add commentary outside the JSON.\n\n"
65
+ f"Catalog:\n{catalog_lines}"
66
+ )
67
+
68
+ def run(self, state: AgentState) -> dict[str, Any]:
69
+ if state.revision_feedback:
70
+ return self._plan_revision(state)
71
+
72
+ user_content = state.request
73
+ if state.retrieved_context:
74
+ context_block = "\n\n".join(state.retrieved_context)
75
+ user_content = (
76
+ f"Relevant project context:\n{context_block}\n\nDeveloper request: {state.request}"
77
+ )
78
+ response = self.complete(user_content, temperature=0.1)
79
+ steps = self._parse_plan(response.content, state.request)
80
+ selected_agents: list[str] = []
81
+ for step in steps:
82
+ if step.agent not in selected_agents:
83
+ selected_agents.append(step.agent)
84
+ agents_desc = ",".join(selected_agents)
85
+ return {
86
+ "selected_agents": selected_agents,
87
+ "plan": steps,
88
+ "events": record(
89
+ state.events,
90
+ self.name,
91
+ f"planned {len(steps)} step(s) across agent(s): {agents_desc}",
92
+ ),
93
+ "total_cost_usd": state.total_cost_usd + response.estimated_cost_usd,
94
+ }
95
+
96
+ def _parse_plan(self, content: str, request: str) -> list[PlanStep]:
97
+ """Parses the JSON plan into PlanSteps, coercing unknown agent names and degrading to a
98
+ single fallback step on any malformed output."""
99
+ raw = _strip_code_fence(content)
100
+ try:
101
+ parsed = json.loads(raw)
102
+ except json.JSONDecodeError:
103
+ return [self._fallback_step(1, request)]
104
+
105
+ raw_steps = parsed.get("steps") if isinstance(parsed, dict) else None
106
+ if not isinstance(raw_steps, list) or not raw_steps:
107
+ return [self._fallback_step(1, request)]
108
+
109
+ steps: list[PlanStep] = []
110
+ for raw_step in raw_steps:
111
+ if not isinstance(raw_step, dict):
112
+ continue
113
+ agent = raw_step.get("agent")
114
+ instruction = raw_step.get("instruction")
115
+ if not isinstance(agent, str) or agent not in self._catalog:
116
+ agent = self._fallback_agent
117
+ if not isinstance(instruction, str) or not instruction.strip():
118
+ instruction = request
119
+ steps.append(
120
+ PlanStep(step_id=len(steps) + 1, agent=agent, instruction=instruction.strip())
121
+ )
122
+
123
+ return steps or [self._fallback_step(1, request)]
124
+
125
+ def _fallback_step(self, step_id: int, request: str) -> PlanStep:
126
+ return PlanStep(step_id=step_id, agent=self._fallback_agent, instruction=request)
127
+
128
+ def _plan_revision(self, state: AgentState) -> dict[str, Any]:
129
+ """Appends one more step re-targeting the last step's agent with reviewer feedback."""
130
+ agent_name = state.plan[-1].agent if state.plan else self._fallback_agent
131
+ previous_result = state.plan[-1].result if state.plan else "(no previous result)"
132
+ user_content = (
133
+ f"Developer request: {state.request}\n\n"
134
+ f"Previous attempt result:\n{previous_result}\n\n"
135
+ f"Reviewer feedback: {state.revision_feedback}\n\n"
136
+ "Rewrite a single, precise, imperative instruction that addresses the feedback. "
137
+ "Reply with only the instruction text, no JSON, no preamble."
138
+ )
139
+ response = self.complete(user_content, temperature=0.1)
140
+ instruction = response.content.strip() or state.request
141
+ next_step_id = state.plan[-1].step_id + 1 if state.plan else 1
142
+ step = PlanStep(step_id=next_step_id, agent=agent_name, instruction=instruction)
143
+ return {
144
+ "plan": [*state.plan, step],
145
+ "events": record(
146
+ state.events, self.name, f"planned revision step {next_step_id} for agent={agent_name}"
147
+ ),
148
+ "total_cost_usd": state.total_cost_usd + response.estimated_cost_usd,
149
+ }
@@ -0,0 +1,121 @@
1
+ """Research Agent: translates an instruction into one web-search/fetch tool call, executes
2
+ it, reports back. Same one-LLM-call JSON tool-selection dispatch every other worker agent
3
+ uses, minus the interactive confirm gate — every tool in this domain is tagged "read-only"
4
+ (an outbound HTTP GET against a public API, nothing to confirm or undo), so there's no
5
+ confirm-required tier to check, unlike `GitAgent`/`FileAgent`/`DockerAgent`/
6
+ `KubernetesAgent`.
7
+
8
+ Every result line from a search tool carries its source URL, and `web_fetch_url` prefixes its
9
+ output with `[source: <url>]` — that's the "citation" half of the architecture doc's
10
+ "docs/SO/GitHub search with citation" requirement: the Reviewer's existing pass-risk-warnings-
11
+ verbatim instruction (Phase 9) doesn't apply here since nothing here is risky, but source URLs
12
+ in plain step output naturally survive into `final_response` the same way any other step
13
+ result does.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from typing import Any
20
+
21
+ from astra.agents.base import BaseAgent
22
+ from astra.core.events import Event, record
23
+ from astra.core.state import AgentState, PlanStep
24
+ from astra.llm.provider import LLMResponse
25
+ from astra.llm.router import FallbackRouter
26
+ from astra.tools.registry import ToolRegistry
27
+
28
+
29
+ def _strip_code_fence(text: str) -> str:
30
+ text = text.strip()
31
+ if text.startswith("```"):
32
+ lines = text.splitlines()
33
+ lines = lines[1:] if lines else lines
34
+ if lines and lines[-1].strip().startswith("```"):
35
+ lines = lines[:-1]
36
+ text = "\n".join(lines).strip()
37
+ return text
38
+
39
+
40
+ class ResearchAgent(BaseAgent):
41
+ name = "research"
42
+ description = (
43
+ "Searches the web for developer information: general web/documentation search, GitHub "
44
+ "repositories, GitHub issues/pull requests, Stack Overflow questions, and fetches the "
45
+ "readable content of a specific documentation URL."
46
+ )
47
+
48
+ def __init__(self, router: FallbackRouter, tool_registry: ToolRegistry) -> None:
49
+ super().__init__(router)
50
+ self._tools = tool_registry
51
+ catalog_lines = "\n".join(f"- {t.name}: {t.description}" for t in tool_registry.by_tag(self.name))
52
+ self.system_prompt = (
53
+ "You are Astra's research agent. Given a developer instruction, choose exactly "
54
+ "one tool from the catalog and the arguments to call it with. Reply with ONLY a "
55
+ 'single JSON object: {"tool": "<tool_name>", "args": {"<arg>": "<value>", ...}}. '
56
+ "Use web_search_general for broad web/documentation/how-to searches, "
57
+ "web_search_github_repos for finding libraries/projects, "
58
+ "web_search_github_issues for finding how others solved a specific problem, "
59
+ "web_search_stackoverflow for Q&A-style questions, and web_fetch_url only when "
60
+ "given (or you already know) a specific URL to read. Omit an arg entirely rather "
61
+ "than inventing a value you weren't given.\n\n"
62
+ f"Catalog:\n{catalog_lines}"
63
+ )
64
+
65
+ def run(self, state: AgentState) -> dict[str, Any]:
66
+ updated_plan = list(state.plan)
67
+ events = state.events
68
+ total_cost = state.total_cost_usd
69
+
70
+ for i, step in enumerate(updated_plan):
71
+ if step.agent != self.name or step.status != "pending":
72
+ continue
73
+ new_step, cost, events = self._execute_step(step, events)
74
+ updated_plan[i] = new_step
75
+ total_cost += cost
76
+
77
+ return {"plan": updated_plan, "events": events, "total_cost_usd": total_cost}
78
+
79
+ def _execute_step(
80
+ self, step: PlanStep, events: list[Event]
81
+ ) -> tuple[PlanStep, float, list[Event]]:
82
+ selection_response = self.complete(step.instruction, temperature=0.0)
83
+ cost = selection_response.estimated_cost_usd
84
+
85
+ try:
86
+ tool_name, args = self._parse_selection(selection_response)
87
+ except ValueError as exc:
88
+ events = record(events, self.name, f"could not parse tool selection: {exc}")
89
+ return step.model_copy(update={"status": "failed", "result": str(exc)}), cost, events
90
+
91
+ tool = self._tools.get(tool_name)
92
+ if tool is None:
93
+ error = f"selected unknown research tool: {tool_name}"
94
+ events = record(events, self.name, error)
95
+ return step.model_copy(update={"status": "failed", "result": error}), cost, events
96
+
97
+ try:
98
+ result = tool.func(**args)
99
+ except Exception as exc: # LLM-supplied tool args are untrusted input; never crash the graph
100
+ error = f"research agent call to {tool_name} failed: {exc}"
101
+ events = record(events, self.name, error)
102
+ return step.model_copy(update={"status": "failed", "result": error}), cost, events
103
+
104
+ output = result.output or result.error or "(no output)"
105
+ events = record(events, self.name, f"executed step {step.step_id} via {tool_name}")
106
+
107
+ status = "done" if result.success else "failed"
108
+ return step.model_copy(update={"status": status, "result": output}), cost, events
109
+
110
+ def _parse_selection(self, response: LLMResponse) -> tuple[str, dict[str, Any]]:
111
+ raw = _strip_code_fence(response.content)
112
+ try:
113
+ parsed = json.loads(raw)
114
+ except json.JSONDecodeError as exc:
115
+ raise ValueError(f"research agent produced non-JSON tool selection: {raw!r}") from exc
116
+
117
+ tool_name = parsed.get("tool")
118
+ args = parsed.get("args") or {}
119
+ if not isinstance(tool_name, str) or not isinstance(args, dict):
120
+ raise ValueError(f"research agent produced a malformed tool selection: {parsed!r}")
121
+ return tool_name, dict(args)
@@ -0,0 +1,52 @@
1
+ """Retrieve Agent: injects ingested-project context into the request before planning.
2
+
3
+ No-ops (no embedding cost, no extra latency) when the current working directory hasn't
4
+ been ingested via `astra ingest` — most requests won't have a project registered, and
5
+ this must stay free in that case.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from astra.core.events import record
14
+ from astra.core.settings import MemorySettings
15
+ from astra.core.state import AgentState
16
+ from astra.memory.embeddings import EmbeddingModel
17
+ from astra.memory.retriever import Retriever
18
+ from astra.memory.store import MemoryStore
19
+ from astra.memory.vector_store import VectorStore
20
+
21
+ _TOP_K = 3
22
+
23
+
24
+ class RetrieveAgent:
25
+ name = "retrieve"
26
+
27
+ def __init__(self, store: MemoryStore, memory_settings: MemorySettings) -> None:
28
+ self._store = store
29
+ self._memory_settings = memory_settings
30
+
31
+ def run(self, state: AgentState) -> dict[str, Any]:
32
+ project = self._store.get_project_by_path(str(Path.cwd()))
33
+ if project is None:
34
+ return {
35
+ "retrieved_context": [],
36
+ "events": record(state.events, self.name, "no ingested project for cwd"),
37
+ }
38
+
39
+ embeddings = EmbeddingModel(self._memory_settings.embedding_model)
40
+ vector_store = VectorStore(
41
+ self._memory_settings.index_path / project.slug, dimension=embeddings.dimension
42
+ )
43
+ results = Retriever(vector_store, embeddings).query(state.request, k=_TOP_K)
44
+ context = [f"{chunk.source}: {chunk.text.strip()}" for chunk, _distance in results]
45
+ return {
46
+ "retrieved_context": context,
47
+ "events": record(
48
+ state.events,
49
+ self.name,
50
+ f"retrieved {len(context)} chunk(s) from project {project.slug}",
51
+ ),
52
+ }