chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
"""aisuite coding agent CLI."""
|
||||
|
||||
from .app import CodeCli
|
||||
from .config import CliConfig, DEFAULT_ALLOWED_COMMANDS
|
||||
|
||||
__all__ = ["CliConfig", "CodeCli", "DEFAULT_ALLOWED_COMMANDS"]
|
||||
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import aisuite as ai
|
||||
|
||||
from .config import CliConfig
|
||||
|
||||
|
||||
def build_agent(config: CliConfig) -> ai.Agent:
|
||||
tools = _coding_tools(config)
|
||||
if config.enable_reviewer:
|
||||
tools.append(
|
||||
ai.agent_tool(
|
||||
build_reviewer_agent(config),
|
||||
name="review_changes",
|
||||
description=(
|
||||
"Ask the reviewer subagent to inspect the current work for "
|
||||
"bugs, regressions, missing tests, and maintainability risks."
|
||||
),
|
||||
)
|
||||
)
|
||||
return ai.Agent(
|
||||
name="aisuite_code",
|
||||
model=config.model,
|
||||
instructions=_main_agent_instructions(config),
|
||||
tools=tools,
|
||||
tags=["cli", "code"],
|
||||
metadata={"app": "aisuite_code_cli"},
|
||||
)
|
||||
|
||||
|
||||
def build_reviewer_agent(config: CliConfig) -> ai.Agent:
|
||||
return ai.Agent(
|
||||
name="reviewer",
|
||||
model=config.model,
|
||||
instructions=(
|
||||
"You are a code reviewer subagent for aisuite-code. Review the "
|
||||
"user-described work and available project files for correctness, "
|
||||
"regressions, missing tests, security risks, and maintainability "
|
||||
"issues. Do not edit files. Do not run commands. Prefer specific "
|
||||
"findings with file paths and line references when available. If "
|
||||
"you find no material issues, say that clearly and mention any "
|
||||
"residual test gaps. Keep the response concise."
|
||||
),
|
||||
tools=ai.toolkits.files(root=config.cwd, allow_write=False),
|
||||
tags=["cli", "code", "reviewer"],
|
||||
metadata={"app": "aisuite_code_cli", "role": "reviewer"},
|
||||
)
|
||||
|
||||
|
||||
def _coding_tools(config: CliConfig) -> list:
|
||||
return [
|
||||
*ai.toolkits.files(
|
||||
root=config.cwd,
|
||||
allow_write=config.allow_write,
|
||||
),
|
||||
*ai.toolkits.git(root=config.cwd),
|
||||
*ai.toolkits.shell(
|
||||
cwd=config.cwd,
|
||||
allowed_commands=config.allowed_commands,
|
||||
allow_all=config.allow_shell_all,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _main_agent_instructions(config: CliConfig) -> str:
|
||||
reviewer_instruction = (
|
||||
" When the user asks for a review, or after substantial edits when a "
|
||||
"second opinion would help, call review_changes with a concise summary "
|
||||
"of the work, changed files, and verification results."
|
||||
if config.enable_reviewer
|
||||
else ""
|
||||
)
|
||||
return (
|
||||
"You are aisuite-code, a concise local coding agent. Work inside the "
|
||||
"configured project directory. Inspect files before editing. Prefer "
|
||||
"small, focused changes. Use replace_in_file for exact, focused text "
|
||||
"replacements, apply_patch for multi-line targeted edits, and "
|
||||
"write_file for new or full-file replacements. apply_patch accepts "
|
||||
"only this Codex-style envelope: *** Begin Patch, then operations "
|
||||
"like *** Update File: path, @@, context lines prefixed with a space, "
|
||||
"removed lines prefixed with -, added lines prefixed with +, and "
|
||||
"*** End Patch. Example: *** Begin Patch\\n*** Update File: app.py\\n"
|
||||
"@@\\n-print('old')\\n+print('new')\\n*** End Patch. "
|
||||
"apply_unified_diff is different: it accepts standard unified diffs "
|
||||
"with --- a/path, +++ b/path, and numbered @@ -x,y +x,y @@ hunks; do "
|
||||
"not send apply_patch envelopes to apply_unified_diff. Use shell "
|
||||
"commands to create projects, run builds, and verify behavior when "
|
||||
"useful. Do not use shell heredocs or redirection for file edits. "
|
||||
"Explain command results and summarize changed files. Do not attempt "
|
||||
"destructive actions unless the user explicitly asks and the approval prompt "
|
||||
f"allows it.{reviewer_instruction}"
|
||||
)
|
||||
@@ -0,0 +1,280 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Optional, TextIO
|
||||
|
||||
import aisuite as ai
|
||||
|
||||
from .agent import build_agent
|
||||
from .approval import ApprovalController
|
||||
from .config import CliConfig
|
||||
from .rendering import print_steps
|
||||
|
||||
|
||||
class CodeCli:
|
||||
def __init__(
|
||||
self,
|
||||
config: CliConfig,
|
||||
*,
|
||||
input_stream: TextIO = sys.stdin,
|
||||
output_stream: TextIO = sys.stdout,
|
||||
):
|
||||
self.config = config
|
||||
self.input_stream = input_stream
|
||||
self.output_stream = output_stream
|
||||
self.approvals = ApprovalController(
|
||||
input_stream=input_stream,
|
||||
output_stream=output_stream,
|
||||
)
|
||||
self.trace_sinks = [ai.tracing.LocalTraceSink(config.trace_file)]
|
||||
self.artifact_store = ai.FileArtifactStore(config.artifact_root)
|
||||
if config.trace_http:
|
||||
self.trace_sinks.append(ai.tracing.HttpTraceSink(config.trace_http))
|
||||
self.agent = build_agent(config)
|
||||
self.result: Optional[ai.RunResult] = None
|
||||
self.viewer: Optional[ai.tracing.ViewerServer] = None
|
||||
|
||||
def run(self) -> None:
|
||||
self._print_header()
|
||||
if self.config.start_viewer:
|
||||
self._start_viewer()
|
||||
try:
|
||||
while True:
|
||||
print("\nYou > ", end="", file=self.output_stream, flush=True)
|
||||
user_input = self.input_stream.readline()
|
||||
if not user_input:
|
||||
print("", file=self.output_stream)
|
||||
break
|
||||
user_input = user_input.strip()
|
||||
if not user_input:
|
||||
continue
|
||||
if user_input.startswith("/"):
|
||||
if self._handle_command(user_input):
|
||||
break
|
||||
continue
|
||||
self._run_agent(user_input)
|
||||
finally:
|
||||
if self.viewer is not None:
|
||||
self.viewer.stop()
|
||||
|
||||
def _run_agent(self, user_input: str) -> None:
|
||||
print("\nWorking...", file=self.output_stream)
|
||||
try:
|
||||
if self.result is None:
|
||||
self.result = ai.Runner.run_sync(
|
||||
self.agent,
|
||||
user_input,
|
||||
max_turns=self.config.max_turns,
|
||||
run_name="code_cli_turn",
|
||||
group_id="aisuite-code-cli",
|
||||
trace_sinks=self.trace_sinks,
|
||||
tool_policy=self.approvals,
|
||||
artifact_store=self.artifact_store,
|
||||
)
|
||||
else:
|
||||
self.result = ai.Runner.continue_sync(
|
||||
self.result,
|
||||
user_input,
|
||||
max_turns=self.config.max_turns,
|
||||
trace_sinks=self.trace_sinks,
|
||||
tool_policy=self.approvals,
|
||||
artifact_store=self.artifact_store,
|
||||
)
|
||||
except Exception as exc:
|
||||
self._print_error(exc)
|
||||
return
|
||||
|
||||
print_steps(self.result, self.output_stream)
|
||||
print("\nAssistant", file=self.output_stream)
|
||||
final_output = str(self.result.final_output or "").strip()
|
||||
if final_output:
|
||||
for line in final_output.splitlines():
|
||||
print(f" {line}", file=self.output_stream)
|
||||
else:
|
||||
print(" (no final output)", file=self.output_stream)
|
||||
self._print_trace_hint()
|
||||
|
||||
def _handle_command(self, command: str) -> bool:
|
||||
if command in {"/exit", "/quit"}:
|
||||
return True
|
||||
if command == "/help":
|
||||
self._print_help()
|
||||
return False
|
||||
if command == "/viewer":
|
||||
print("\nViewer", file=self.output_stream)
|
||||
if self.viewer is not None:
|
||||
print(f" open: {self.viewer.url}", file=self.output_stream)
|
||||
print(" start here: /viewer start", file=self.output_stream)
|
||||
print(
|
||||
" run separately: python -m aisuite.tracing.viewer "
|
||||
f"--trace-file {self.config.trace_file} "
|
||||
f"--artifact-root {self.config.artifact_root}",
|
||||
file=self.output_stream,
|
||||
)
|
||||
print(
|
||||
" live endpoint: "
|
||||
f"{self.config.trace_http or '(pass --trace-http to stream events)'}",
|
||||
file=self.output_stream,
|
||||
)
|
||||
return False
|
||||
if command == "/viewer start":
|
||||
self._start_viewer()
|
||||
return False
|
||||
if command == "/status":
|
||||
self._print_status()
|
||||
return False
|
||||
if command == "/examples":
|
||||
self._print_examples()
|
||||
return False
|
||||
if command == "/last":
|
||||
self._print_last_turn()
|
||||
return False
|
||||
if command == "/clear":
|
||||
self.result = None
|
||||
print("\nConversation state cleared.", file=self.output_stream)
|
||||
return False
|
||||
print(f"\nUnknown command: {command}", file=self.output_stream)
|
||||
self._print_help()
|
||||
return False
|
||||
|
||||
def _start_viewer(self) -> None:
|
||||
if self.viewer is None:
|
||||
self.viewer = ai.tracing.start_viewer(
|
||||
self.config.trace_file,
|
||||
port=0,
|
||||
artifact_root=self.config.artifact_root,
|
||||
)
|
||||
print(f"\nViewer: {self.viewer.url}", file=self.output_stream)
|
||||
|
||||
def _print_status(self) -> None:
|
||||
print(f"\nmodel: {self.config.model}", file=self.output_stream)
|
||||
print(f"cwd: {self.config.cwd}", file=self.output_stream)
|
||||
print(f"trace_file: {self.config.trace_file}", file=self.output_stream)
|
||||
print(f"artifact_root: {self.config.artifact_root}", file=self.output_stream)
|
||||
print(f"trace_http: {self.config.trace_http or '-'}", file=self.output_stream)
|
||||
print(f"write_tools: {self.config.allow_write}", file=self.output_stream)
|
||||
print(f"reviewer: {self.config.enable_reviewer}", file=self.output_stream)
|
||||
print(
|
||||
f"allowed_commands: {', '.join(self.config.allowed_commands) or 'all'}",
|
||||
file=self.output_stream,
|
||||
)
|
||||
|
||||
def _print_header(self) -> None:
|
||||
print("aisuite-code", file=self.output_stream)
|
||||
print("-" * 56, file=self.output_stream)
|
||||
print("Session", file=self.output_stream)
|
||||
print(f" model: {self.config.model}", file=self.output_stream)
|
||||
print(f" cwd: {self.config.cwd}", file=self.output_stream)
|
||||
print(
|
||||
f" tools: writes {'on' if self.config.allow_write else 'off'} · "
|
||||
f"shell {'all' if self.config.allow_shell_all else 'limited'} · "
|
||||
f"reviewer {'on' if self.config.enable_reviewer else 'off'}",
|
||||
file=self.output_stream,
|
||||
)
|
||||
print(f" traces: {self.config.trace_file}", file=self.output_stream)
|
||||
print(f" artifacts:{self.config.artifact_root}", file=self.output_stream)
|
||||
if self.config.trace_http:
|
||||
print(f" stream: {self.config.trace_http}", file=self.output_stream)
|
||||
print(
|
||||
"\nType /help for commands. Use /viewer start for local traces.",
|
||||
file=self.output_stream,
|
||||
)
|
||||
self._print_starter_prompts()
|
||||
|
||||
def _print_help(self) -> None:
|
||||
print("\nCommands", file=self.output_stream)
|
||||
print(
|
||||
" /viewer show viewer status and command", file=self.output_stream
|
||||
)
|
||||
print(" /viewer start start local viewer server", file=self.output_stream)
|
||||
print(" /status show current configuration", file=self.output_stream)
|
||||
print(" /examples show good starter prompts", file=self.output_stream)
|
||||
print(" /last show last turn details", file=self.output_stream)
|
||||
print(" /clear clear conversation state", file=self.output_stream)
|
||||
print(" /exit quit", file=self.output_stream)
|
||||
self._print_examples()
|
||||
|
||||
def _print_trace_hint(self) -> None:
|
||||
if self.result is None:
|
||||
return
|
||||
print(f"\nTrace: {self.result.trace_id}", file=self.output_stream)
|
||||
if self.viewer is not None:
|
||||
focused_url = f"{self.viewer.url}?embed=1&trace_id={self.result.trace_id}"
|
||||
print(f" focused viewer: {focused_url}", file=self.output_stream)
|
||||
else:
|
||||
print(" start viewer: /viewer start", file=self.output_stream)
|
||||
|
||||
def _print_starter_prompts(self) -> None:
|
||||
print("\nTry", file=self.output_stream)
|
||||
for prompt in (
|
||||
"List files in this directory and tell me what you see.",
|
||||
"Read README.md and summarize the project in 5 bullets.",
|
||||
"Create app.py with add(a, b), then run it.",
|
||||
):
|
||||
print(f" {prompt}", file=self.output_stream)
|
||||
|
||||
def _print_examples(self) -> None:
|
||||
self._print_starter_prompts()
|
||||
print(
|
||||
" Run the focused tests for the files you changed.",
|
||||
file=self.output_stream,
|
||||
)
|
||||
print(
|
||||
" Ask the reviewer subagent to review the current changes.",
|
||||
file=self.output_stream,
|
||||
)
|
||||
|
||||
def _print_last_turn(self) -> None:
|
||||
if self.result is None:
|
||||
print("\nNo turns yet.", file=self.output_stream)
|
||||
return
|
||||
print("\nLast turn", file=self.output_stream)
|
||||
print(f" trace: {self.result.trace_id}", file=self.output_stream)
|
||||
print(f" status: {self.result.status}", file=self.output_stream)
|
||||
print(
|
||||
f" input: {self._compact(self.result.input, 260)}", file=self.output_stream
|
||||
)
|
||||
print(" output:", file=self.output_stream)
|
||||
output = str(self.result.final_output or "").strip() or "(no final output)"
|
||||
for line in output.splitlines():
|
||||
print(f" {line}", file=self.output_stream)
|
||||
if not self.result.steps:
|
||||
return
|
||||
print(" steps:", file=self.output_stream)
|
||||
for step in self.result.steps:
|
||||
print(
|
||||
f" - {step.type}: {step.name or '-'} ({step.id})",
|
||||
file=self.output_stream,
|
||||
)
|
||||
if step.data:
|
||||
print(f" {self._compact(step.data, 700)}", file=self.output_stream)
|
||||
|
||||
def _compact(self, value: object, max_chars: int = 500) -> str:
|
||||
if isinstance(value, str):
|
||||
rendered = value
|
||||
else:
|
||||
rendered = json.dumps(value, sort_keys=True, default=str)
|
||||
rendered = rendered.replace("\n", "\\n")
|
||||
if len(rendered) <= max_chars:
|
||||
return rendered
|
||||
return rendered[: max_chars - 3] + "..."
|
||||
|
||||
def _print_error(self, exc: Exception) -> None:
|
||||
message = str(exc)
|
||||
print(f"\nError: {message}", file=self.output_stream)
|
||||
if "No module named 'openai'" in message:
|
||||
print(
|
||||
" Install CLI dependencies from cli/py/aisuite-code-cli with "
|
||||
"python3 -m poetry install.",
|
||||
file=self.output_stream,
|
||||
)
|
||||
if "OPENAI_API_KEY" in message or "API key" in message:
|
||||
print(
|
||||
" Set OPENAI_API_KEY in your shell, or source the repo .env file.",
|
||||
file=self.output_stream,
|
||||
)
|
||||
print(
|
||||
" Use /status to inspect the active model, cwd, and trace files.",
|
||||
file=self.output_stream,
|
||||
)
|
||||
@@ -0,0 +1,212 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Any, TextIO
|
||||
|
||||
import aisuite as ai
|
||||
|
||||
|
||||
class ApprovalController:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
input_stream: TextIO = sys.stdin,
|
||||
output_stream: TextIO = sys.stdout,
|
||||
):
|
||||
self.input_stream = input_stream
|
||||
self.output_stream = output_stream
|
||||
self.always_allow_tools: set[str] = set()
|
||||
self.always_allow_commands: set[str] = set()
|
||||
|
||||
def evaluate(self, context: ai.ToolPolicyContext) -> ai.ToolPolicyDecision:
|
||||
metadata = context.tool_metadata
|
||||
command = _exact_command(context)
|
||||
if command and command in self.always_allow_commands:
|
||||
return ai.ToolPolicyDecision(
|
||||
allowed=True,
|
||||
reason="command allowed for session",
|
||||
)
|
||||
if context.tool_name in self.always_allow_tools:
|
||||
return ai.ToolPolicyDecision(
|
||||
allowed=True,
|
||||
reason="tool allowed for session",
|
||||
)
|
||||
if metadata is None or not metadata.requires_approval:
|
||||
return ai.ToolPolicyDecision(allowed=True, reason="low risk")
|
||||
|
||||
self._print_approval_request(context)
|
||||
choice = self._read_choice()
|
||||
if choice == "c" and command:
|
||||
self.always_allow_commands.add(command)
|
||||
return ai.ToolPolicyDecision(
|
||||
allowed=True,
|
||||
reason="command allowed for session",
|
||||
)
|
||||
if choice == "a":
|
||||
self.always_allow_tools.add(context.tool_name)
|
||||
return ai.ToolPolicyDecision(
|
||||
allowed=True,
|
||||
reason="tool allowed for session",
|
||||
)
|
||||
if choice == "y":
|
||||
return ai.ToolPolicyDecision(allowed=True, reason="approved by user")
|
||||
return ai.ToolPolicyDecision(allowed=False, reason="denied by user")
|
||||
|
||||
def _print_approval_request(self, context: ai.ToolPolicyContext) -> None:
|
||||
metadata = context.tool_metadata
|
||||
action = _approval_action(context)
|
||||
effect = _approval_effect(context)
|
||||
preview = _approval_preview(context)
|
||||
risk = metadata.risk_level if metadata else "unknown"
|
||||
category = metadata.category if metadata and metadata.category else "-"
|
||||
|
||||
print("\nPermission required", file=self.output_stream)
|
||||
print(" Action", file=self.output_stream)
|
||||
print(f" {action}", file=self.output_stream)
|
||||
print(" Risk", file=self.output_stream)
|
||||
print(f" {risk} · {category}", file=self.output_stream)
|
||||
print(" Effect", file=self.output_stream)
|
||||
print(f" {effect}", file=self.output_stream)
|
||||
print(" Preview", file=self.output_stream)
|
||||
for line in preview:
|
||||
print(f" {line}", file=self.output_stream)
|
||||
prompt = " Allow? [y] once [n] deny [a] always this tool"
|
||||
if _exact_command(context):
|
||||
prompt += " [c] always this command"
|
||||
print(prompt, file=self.output_stream)
|
||||
|
||||
def _read_choice(self) -> str:
|
||||
print("> ", end="", file=self.output_stream, flush=True)
|
||||
choice = self.input_stream.readline().strip().lower()
|
||||
if choice in {"y", "yes"}:
|
||||
return "y"
|
||||
if choice in {"a", "always"}:
|
||||
return "a"
|
||||
if choice in {"c", "command"}:
|
||||
return "c"
|
||||
return "n"
|
||||
|
||||
|
||||
def _approval_action(context: ai.ToolPolicyContext) -> str:
|
||||
args = context.arguments or {}
|
||||
if context.tool_name == "run_shell":
|
||||
command = args.get("command") or "-"
|
||||
return f"run shell command: {_single_line(str(command), 140)}"
|
||||
if context.tool_name == "write_file":
|
||||
return f"write file: {args.get('path', '-')}"
|
||||
if context.tool_name == "replace_in_file":
|
||||
return f"edit file: {args.get('path', '-')}"
|
||||
if context.tool_name == "apply_patch":
|
||||
return "apply Codex-style patch"
|
||||
if context.tool_name == "apply_unified_diff":
|
||||
return "apply unified diff"
|
||||
if context.tool_name == "review_changes":
|
||||
return "invoke reviewer subagent"
|
||||
return f"run tool: {context.tool_name}"
|
||||
|
||||
|
||||
def _approval_effect(context: ai.ToolPolicyContext) -> str:
|
||||
args = context.arguments or {}
|
||||
if context.tool_name == "run_shell":
|
||||
return "Executes a command in the configured workspace."
|
||||
if context.tool_name == "write_file":
|
||||
overwrite = args.get("overwrite", True)
|
||||
mode = "create or overwrite" if overwrite else "create if missing"
|
||||
return f"May {mode} a file under the configured workspace."
|
||||
if context.tool_name == "replace_in_file":
|
||||
expected = args.get("expected_replacements", 1)
|
||||
return f"Replaces exact text in one file; expected replacements: {expected}."
|
||||
if context.tool_name == "apply_patch":
|
||||
return "Applies one or more targeted file edits from a Codex-style patch."
|
||||
if context.tool_name == "apply_unified_diff":
|
||||
return "Applies one or more file edits from a unified diff."
|
||||
if context.tool_name == "review_changes":
|
||||
return "Runs a read-only reviewer subagent and records a child trace."
|
||||
return "Runs the tool with the summarized arguments below."
|
||||
|
||||
|
||||
def _approval_preview(context: ai.ToolPolicyContext) -> list[str]:
|
||||
args = context.arguments or {}
|
||||
if context.tool_name == "run_shell":
|
||||
return [f"command: {_summarize_argument(args.get('command'), max_chars=260)}"]
|
||||
if context.tool_name == "write_file":
|
||||
return [
|
||||
f"path: {args.get('path', '-')}",
|
||||
f"content: {_text_stats(args.get('content'))}",
|
||||
]
|
||||
if context.tool_name == "replace_in_file":
|
||||
return [
|
||||
f"path: {args.get('path', '-')}",
|
||||
f"old: {_text_stats(args.get('old'))}",
|
||||
f"new: {_text_stats(args.get('new'))}",
|
||||
]
|
||||
if context.tool_name == "apply_patch":
|
||||
return [f"patch: {_text_stats(args.get('patch'))}"]
|
||||
if context.tool_name == "apply_unified_diff":
|
||||
return [f"diff: {_text_stats(args.get('diff'))}"]
|
||||
if context.tool_name == "review_changes":
|
||||
return [f"input: {_summarize_argument(args.get('input'), max_chars=260)}"]
|
||||
if not args:
|
||||
return ["no arguments"]
|
||||
return [f"{key}: {_summarize_argument(value)}" for key, value in args.items()]
|
||||
|
||||
|
||||
def _summarize_argument(value: Any, max_chars: int = 700) -> str:
|
||||
if _is_artifact_ref(value):
|
||||
ref = value.get("artifact_ref") or {}
|
||||
preview = value.get("preview")
|
||||
pieces = [
|
||||
f"artifact {ref.get('artifact_id')}",
|
||||
(
|
||||
f"{ref.get('size_bytes')} bytes"
|
||||
if ref.get("size_bytes") is not None
|
||||
else None
|
||||
),
|
||||
]
|
||||
if preview:
|
||||
pieces.append(f"preview={_single_line(str(preview), 180)}")
|
||||
return " · ".join(piece for piece in pieces if piece)
|
||||
if isinstance(value, str):
|
||||
lines = len(value.splitlines())
|
||||
if len(value) <= max_chars:
|
||||
return value
|
||||
return (
|
||||
f"{len(value)} chars · {lines} lines · "
|
||||
f"preview={_single_line(value, 360)}"
|
||||
)
|
||||
try:
|
||||
rendered = json.dumps(value, sort_keys=True)
|
||||
except TypeError:
|
||||
rendered = str(value)
|
||||
if len(rendered) <= max_chars:
|
||||
return rendered
|
||||
return f"{len(rendered)} chars · preview={_single_line(rendered, 360)}"
|
||||
|
||||
|
||||
def _is_artifact_ref(value: Any) -> bool:
|
||||
return (
|
||||
isinstance(value, dict)
|
||||
and value.get("type") == "artifact_ref"
|
||||
and isinstance(value.get("artifact_ref"), dict)
|
||||
)
|
||||
|
||||
|
||||
def _single_line(value: str, max_chars: int) -> str:
|
||||
rendered = value.replace("\n", "\\n")
|
||||
if len(rendered) <= max_chars:
|
||||
return rendered
|
||||
return rendered[: max_chars - 3] + "..."
|
||||
|
||||
|
||||
def _exact_command(context: ai.ToolPolicyContext) -> str | None:
|
||||
if context.tool_name != "run_shell":
|
||||
return None
|
||||
command = context.arguments.get("command") if context.arguments else None
|
||||
return command if isinstance(command, str) and command else None
|
||||
|
||||
|
||||
def _text_stats(value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
return "-"
|
||||
return f"{len(value)} chars · {len(value.splitlines())} lines"
|
||||
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
DEFAULT_ALLOWED_COMMANDS = [
|
||||
"npm",
|
||||
"npx",
|
||||
"node",
|
||||
"python",
|
||||
"python3",
|
||||
"pytest",
|
||||
"git status",
|
||||
"git diff",
|
||||
"git log",
|
||||
"mkdir",
|
||||
"ls",
|
||||
"pwd",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CliConfig:
|
||||
model: str
|
||||
cwd: Path
|
||||
trace_file: Path
|
||||
artifact_root: Path = field(default_factory=lambda: Path(".aisuite/artifacts"))
|
||||
trace_http: Optional[str] = None
|
||||
allow_write: bool = True
|
||||
allow_shell_all: bool = False
|
||||
allowed_commands: list[str] = field(default_factory=list)
|
||||
max_turns: int = 8
|
||||
start_viewer: bool = False
|
||||
enable_reviewer: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.cwd = self.cwd.expanduser().resolve()
|
||||
if not self.trace_file.is_absolute():
|
||||
self.trace_file = self.cwd / self.trace_file
|
||||
if not self.artifact_root.is_absolute():
|
||||
self.artifact_root = self.cwd / self.artifact_root
|
||||
|
||||
|
||||
def parse_args(argv: Optional[list[str]] = None) -> CliConfig:
|
||||
parser = argparse.ArgumentParser(description="Run the aisuite coding agent CLI.")
|
||||
parser.add_argument("--model", default="openai:gpt-4o-mini")
|
||||
parser.add_argument("--cwd", default=".")
|
||||
parser.add_argument("--trace-file", default=".aisuite/code.jsonl")
|
||||
parser.add_argument("--artifact-root", default=".aisuite/artifacts")
|
||||
parser.add_argument(
|
||||
"--trace-http",
|
||||
default=None,
|
||||
help="Optional viewer /api/events endpoint for live trace streaming.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--read-only",
|
||||
action="store_true",
|
||||
help="Disable file write tools.",
|
||||
)
|
||||
parser.add_argument("--allow-shell-all", action="store_true")
|
||||
parser.add_argument(
|
||||
"--allow-command",
|
||||
action="append",
|
||||
dest="allowed_commands",
|
||||
default=[],
|
||||
help="Command prefix to allow. Can be supplied multiple times.",
|
||||
)
|
||||
parser.add_argument("--max-turns", type=int, default=8)
|
||||
parser.add_argument(
|
||||
"--viewer",
|
||||
action="store_true",
|
||||
help="Start the local viewer when the CLI launches.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-reviewer",
|
||||
action="store_true",
|
||||
help="Disable the default read-only reviewer subagent tool.",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
cwd = Path(args.cwd).expanduser().resolve()
|
||||
trace_file = Path(args.trace_file)
|
||||
if not trace_file.is_absolute():
|
||||
trace_file = cwd / trace_file
|
||||
artifact_root = Path(args.artifact_root)
|
||||
if not artifact_root.is_absolute():
|
||||
artifact_root = cwd / artifact_root
|
||||
return CliConfig(
|
||||
model=args.model,
|
||||
cwd=cwd,
|
||||
trace_file=trace_file,
|
||||
artifact_root=artifact_root,
|
||||
trace_http=args.trace_http,
|
||||
allow_write=not args.read_only,
|
||||
allow_shell_all=args.allow_shell_all,
|
||||
allowed_commands=args.allowed_commands or list(DEFAULT_ALLOWED_COMMANDS),
|
||||
max_turns=args.max_turns,
|
||||
start_viewer=args.viewer,
|
||||
enable_reviewer=not args.no_reviewer,
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .app import CodeCli
|
||||
from .config import parse_args
|
||||
|
||||
|
||||
def main(argv: Optional[list[str]] = None) -> None:
|
||||
config = parse_args(argv)
|
||||
CodeCli(config).run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TextIO
|
||||
|
||||
import aisuite as ai
|
||||
|
||||
WRITE_TOOL_NAMES = {
|
||||
"write_file",
|
||||
"replace_in_file",
|
||||
"apply_patch",
|
||||
"apply_unified_diff",
|
||||
}
|
||||
|
||||
|
||||
def print_steps(result: ai.RunResult, output_stream: TextIO) -> None:
|
||||
tool_steps = [
|
||||
step for step in result.steps if step.type in {"tool_call", "tool_result"}
|
||||
]
|
||||
if not tool_steps:
|
||||
return
|
||||
print("\nActivity", file=output_stream)
|
||||
for step in tool_steps[-10:]:
|
||||
data = step.data
|
||||
if step.type == "tool_call":
|
||||
status = "allowed" if data.get("allowed") else "denied"
|
||||
summary = summarize_tool_arguments(step.name, data.get("arguments"))
|
||||
print(
|
||||
f" tool request: {step.name} · {status}{summary}",
|
||||
file=output_stream,
|
||||
)
|
||||
if data.get("reason"):
|
||||
print(f" reason: {data['reason']}", file=output_stream)
|
||||
elif step.type == "tool_result":
|
||||
result_summary = summarize_result_preview(
|
||||
step.name, data.get("result_preview")
|
||||
)
|
||||
status = data.get("status") or "completed"
|
||||
print(
|
||||
f" tool result: {step.name} · {status}{result_summary}",
|
||||
file=output_stream,
|
||||
)
|
||||
|
||||
|
||||
def summarize_tool_arguments(tool_name: str, value: object) -> str:
|
||||
if not isinstance(value, dict) or not value:
|
||||
return ""
|
||||
if tool_name == "run_shell":
|
||||
command = value.get("command")
|
||||
return f" · {compact_value(command, 180)}" if command else ""
|
||||
if tool_name in WRITE_TOOL_NAMES:
|
||||
path = value.get("path")
|
||||
if path:
|
||||
if tool_name == "write_file" and "content" in value:
|
||||
return f" · {path} · {text_stats(value.get('content'))}"
|
||||
if tool_name == "replace_in_file":
|
||||
return f" · {path} · replace {text_stats(value.get('old'))}"
|
||||
return f" · {path}"
|
||||
if "patch" in value:
|
||||
return f" · patch · {text_stats(value.get('patch'))}"
|
||||
if "diff" in value:
|
||||
return f" · diff · {text_stats(value.get('diff'))}"
|
||||
if tool_name == "review_changes":
|
||||
return " · reviewer subagent"
|
||||
return " · " + compact_value(value, 220)
|
||||
|
||||
|
||||
def summarize_result_preview(tool_name: str, preview: object) -> str:
|
||||
if not preview:
|
||||
return ""
|
||||
try:
|
||||
value = json.loads(preview) if isinstance(preview, str) else preview
|
||||
except json.JSONDecodeError:
|
||||
return " · " + compact_value(preview, 220)
|
||||
|
||||
if isinstance(value, dict) and {"stdout", "stderr", "exit_code"} & set(value):
|
||||
exit_code = value.get("exit_code")
|
||||
stdout = value.get("stdout") or ""
|
||||
stderr = value.get("stderr") or ""
|
||||
parts = [f"exit {exit_code}" if exit_code is not None else None]
|
||||
if stdout:
|
||||
parts.append(f"stdout {text_stats(stdout)}")
|
||||
if stderr:
|
||||
parts.append(f"stderr {text_stats(stderr)}")
|
||||
return " · " + " · ".join(part for part in parts if part)
|
||||
if isinstance(value, dict):
|
||||
if "path" in value and "content" in value:
|
||||
return f" · {value['path']} · {text_stats(value.get('content'))}"
|
||||
changed = value.get("changed_files") or []
|
||||
if changed:
|
||||
files = ", ".join(changed[:3])
|
||||
more = "..." if len(changed) > 3 else ""
|
||||
return f" · {len(changed)} file(s): {files}{more}"
|
||||
return " · " + compact_value(value, 220)
|
||||
|
||||
|
||||
def print_mapping(label: str, value: object, output_stream: TextIO) -> None:
|
||||
if not value:
|
||||
return
|
||||
print(f" {label}:", file=output_stream)
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
print(f" {key}: {compact_value(item)}", file=output_stream)
|
||||
return
|
||||
print(f" {compact_value(value)}", file=output_stream)
|
||||
|
||||
|
||||
def print_result_preview(preview: str, output_stream: TextIO) -> None:
|
||||
try:
|
||||
value = json.loads(preview)
|
||||
except json.JSONDecodeError:
|
||||
print(f" result: {compact_value(preview)}", file=output_stream)
|
||||
return
|
||||
|
||||
if isinstance(value, dict) and {"stdout", "stderr", "exit_code"} & set(value):
|
||||
for key in ("exit_code", "timed_out"):
|
||||
if key in value:
|
||||
print(f" {key}: {value[key]}", file=output_stream)
|
||||
for key in ("stdout", "stderr"):
|
||||
if value.get(key):
|
||||
print(
|
||||
f" {key}: {compact_value(value[key])}",
|
||||
file=output_stream,
|
||||
)
|
||||
return
|
||||
print(f" result: {compact_value(value)}", file=output_stream)
|
||||
|
||||
|
||||
def text_stats(value: object) -> str:
|
||||
if not isinstance(value, str):
|
||||
return "-"
|
||||
line_count = len(value.splitlines())
|
||||
return f"{len(value)} chars, {line_count} lines"
|
||||
|
||||
|
||||
def compact_value(value: object, max_chars: int = 500) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
rendered = value
|
||||
else:
|
||||
rendered = json.dumps(value, sort_keys=True)
|
||||
rendered = rendered.replace("\n", "\\n")
|
||||
if len(rendered) <= max_chars:
|
||||
return rendered
|
||||
return rendered[: max_chars - 3] + "..."
|
||||
Reference in New Issue
Block a user