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
astra/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
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