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
astra/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
astra/agents/__init__.py
ADDED
|
File without changes
|
astra/agents/base.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Base contract every LangGraph agent node implements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from astra.core.state import AgentState
|
|
10
|
+
from astra.llm.provider import LLMMessage, LLMResponse
|
|
11
|
+
from astra.llm.router import FallbackRouter
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BaseAgent(ABC):
|
|
15
|
+
name: str
|
|
16
|
+
description: str = ""
|
|
17
|
+
system_prompt: str = ""
|
|
18
|
+
|
|
19
|
+
def __init__(self, router: FallbackRouter) -> None:
|
|
20
|
+
self._router = router
|
|
21
|
+
|
|
22
|
+
def complete(self, user_content: str, *, temperature: float = 0.2) -> LLMResponse:
|
|
23
|
+
messages = [
|
|
24
|
+
LLMMessage(role="system", content=self.system_prompt),
|
|
25
|
+
LLMMessage(role="user", content=user_content),
|
|
26
|
+
]
|
|
27
|
+
return self._router.complete(messages, temperature=temperature)
|
|
28
|
+
|
|
29
|
+
def stream(
|
|
30
|
+
self,
|
|
31
|
+
user_content: str,
|
|
32
|
+
*,
|
|
33
|
+
temperature: float = 0.2,
|
|
34
|
+
on_chunk: Callable[[str], None] | None = None,
|
|
35
|
+
) -> LLMResponse:
|
|
36
|
+
messages = [
|
|
37
|
+
LLMMessage(role="system", content=self.system_prompt),
|
|
38
|
+
LLMMessage(role="user", content=user_content),
|
|
39
|
+
]
|
|
40
|
+
return self._router.stream_complete(messages, temperature=temperature, on_chunk=on_chunk)
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def run(self, state: AgentState) -> dict[str, Any]:
|
|
44
|
+
"""Return a partial AgentState update — the LangGraph node contract."""
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Coder Agent: general-purpose coding assistant, executes plan steps assigned to it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from astra.agents.base import BaseAgent
|
|
8
|
+
from astra.core.events import record
|
|
9
|
+
from astra.core.state import AgentState
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CoderAgent(BaseAgent):
|
|
13
|
+
name = "coder"
|
|
14
|
+
description = (
|
|
15
|
+
"General-purpose coding assistant: explains code, writes code, and reasons about "
|
|
16
|
+
"software design and general development questions. For root-causing errors, stack "
|
|
17
|
+
"traces, or failing tests, prefer the debug agent instead."
|
|
18
|
+
)
|
|
19
|
+
system_prompt = (
|
|
20
|
+
"You are Astra's coding agent, an expert software engineer. Answer the instruction "
|
|
21
|
+
"directly and concisely, with code blocks where helpful. Do not restate the question."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def run(self, state: AgentState) -> dict[str, Any]:
|
|
25
|
+
updated_plan = list(state.plan)
|
|
26
|
+
events = state.events
|
|
27
|
+
total_cost = state.total_cost_usd
|
|
28
|
+
|
|
29
|
+
for i, step in enumerate(updated_plan):
|
|
30
|
+
if step.agent != self.name or step.status != "pending":
|
|
31
|
+
continue
|
|
32
|
+
response = self.complete(step.instruction)
|
|
33
|
+
updated_plan[i] = step.model_copy(
|
|
34
|
+
update={"result": response.content, "status": "done"}
|
|
35
|
+
)
|
|
36
|
+
total_cost += response.estimated_cost_usd
|
|
37
|
+
events = record(events, self.name, f"executed step {step.step_id}")
|
|
38
|
+
|
|
39
|
+
return {"plan": updated_plan, "events": events, "total_cost_usd": total_cost}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Debug Agent: root-causes errors, stack traces, and failing-test output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from astra.agents.base import BaseAgent
|
|
8
|
+
from astra.core.events import record
|
|
9
|
+
from astra.core.state import AgentState
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DebugAgent(BaseAgent):
|
|
13
|
+
name = "debug"
|
|
14
|
+
description = (
|
|
15
|
+
"Debugging specialist: analyzes error messages, stack traces, and failing test output "
|
|
16
|
+
"to find the root cause and propose a concrete fix."
|
|
17
|
+
)
|
|
18
|
+
system_prompt = (
|
|
19
|
+
"You are Astra's debugging agent, an expert at root-causing software failures. Given "
|
|
20
|
+
"an error, stack trace, or description of unexpected behavior, identify the most "
|
|
21
|
+
"likely root cause and propose a concrete fix. Be direct and specific — name the "
|
|
22
|
+
"failing component and the exact change needed, with a code block if helpful. Do not "
|
|
23
|
+
"restate the question or list generic troubleshooting steps unrelated to this case."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def run(self, state: AgentState) -> dict[str, Any]:
|
|
27
|
+
updated_plan = list(state.plan)
|
|
28
|
+
events = state.events
|
|
29
|
+
total_cost = state.total_cost_usd
|
|
30
|
+
|
|
31
|
+
for i, step in enumerate(updated_plan):
|
|
32
|
+
if step.agent != self.name or step.status != "pending":
|
|
33
|
+
continue
|
|
34
|
+
response = self.complete(step.instruction)
|
|
35
|
+
updated_plan[i] = step.model_copy(
|
|
36
|
+
update={"result": response.content, "status": "done"}
|
|
37
|
+
)
|
|
38
|
+
total_cost += response.estimated_cost_usd
|
|
39
|
+
events = record(events, self.name, f"executed step {step.step_id}")
|
|
40
|
+
|
|
41
|
+
return {"plan": updated_plan, "events": events, "total_cost_usd": total_cost}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Docker Agent: translates an instruction into one docker tool call, executes it, reports
|
|
2
|
+
back. Same one-LLM-call JSON tool-selection dispatch `GitAgent`/`FileAgent` use, plus two
|
|
3
|
+
behaviors specific to this domain:
|
|
4
|
+
|
|
5
|
+
- **Ambient build path**: `docker_build` takes a `path` arg (the Dockerfile's directory); if
|
|
6
|
+
the LLM omits it, the agent defaults to the current project directory rather than inventing
|
|
7
|
+
one — same "ambient root, not LLM-chosen" trust level `tools/code/backend.py` gives its
|
|
8
|
+
`root` arg.
|
|
9
|
+
- **The interactive confirm gate** (§8 of the architecture doc): a confirm-required tool call
|
|
10
|
+
(`docker_rm`, `docker_rmi`, `docker_exec`, `docker_prune` — permanent deletion or arbitrary
|
|
11
|
+
in-container command execution) is blocked on `self._confirm` before it runs, unless
|
|
12
|
+
`state.assume_yes` is set. Declining cancels the step without ever calling the tool — same
|
|
13
|
+
gate `GitAgent`/`FileAgent` apply to their own confirm-required tools.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from astra.agents.base import BaseAgent
|
|
23
|
+
from astra.core.confirm import ConfirmFn, interactive_confirm
|
|
24
|
+
from astra.core.events import Event, record
|
|
25
|
+
from astra.core.state import AgentState, PlanStep
|
|
26
|
+
from astra.llm.provider import LLMResponse
|
|
27
|
+
from astra.llm.router import FallbackRouter
|
|
28
|
+
from astra.tools.registry import ToolRegistry
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _strip_code_fence(text: str) -> str:
|
|
32
|
+
text = text.strip()
|
|
33
|
+
if text.startswith("```"):
|
|
34
|
+
lines = text.splitlines()
|
|
35
|
+
lines = lines[1:] if lines else lines
|
|
36
|
+
if lines and lines[-1].strip().startswith("```"):
|
|
37
|
+
lines = lines[:-1]
|
|
38
|
+
text = "\n".join(lines).strip()
|
|
39
|
+
return text
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DockerAgent(BaseAgent):
|
|
43
|
+
name = "docker"
|
|
44
|
+
description = (
|
|
45
|
+
"Manages docker containers and images in the current project: list, inspect, build, "
|
|
46
|
+
"run, start, stop, restart, remove, exec into, prune, and pull."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
router: FallbackRouter,
|
|
52
|
+
tool_registry: ToolRegistry,
|
|
53
|
+
confirm: ConfirmFn = interactive_confirm,
|
|
54
|
+
) -> None:
|
|
55
|
+
super().__init__(router)
|
|
56
|
+
self._tools = tool_registry
|
|
57
|
+
self._confirm = confirm
|
|
58
|
+
catalog_lines = "\n".join(f"- {t.name}: {t.description}" for t in tool_registry.by_tag(self.name))
|
|
59
|
+
self.system_prompt = (
|
|
60
|
+
"You are Astra's docker agent. Given a developer instruction, choose exactly one "
|
|
61
|
+
"tool from the catalog and the arguments to call it with. Reply with ONLY a single "
|
|
62
|
+
'JSON object: {"tool": "<tool_name>", "args": {"<arg>": "<value>", ...}}. Omit an '
|
|
63
|
+
"arg entirely rather than inventing a value you weren't given — in particular, "
|
|
64
|
+
'omit "path" for docker_build if no build context directory was specified.\n\n'
|
|
65
|
+
f"Catalog:\n{catalog_lines}"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def run(self, state: AgentState) -> dict[str, Any]:
|
|
69
|
+
updated_plan = list(state.plan)
|
|
70
|
+
events = state.events
|
|
71
|
+
total_cost = state.total_cost_usd
|
|
72
|
+
root = Path.cwd().resolve()
|
|
73
|
+
|
|
74
|
+
for i, step in enumerate(updated_plan):
|
|
75
|
+
if step.agent != self.name or step.status != "pending":
|
|
76
|
+
continue
|
|
77
|
+
new_step, cost, events = self._execute_step(step, root, events, state.assume_yes)
|
|
78
|
+
updated_plan[i] = new_step
|
|
79
|
+
total_cost += cost
|
|
80
|
+
|
|
81
|
+
return {"plan": updated_plan, "events": events, "total_cost_usd": total_cost}
|
|
82
|
+
|
|
83
|
+
def _execute_step(
|
|
84
|
+
self, step: PlanStep, root: Path, events: list[Event], assume_yes: bool
|
|
85
|
+
) -> tuple[PlanStep, float, list[Event]]:
|
|
86
|
+
selection_response = self.complete(step.instruction, temperature=0.0)
|
|
87
|
+
cost = selection_response.estimated_cost_usd
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
tool_name, args = self._parse_selection(selection_response)
|
|
91
|
+
except ValueError as exc:
|
|
92
|
+
events = record(events, self.name, f"could not parse tool selection: {exc}")
|
|
93
|
+
return step.model_copy(update={"status": "failed", "result": str(exc)}), cost, events
|
|
94
|
+
|
|
95
|
+
tool = self._tools.get(tool_name)
|
|
96
|
+
if tool is None:
|
|
97
|
+
error = f"selected unknown docker tool: {tool_name}"
|
|
98
|
+
events = record(events, self.name, error)
|
|
99
|
+
return step.model_copy(update={"status": "failed", "result": error}), cost, events
|
|
100
|
+
|
|
101
|
+
if tool_name == "docker_build" and not args.get("path"):
|
|
102
|
+
args["path"] = str(root)
|
|
103
|
+
|
|
104
|
+
is_risky = "confirm-required" in tool.tags
|
|
105
|
+
if is_risky and not assume_yes:
|
|
106
|
+
approved = self._confirm(f"Allow risky docker operation ({tool_name}) with args={args}?")
|
|
107
|
+
if not approved:
|
|
108
|
+
message = f"cancelled by user (risky operation not confirmed): {tool_name}"
|
|
109
|
+
events = record(events, self.name, message)
|
|
110
|
+
return step.model_copy(update={"status": "failed", "result": message}), cost, events
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
result = tool.func(**args)
|
|
114
|
+
except Exception as exc: # LLM-supplied tool args are untrusted input; never crash the graph
|
|
115
|
+
error = f"docker agent call to {tool_name} failed: {exc}"
|
|
116
|
+
events = record(events, self.name, error)
|
|
117
|
+
return step.model_copy(update={"status": "failed", "result": error}), cost, events
|
|
118
|
+
|
|
119
|
+
output = result.output or result.error or "(no output)"
|
|
120
|
+
if is_risky:
|
|
121
|
+
output = f"⚠️ risky operation ({tool_name}): {output}"
|
|
122
|
+
events = record(events, self.name, f"risky operation executed: {tool_name}")
|
|
123
|
+
events = record(events, self.name, f"executed step {step.step_id} via {tool_name}")
|
|
124
|
+
|
|
125
|
+
status = "done" if result.success else "failed"
|
|
126
|
+
return step.model_copy(update={"status": status, "result": output}), cost, events
|
|
127
|
+
|
|
128
|
+
def _parse_selection(self, response: LLMResponse) -> tuple[str, dict[str, Any]]:
|
|
129
|
+
raw = _strip_code_fence(response.content)
|
|
130
|
+
try:
|
|
131
|
+
parsed = json.loads(raw)
|
|
132
|
+
except json.JSONDecodeError as exc:
|
|
133
|
+
raise ValueError(f"docker agent produced non-JSON tool selection: {raw!r}") from exc
|
|
134
|
+
|
|
135
|
+
tool_name = parsed.get("tool")
|
|
136
|
+
args = parsed.get("args") or {}
|
|
137
|
+
if not isinstance(tool_name, str) or not isinstance(args, dict):
|
|
138
|
+
raise ValueError(f"docker agent produced a malformed tool selection: {parsed!r}")
|
|
139
|
+
return tool_name, dict(args)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Documentation Agent: writes and improves docstrings, READMEs, and usage guides."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from astra.agents.base import BaseAgent
|
|
8
|
+
from astra.core.events import record
|
|
9
|
+
from astra.core.state import AgentState
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DocumentationAgent(BaseAgent):
|
|
13
|
+
name = "documentation"
|
|
14
|
+
description = (
|
|
15
|
+
"Documentation specialist: writes and improves docstrings, README sections, API docs, "
|
|
16
|
+
"and usage guides for code."
|
|
17
|
+
)
|
|
18
|
+
system_prompt = (
|
|
19
|
+
"You are Astra's documentation agent, an expert technical writer for software "
|
|
20
|
+
"projects. Given a request to document code or a project, produce clear, accurate "
|
|
21
|
+
"documentation in the requested format (docstring, README section, API reference, or "
|
|
22
|
+
"usage guide). Match the style conventions implied by any provided context. Do not "
|
|
23
|
+
"invent behavior the code doesn't have, and do not restate the question."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def run(self, state: AgentState) -> dict[str, Any]:
|
|
27
|
+
updated_plan = list(state.plan)
|
|
28
|
+
events = state.events
|
|
29
|
+
total_cost = state.total_cost_usd
|
|
30
|
+
|
|
31
|
+
for i, step in enumerate(updated_plan):
|
|
32
|
+
if step.agent != self.name or step.status != "pending":
|
|
33
|
+
continue
|
|
34
|
+
response = self.complete(step.instruction)
|
|
35
|
+
updated_plan[i] = step.model_copy(
|
|
36
|
+
update={"result": response.content, "status": "done"}
|
|
37
|
+
)
|
|
38
|
+
total_cost += response.estimated_cost_usd
|
|
39
|
+
events = record(events, self.name, f"executed step {step.step_id}")
|
|
40
|
+
|
|
41
|
+
return {"plan": updated_plan, "events": events, "total_cost_usd": total_cost}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""File Agent: translates an instruction into one filesystem-, code-, or shell-tool call,
|
|
2
|
+
executes it, reports back. Owns Phase 8's two tool domains (filesystem read/write/list/
|
|
3
|
+
search/delete, and code lint/format/test/symbol-search) plus Phase 9's shell domain behind
|
|
4
|
+
a single LLM tool-selection step — the same JSON dispatch pattern `GitAgent` uses, since all
|
|
5
|
+
three domains are small and closely related (they all operate on the current project tree).
|
|
6
|
+
|
|
7
|
+
Two extra behaviors beyond plain dispatch, both part of the §8 safety model:
|
|
8
|
+
- **Sandbox-escape detection**. Every filesystem tool takes a path-shaped arg ("path",
|
|
9
|
+
"src", "dst"); those are resolved against the current working directory before the tool
|
|
10
|
+
runs, and if resolution lands outside the project root the call is treated as risky.
|
|
11
|
+
Code and shell tools pass their (non-path) args straight through to a subprocess the same
|
|
12
|
+
way git's branch/path args do, so they aren't sandbox-resolved.
|
|
13
|
+
- **The interactive confirm gate**. Any risky call (a statically `confirm-required` tool —
|
|
14
|
+
`fs_delete_file`, `shell_run` — or a sandbox-escaping path) is blocked on `self._confirm`
|
|
15
|
+
before it runs, unless `state.assume_yes` is set (`astra run --yes`). Declining cancels
|
|
16
|
+
the step without ever calling the tool. `shell_run` additionally has its own hard denylist
|
|
17
|
+
in `tools/shell/backend.py` that runs regardless of confirmation.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from astra.agents.base import BaseAgent
|
|
27
|
+
from astra.core.confirm import ConfirmFn, interactive_confirm
|
|
28
|
+
from astra.core.events import Event, record
|
|
29
|
+
from astra.core.state import AgentState, PlanStep
|
|
30
|
+
from astra.llm.provider import LLMResponse
|
|
31
|
+
from astra.llm.router import FallbackRouter
|
|
32
|
+
from astra.tools.code.tools import CODE_TOOLS
|
|
33
|
+
from astra.tools.filesystem.tools import FILESYSTEM_TOOLS
|
|
34
|
+
from astra.tools.registry import ToolRegistry
|
|
35
|
+
from astra.tools.shell.tools import SHELL_TOOLS
|
|
36
|
+
|
|
37
|
+
_PATH_ARGS = frozenset({"path", "src", "dst"})
|
|
38
|
+
_AMBIENT_ROOT_TAGS = frozenset({"code", "shell"})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _strip_code_fence(text: str) -> str:
|
|
42
|
+
text = text.strip()
|
|
43
|
+
if text.startswith("```"):
|
|
44
|
+
lines = text.splitlines()
|
|
45
|
+
lines = lines[1:] if lines else lines
|
|
46
|
+
if lines and lines[-1].strip().startswith("```"):
|
|
47
|
+
lines = lines[:-1]
|
|
48
|
+
text = "\n".join(lines).strip()
|
|
49
|
+
return text
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_file_registry() -> ToolRegistry:
|
|
53
|
+
registry = ToolRegistry()
|
|
54
|
+
for tool in [*FILESYSTEM_TOOLS, *CODE_TOOLS, *SHELL_TOOLS]:
|
|
55
|
+
registry.register(tool)
|
|
56
|
+
return registry
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class FileAgent(BaseAgent):
|
|
60
|
+
name = "file"
|
|
61
|
+
description = (
|
|
62
|
+
"Reads, writes, lists, searches, and deletes files in the current project directory, "
|
|
63
|
+
"runs code-quality tools against it (lint, format, run tests, find symbols), and runs "
|
|
64
|
+
"arbitrary shell commands in it."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
router: FallbackRouter,
|
|
70
|
+
tool_registry: ToolRegistry,
|
|
71
|
+
confirm: ConfirmFn = interactive_confirm,
|
|
72
|
+
) -> None:
|
|
73
|
+
super().__init__(router)
|
|
74
|
+
self._tools = tool_registry
|
|
75
|
+
self._confirm = confirm
|
|
76
|
+
catalog_lines = "\n".join(f"- {t.name}: {t.description}" for t in tool_registry.by_tag(self.name))
|
|
77
|
+
self.system_prompt = (
|
|
78
|
+
"You are Astra's file agent. Given a developer instruction, choose exactly one tool "
|
|
79
|
+
"from the catalog and the arguments to call it with. Reply with ONLY a single JSON "
|
|
80
|
+
'object: {"tool": "<tool_name>", "args": {"<arg>": "<value>", ...}}. Paths are always '
|
|
81
|
+
"relative to the current project directory. Omit an arg entirely rather than "
|
|
82
|
+
"inventing a value you weren't given.\n\n"
|
|
83
|
+
f"Catalog:\n{catalog_lines}"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def run(self, state: AgentState) -> dict[str, Any]:
|
|
87
|
+
updated_plan = list(state.plan)
|
|
88
|
+
events = state.events
|
|
89
|
+
total_cost = state.total_cost_usd
|
|
90
|
+
root = Path.cwd().resolve()
|
|
91
|
+
|
|
92
|
+
for i, step in enumerate(updated_plan):
|
|
93
|
+
if step.agent != self.name or step.status != "pending":
|
|
94
|
+
continue
|
|
95
|
+
new_step, cost, events = self._execute_step(step, root, events, state.assume_yes)
|
|
96
|
+
updated_plan[i] = new_step
|
|
97
|
+
total_cost += cost
|
|
98
|
+
|
|
99
|
+
return {"plan": updated_plan, "events": events, "total_cost_usd": total_cost}
|
|
100
|
+
|
|
101
|
+
def _execute_step(
|
|
102
|
+
self, step: PlanStep, root: Path, events: list[Event], assume_yes: bool
|
|
103
|
+
) -> tuple[PlanStep, float, list[Event]]:
|
|
104
|
+
selection_response = self.complete(step.instruction, temperature=0.0)
|
|
105
|
+
cost = selection_response.estimated_cost_usd
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
tool_name, args = self._parse_selection(selection_response)
|
|
109
|
+
except ValueError as exc:
|
|
110
|
+
events = record(events, self.name, f"could not parse tool selection: {exc}")
|
|
111
|
+
return step.model_copy(update={"status": "failed", "result": str(exc)}), cost, events
|
|
112
|
+
|
|
113
|
+
tool = self._tools.get(tool_name)
|
|
114
|
+
if tool is None:
|
|
115
|
+
error = f"selected unknown file tool: {tool_name}"
|
|
116
|
+
events = record(events, self.name, error)
|
|
117
|
+
return step.model_copy(update={"status": "failed", "result": error}), cost, events
|
|
118
|
+
|
|
119
|
+
if tool.tags & _AMBIENT_ROOT_TAGS:
|
|
120
|
+
escaped = False
|
|
121
|
+
call_kwargs: dict[str, Any] = {"root": root, **args}
|
|
122
|
+
else:
|
|
123
|
+
call_kwargs, escaped = self._resolve_paths(root, args)
|
|
124
|
+
|
|
125
|
+
is_risky = "confirm-required" in tool.tags or escaped
|
|
126
|
+
reason = "confirm-required" if "confirm-required" in tool.tags else "path escapes project root"
|
|
127
|
+
|
|
128
|
+
if is_risky and not assume_yes:
|
|
129
|
+
approved = self._confirm(f"Allow risky operation ({tool_name}, {reason}) with args={args}?")
|
|
130
|
+
if not approved:
|
|
131
|
+
message = f"cancelled by user (risky operation not confirmed): {tool_name}"
|
|
132
|
+
events = record(events, self.name, message)
|
|
133
|
+
return step.model_copy(update={"status": "failed", "result": message}), cost, events
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
result = tool.func(**call_kwargs)
|
|
137
|
+
except Exception as exc: # LLM-supplied tool args are untrusted input; never crash the graph
|
|
138
|
+
error = f"file agent call to {tool_name} failed: {exc}"
|
|
139
|
+
events = record(events, self.name, error)
|
|
140
|
+
return step.model_copy(update={"status": "failed", "result": error}), cost, events
|
|
141
|
+
|
|
142
|
+
output = result.output or result.error or "(no output)"
|
|
143
|
+
if is_risky:
|
|
144
|
+
output = f"⚠️ risky operation ({tool_name}, {reason}): {output}"
|
|
145
|
+
events = record(events, self.name, f"risky operation executed: {tool_name}")
|
|
146
|
+
events = record(events, self.name, f"executed step {step.step_id} via {tool_name}")
|
|
147
|
+
|
|
148
|
+
status = "done" if result.success else "failed"
|
|
149
|
+
return step.model_copy(update={"status": status, "result": output}), cost, events
|
|
150
|
+
|
|
151
|
+
def _resolve_paths(self, root: Path, args: dict[str, Any]) -> tuple[dict[str, Any], bool]:
|
|
152
|
+
resolved: dict[str, Any] = dict(args)
|
|
153
|
+
escaped = False
|
|
154
|
+
for key in _PATH_ARGS & resolved.keys():
|
|
155
|
+
value = resolved[key]
|
|
156
|
+
if not isinstance(value, str) or not value:
|
|
157
|
+
continue
|
|
158
|
+
candidate = Path(value)
|
|
159
|
+
absolute = (candidate if candidate.is_absolute() else root / candidate).resolve()
|
|
160
|
+
if absolute != root and root not in absolute.parents:
|
|
161
|
+
escaped = True
|
|
162
|
+
resolved[key] = absolute
|
|
163
|
+
return resolved, escaped
|
|
164
|
+
|
|
165
|
+
def _parse_selection(self, response: LLMResponse) -> tuple[str, dict[str, Any]]:
|
|
166
|
+
raw = _strip_code_fence(response.content)
|
|
167
|
+
try:
|
|
168
|
+
parsed = json.loads(raw)
|
|
169
|
+
except json.JSONDecodeError as exc:
|
|
170
|
+
raise ValueError(f"file agent produced non-JSON tool selection: {raw!r}") from exc
|
|
171
|
+
|
|
172
|
+
tool_name = parsed.get("tool")
|
|
173
|
+
args = parsed.get("args") or {}
|
|
174
|
+
if not isinstance(tool_name, str) or not isinstance(args, dict):
|
|
175
|
+
raise ValueError(f"file agent produced a malformed tool selection: {parsed!r}")
|
|
176
|
+
return tool_name, dict(args)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Git Agent: translates an instruction into one git tool call, executes it, reports back.
|
|
2
|
+
|
|
3
|
+
Three extra behaviors beyond plain tool dispatch:
|
|
4
|
+
- **Commit-message generation**: if a plan step wants a commit but doesn't supply a message,
|
|
5
|
+
the agent looks at the staged diff and asks the LLM for a concise one before committing.
|
|
6
|
+
- **Risky-change detection**: if the selected tool is tagged "confirm-required" (touches a
|
|
7
|
+
remote or can discard/rewrite history), the result is prefixed with a warning and the event
|
|
8
|
+
log records it.
|
|
9
|
+
- **The interactive confirm gate** (Phase 9, §8 of the architecture doc): a confirm-required
|
|
10
|
+
tool call is blocked on `self._confirm` before it runs, unless `state.assume_yes` is set
|
|
11
|
+
(`astra run --yes`). Declining cancels the step without ever calling the tool — same gate
|
|
12
|
+
`FileAgent` applies to its own confirm-required/sandbox-escaping tools.
|
|
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 LLMMessage, LLMResponse
|
|
26
|
+
from astra.llm.router import FallbackRouter
|
|
27
|
+
from astra.tools.registry import ToolRegistry
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _strip_code_fence(text: str) -> str:
|
|
31
|
+
text = text.strip()
|
|
32
|
+
if text.startswith("```"):
|
|
33
|
+
lines = text.splitlines()
|
|
34
|
+
lines = lines[1:] if lines else lines
|
|
35
|
+
if lines and lines[-1].strip().startswith("```"):
|
|
36
|
+
lines = lines[:-1]
|
|
37
|
+
text = "\n".join(lines).strip()
|
|
38
|
+
return text
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class GitAgent(BaseAgent):
|
|
42
|
+
name = "git"
|
|
43
|
+
description = (
|
|
44
|
+
"Performs git operations against the repository in the current directory: status, "
|
|
45
|
+
"diff, log, blame, branch, commit, push, pull, merge, stash, and checkout."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
router: FallbackRouter,
|
|
51
|
+
tool_registry: ToolRegistry,
|
|
52
|
+
confirm: ConfirmFn = interactive_confirm,
|
|
53
|
+
) -> None:
|
|
54
|
+
super().__init__(router)
|
|
55
|
+
self._tools = tool_registry
|
|
56
|
+
self._confirm = confirm
|
|
57
|
+
catalog_lines = "\n".join(f"- {t.name}: {t.description}" for t in tool_registry.by_tag(self.name))
|
|
58
|
+
self.system_prompt = (
|
|
59
|
+
"You are Astra's git agent. Given a developer instruction, choose exactly one tool "
|
|
60
|
+
"from the catalog and the arguments to call it with. Reply with ONLY a single JSON "
|
|
61
|
+
'object: {"tool": "<tool_name>", "args": {"<arg>": "<value>", ...}}. Omit an arg '
|
|
62
|
+
"entirely rather than inventing a value you weren't given — in particular, omit "
|
|
63
|
+
'"message" for git_commit if no commit message was specified.\n\n'
|
|
64
|
+
f"Catalog:\n{catalog_lines}"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def run(self, state: AgentState) -> dict[str, Any]:
|
|
68
|
+
updated_plan = list(state.plan)
|
|
69
|
+
events = state.events
|
|
70
|
+
total_cost = state.total_cost_usd
|
|
71
|
+
repo_path = Path.cwd()
|
|
72
|
+
|
|
73
|
+
for i, step in enumerate(updated_plan):
|
|
74
|
+
if step.agent != self.name or step.status != "pending":
|
|
75
|
+
continue
|
|
76
|
+
new_step, cost, events = self._execute_step(step, repo_path, events, state.assume_yes)
|
|
77
|
+
updated_plan[i] = new_step
|
|
78
|
+
total_cost += cost
|
|
79
|
+
|
|
80
|
+
return {"plan": updated_plan, "events": events, "total_cost_usd": total_cost}
|
|
81
|
+
|
|
82
|
+
def _execute_step(
|
|
83
|
+
self, step: PlanStep, repo_path: Path, events: list[Event], assume_yes: bool
|
|
84
|
+
) -> tuple[PlanStep, float, list[Event]]:
|
|
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 git tool: {tool_name}"
|
|
97
|
+
events = record(events, self.name, error)
|
|
98
|
+
return step.model_copy(update={"status": "failed", "result": error}), cost, events
|
|
99
|
+
|
|
100
|
+
if tool_name == "git_commit" and not args.get("message"):
|
|
101
|
+
message, message_cost = self._generate_commit_message(repo_path)
|
|
102
|
+
args["message"] = message
|
|
103
|
+
cost += message_cost
|
|
104
|
+
|
|
105
|
+
is_risky = "confirm-required" in tool.tags
|
|
106
|
+
if is_risky and not assume_yes:
|
|
107
|
+
approved = self._confirm(f"Allow risky git operation ({tool_name}) with args={args}?")
|
|
108
|
+
if not approved:
|
|
109
|
+
message = f"cancelled by user (risky operation not confirmed): {tool_name}"
|
|
110
|
+
events = record(events, self.name, message)
|
|
111
|
+
return step.model_copy(update={"status": "failed", "result": message}), cost, events
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
result = tool.func(repo_path=repo_path, **args)
|
|
115
|
+
except Exception as exc: # LLM-supplied tool args are untrusted input; never crash the graph
|
|
116
|
+
error = f"git agent call to {tool_name} failed: {exc}"
|
|
117
|
+
events = record(events, self.name, error)
|
|
118
|
+
return step.model_copy(update={"status": "failed", "result": error}), cost, events
|
|
119
|
+
|
|
120
|
+
output = result.output or result.error or "(no output)"
|
|
121
|
+
if is_risky:
|
|
122
|
+
output = f"⚠️ risky operation ({tool_name}): {output}"
|
|
123
|
+
events = record(events, self.name, f"risky operation executed: {tool_name}")
|
|
124
|
+
events = record(events, self.name, f"executed step {step.step_id} via {tool_name}")
|
|
125
|
+
|
|
126
|
+
status = "done" if result.success else "failed"
|
|
127
|
+
return step.model_copy(update={"status": status, "result": output}), cost, events
|
|
128
|
+
|
|
129
|
+
def _parse_selection(self, response: LLMResponse) -> tuple[str, dict[str, Any]]:
|
|
130
|
+
raw = _strip_code_fence(response.content)
|
|
131
|
+
try:
|
|
132
|
+
parsed = json.loads(raw)
|
|
133
|
+
except json.JSONDecodeError as exc:
|
|
134
|
+
raise ValueError(f"git agent produced non-JSON tool selection: {raw!r}") from exc
|
|
135
|
+
|
|
136
|
+
tool_name = parsed.get("tool")
|
|
137
|
+
args = parsed.get("args") or {}
|
|
138
|
+
if not isinstance(tool_name, str) or not isinstance(args, dict):
|
|
139
|
+
raise ValueError(f"git agent produced a malformed tool selection: {parsed!r}")
|
|
140
|
+
return tool_name, dict(args)
|
|
141
|
+
|
|
142
|
+
def _generate_commit_message(self, repo_path: Path) -> tuple[str, float]:
|
|
143
|
+
diff_tool = self._tools.get("git_diff")
|
|
144
|
+
assert diff_tool is not None
|
|
145
|
+
diff_result = diff_tool.func(repo_path=repo_path, staged=True)
|
|
146
|
+
diff_text = diff_result.output or "(no staged diff available)"
|
|
147
|
+
|
|
148
|
+
response = self._router.complete(
|
|
149
|
+
[
|
|
150
|
+
LLMMessage(
|
|
151
|
+
role="system",
|
|
152
|
+
content=(
|
|
153
|
+
"You write concise, conventional git commit messages (one line, "
|
|
154
|
+
"imperative mood, no trailing period) from a diff. Reply with only "
|
|
155
|
+
"the commit message."
|
|
156
|
+
),
|
|
157
|
+
),
|
|
158
|
+
LLMMessage(role="user", content=diff_text[:4000]),
|
|
159
|
+
],
|
|
160
|
+
temperature=0.1,
|
|
161
|
+
)
|
|
162
|
+
message = response.content.strip().strip('"') or "update"
|
|
163
|
+
return message, response.estimated_cost_usd
|