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.
- astra/__init__.py +1 -0
- astra/agents/__init__.py +0 -0
- astra/agents/base.py +44 -0
- astra/agents/coder_agent.py +39 -0
- astra/agents/debug_agent.py +41 -0
- astra/agents/docker_agent.py +139 -0
- astra/agents/documentation_agent.py +41 -0
- astra/agents/file_agent.py +176 -0
- astra/agents/git_agent.py +163 -0
- astra/agents/kubernetes_agent.py +150 -0
- astra/agents/mcp_agent.py +135 -0
- astra/agents/memory_agent.py +35 -0
- astra/agents/planner_agent.py +149 -0
- astra/agents/research_agent.py +121 -0
- astra/agents/retrieve_agent.py +52 -0
- astra/agents/reviewer_agent.py +133 -0
- astra/cli/__init__.py +0 -0
- astra/cli/app.py +175 -0
- astra/cli/chat.py +145 -0
- astra/cli/ingest.py +57 -0
- astra/cli/mcp.py +65 -0
- astra/cli/memory.py +197 -0
- astra/cli/rendering.py +50 -0
- astra/cli/setup.py +206 -0
- astra/cli/single.py +35 -0
- astra/config/__init__.py +0 -0
- astra/config/defaults.yaml +37 -0
- astra/config/profiles/.gitkeep +0 -0
- astra/core/__init__.py +0 -0
- astra/core/confirm.py +18 -0
- astra/core/events.py +18 -0
- astra/core/exceptions.py +15 -0
- astra/core/graph.py +141 -0
- astra/core/json_stream.py +101 -0
- astra/core/onboarding.py +37 -0
- astra/core/settings.py +139 -0
- astra/core/state.py +32 -0
- astra/llm/__init__.py +0 -0
- astra/llm/cost.py +27 -0
- astra/llm/litellm_client.py +138 -0
- astra/llm/ollama_client.py +139 -0
- astra/llm/provider.py +88 -0
- astra/llm/router.py +201 -0
- astra/memory/__init__.py +0 -0
- astra/memory/chunking.py +77 -0
- astra/memory/embeddings.py +42 -0
- astra/memory/models.py +48 -0
- astra/memory/retriever.py +16 -0
- astra/memory/store.py +219 -0
- astra/memory/vector_store.py +102 -0
- astra/models/__init__.py +0 -0
- astra/models/tool_result.py +11 -0
- astra/prompts/.gitkeep +0 -0
- astra/tools/__init__.py +0 -0
- astra/tools/code/__init__.py +0 -0
- astra/tools/code/backend.py +76 -0
- astra/tools/code/tools.py +38 -0
- astra/tools/docker/__init__.py +0 -0
- astra/tools/docker/backend.py +122 -0
- astra/tools/docker/tools.py +53 -0
- astra/tools/filesystem/__init__.py +0 -0
- astra/tools/filesystem/backend.py +121 -0
- astra/tools/filesystem/tools.py +46 -0
- astra/tools/git/__init__.py +0 -0
- astra/tools/git/backend.py +110 -0
- astra/tools/git/tools.py +55 -0
- astra/tools/kubernetes/__init__.py +0 -0
- astra/tools/kubernetes/backend.py +101 -0
- astra/tools/kubernetes/tools.py +47 -0
- astra/tools/mcp/__init__.py +0 -0
- astra/tools/mcp/backend.py +103 -0
- astra/tools/mcp/client.py +249 -0
- astra/tools/mcp/discovery.py +137 -0
- astra/tools/registry.py +48 -0
- astra/tools/shell/__init__.py +0 -0
- astra/tools/shell/backend.py +116 -0
- astra/tools/shell/tools.py +32 -0
- astra/tools/web/__init__.py +0 -0
- astra/tools/web/backend.py +186 -0
- astra/tools/web/tools.py +58 -0
- astra/utils/__init__.py +0 -0
- astra/utils/logging.py +43 -0
- astra_cli_agent-0.1.0.dist-info/METADATA +130 -0
- astra_cli_agent-0.1.0.dist-info/RECORD +86 -0
- astra_cli_agent-0.1.0.dist-info/WHEEL +4 -0
- 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
|
+
}
|