Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,4 @@ cython_debug/

# Super Powers
.superpowers/
.worktrees/
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ dependencies = [
"pillow==12.2.0",
"cryptography>=42.0",
"keyring>=25.0",
"tree-sitter>=0.23",
"tree-sitter-bash>=0.23",
"tree-sitter>=0.25,<0.26",
"tree-sitter-bash>=0.25,<0.26",
]

[project.optional-dependencies]
Expand Down
2 changes: 1 addition & 1 deletion src/iac_code/acp/slash_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ async def _handle_compact(self, agent_loop) -> str:
async def _handle_clear(self, agent_loop) -> str:
"""Clear the agent_loop conversation history."""
try:
agent_loop.context_manager.reset()
agent_loop.reset()
except Exception as exc:
logger.warning("ACP /clear failed: %s", exc)
return _("Clear failed: {error}").format(error=exc)
Expand Down
47 changes: 47 additions & 0 deletions src/iac_code/agent/agent_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
ToolResultEvent,
ToolUseEndEvent,
ToolUseStartEvent,
Usage,
)


Expand Down Expand Up @@ -69,6 +70,7 @@ def __init__(
cwd: str | None = None,
permission_context: Any = None, # ToolPermissionContext
permission_context_getter: Any = None, # Callable[[], ToolPermissionContext | None]
auto_trigger_skills: list[Any] | None = None,
) -> None:
self._provider_manager = provider_manager
self.system_prompt = system_prompt
Expand All @@ -79,6 +81,8 @@ def __init__(
self._cwd = cwd or os.getcwd()
self._permission_context = permission_context
self._permission_context_getter = permission_context_getter
self._auto_trigger_skills = auto_trigger_skills or []
self._auto_loaded_skills: set[str] = set()
self._current_git_branch: str | None = None

model_name = ""
Expand Down Expand Up @@ -224,6 +228,7 @@ async def run_streaming(self, user_input: str | list[ContentBlock]) -> AsyncGene
# between turns (user runs git checkout via Bash tool), but
# is treated as stable within a single in-flight request.
self._refresh_git_branch()
await self._apply_auto_triggers(user_input)
self.context_manager.add_user_message(user_input)
if self._session_storage:
from iac_code.agent.message import Message
Expand Down Expand Up @@ -535,6 +540,46 @@ async def poll_event_queues():
self.context_manager.add_raw_message(msg)
if result.context_modifier is not None:
self._apply_context_modifier(result.context_modifier)
else:
yield MessageEndEvent(stop_reason="max_turns", usage=Usage())

async def _apply_auto_triggers(self, user_input: str | list[ContentBlock]) -> None:
if not self._auto_trigger_skills:
return
if all(command.name in self._auto_loaded_skills for command in self._auto_trigger_skills):
return
prompt_text = self._auto_trigger_text(user_input)
if not prompt_text:
return

from iac_code.skills.auto_trigger import process_auto_triggered_skills

results = await process_auto_triggered_skills(
prompt_text,
self._auto_trigger_skills,
loaded_skill_names=self._auto_loaded_skills,
context_messages=self.context_manager.get_messages(),
session_id=self._session_id,
)
for result in results:
for msg in result.new_messages:
injected = self.context_manager.add_raw_message(msg)
if self._session_storage:
self._session_storage.append(
self._cwd,
self._session_id,
injected,
git_branch=self._current_git_branch,
)
if result.context_modifier is not None:
self._apply_context_modifier(result.context_modifier)

@staticmethod
def _auto_trigger_text(user_input: str | list[ContentBlock]) -> str:
if isinstance(user_input, str):
return user_input
parts = [block.text for block in user_input if isinstance(block, TextBlock)]
return " ".join(part for part in parts if part).strip()

def _apply_context_modifier(self, modifier: Any) -> None:
"""Apply a context modifier from a ToolResult to the current execution context."""
Expand Down Expand Up @@ -642,6 +687,7 @@ def replace_session(self, session_id: str, resume_messages: list | None) -> None

self._session_id = session_id
self._current_git_branch = None
self._auto_loaded_skills.clear()
self.context_manager.reset()
if resume_messages:
self.context_manager.load_messages(resume_messages)
Expand All @@ -663,6 +709,7 @@ def _refresh_git_branch(self) -> None:
self._current_git_branch = None

def reset(self) -> None:
self._auto_loaded_skills.clear()
self.context_manager.reset()

def get_context_usage(self) -> dict:
Expand Down
54 changes: 54 additions & 0 deletions src/iac_code/cli/headless.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
ErrorEvent,
MessageEndEvent,
PermissionRequestEvent,
StackInstancesProgressEvent,
StackProgressEvent,
SubAgentToolEvent,
ToolResultEvent,
ToolUseStartEvent,
)
from iac_code.utils.background_housekeeping import start_background_housekeeping

Expand All @@ -33,6 +38,47 @@
__all__ = ["HeadlessRunner", "logger"]


class _ProgressWriter:
"""Write human-readable headless progress to stderr."""

def __init__(self, stream: IO[str]) -> None:
self._stream = stream

def handle(self, event: Any) -> None:
line: str | None = None
if isinstance(event, ToolUseStartEvent):
line = _("Tool started: {}").format(event.name)
elif isinstance(event, ToolResultEvent):
if event.is_error:
line = _("Tool failed: {}").format(event.tool_name)
else:
line = _("Tool finished: {}").format(event.tool_name)
elif isinstance(event, SubAgentToolEvent):
if event.is_done:
if event.is_error:
line = _("Child tool failed: {}").format(event.child_tool_name)
else:
line = _("Child tool finished: {}").format(event.child_tool_name)
else:
line = _("Child tool started: {}").format(event.child_tool_name)
elif isinstance(event, StackProgressEvent):
line = _("Stack {}: {} ({:.1f}%)").format(
event.stack_name,
event.status,
event.progress_percentage,
)
elif isinstance(event, StackInstancesProgressEvent):
line = _("Stack group {}: {} ({}%)").format(
event.stack_group_name,
event.status,
event.progress_percentage,
)

if line is not None:
self._stream.write(line + "\n")
self._stream.flush()


class HeadlessRunner:
"""Run a single prompt headlessly, auto-approving all permission requests."""

Expand All @@ -45,6 +91,8 @@ def __init__(
cli_allowed_tools: list[str] | None = None,
cli_disallowed_tools: list[str] | None = None,
cli_permission_mode: str | None = None,
verbose: bool = False,
progress_stream: IO[str] | None = None,
) -> None:
self._model = model
self._output_format = output_format
Expand All @@ -53,6 +101,8 @@ def __init__(
self._cli_allowed_tools = cli_allowed_tools
self._cli_disallowed_tools = cli_disallowed_tools
self._cli_permission_mode = cli_permission_mode
self._verbose = verbose
self._progress_stream = progress_stream or sys.stderr

def _print_provider_not_configured(self, exc: Exception) -> None:
logger.error("Provider not configured: {}", exc)
Expand Down Expand Up @@ -91,6 +141,7 @@ async def run(self, prompt: str) -> int:

agent_loop = self._create_agent_loop()
writer = create_writer(self._output_format, self._output_stream)
progress_writer = _ProgressWriter(self._progress_stream) if self._verbose else None

has_error = False
hit_max_turns = False
Expand Down Expand Up @@ -118,6 +169,9 @@ async def run(self, prompt: str) -> int:
if isinstance(event, MessageEndEvent) and event.stop_reason == "max_turns":
hit_max_turns = True

if progress_writer is not None:
progress_writer.handle(event)

writer.handle(event)
except ProviderNotConfiguredError as exc:
self._print_provider_not_configured(exc)
Expand Down
29 changes: 26 additions & 3 deletions src/iac_code/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ def main(
output_format: str = typer.Option("text", "--output-format", help=_("Output format: text, json, stream-json")),
max_turns: int = typer.Option(100, "--max-turns", help=_("Maximum agent turns in headless mode")),
debug: bool = typer.Option(False, "--debug", "-d", help=_("Enable debug logging")),
verbose: bool = typer.Option(False, "--verbose", help=_("Show headless progress on stderr")),
version: bool = typer.Option(False, "--version", "-v", "-V", is_eager=True, help=_("Show version and exit")),
resume: str = typer.Option("", "--resume", "-r", help=_("Resume a session by ID")),
continue_session: bool = typer.Option(False, "--continue", "-c", help=_("Resume the most recent session")),
Expand Down Expand Up @@ -150,6 +151,18 @@ def main(
typer.echo(_("Error: --resume and --continue cannot be used together."), err=True)
raise typer.Exit(1)

fmt = None
if prompt:
from iac_code.cli.output_formats import OutputFormat

normalized_output_format = (output_format or "text").strip().lower()
try:
fmt = OutputFormat(normalized_output_format)
except ValueError as exc:
valid = ", ".join(item.value for item in OutputFormat)
typer.echo(_("Invalid --output-format '{}'. Valid values: {}").format(output_format, valid), err=True)
raise typer.Exit(1) from exc

# Priority: CLI parameter > saved config > default
if not model:
try:
Expand All @@ -159,10 +172,21 @@ def main(
raise typer.Exit(1)

if prompt:
assert fmt is not None

# Read from stdin if prompt is "-"
if prompt == "-":
prompt = sys.stdin.read().strip()

if permission_mode:
from iac_code.services.permissions.loader import parse_cli_permission_mode

try:
parse_cli_permission_mode(permission_mode)
except ValueError as exc:
typer.echo(str(exc), err=True)
raise typer.Exit(1) from exc

# Headless mode: generate session_id for logging only
session_id = str(uuid.uuid4())
setup_logging(session_id=session_id, debug=debug)
Expand All @@ -175,7 +199,7 @@ def main(
Events.SESSION_STARTED,
{
"headless": True,
"output_format": output_format or "text",
"output_format": fmt.value,
},
)
add_metric(Metrics.SESSION_COUNT, 1, {})
Expand Down Expand Up @@ -215,9 +239,7 @@ async def _run_with_handler(coro):
return await coro

from iac_code.cli.headless import HeadlessRunner
from iac_code.cli.output_formats import OutputFormat

fmt = OutputFormat(output_format)
cli_allowed = [s.strip() for s in allowed_tools.split(",") if s.strip()] if allowed_tools else None
cli_disallowed = [s.strip() for s in disallowed_tools.split(",") if s.strip()] if disallowed_tools else None
try:
Expand All @@ -228,6 +250,7 @@ async def _run_with_handler(coro):
cli_allowed_tools=cli_allowed,
cli_disallowed_tools=cli_disallowed,
cli_permission_mode=permission_mode or None,
verbose=verbose,
)
exit_code = asyncio.run(_run_with_handler(runner.run(prompt)))
except _QwenPawError as exc:
Expand Down
7 changes: 6 additions & 1 deletion src/iac_code/cli/output_formats.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,17 @@ def handle(self, event: StreamEvent) -> None:
entry["result"] = event.result
entry["is_error"] = event.is_error
elif isinstance(event, MessageEndEvent):
self._usage = {
usage = {
"input_tokens": event.usage.input_tokens,
"output_tokens": event.usage.output_tokens,
"cache_creation_input_tokens": event.usage.cache_creation_input_tokens,
"cache_read_input_tokens": event.usage.cache_read_input_tokens,
}
is_empty_synthetic_max_turns = (
event.stop_reason == "max_turns" and self._usage is not None and not any(usage.values())
)
if not is_empty_synthetic_max_turns:
self._usage = usage
elif isinstance(event, ErrorEvent):
self._error = event.error

Expand Down
2 changes: 1 addition & 1 deletion src/iac_code/commands/clear.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ async def clear_command(context=None, **kwargs) -> str:
if context and hasattr(context, "repl"):
agent_loop = getattr(context.repl, "_agent_loop", None)
if agent_loop:
agent_loop.context_manager.reset()
agent_loop.reset()
if hasattr(context.repl, "_command_log"):
context.repl._command_log.clear()

Expand Down
Loading
Loading