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
36 changes: 28 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,14 @@ Event-driven, layered architecture with:
- `types.py`: Event-stream union (`AgentEvent`), `AgentSessionProtocol`, tool result dataclasses, and re-exports of patching types.
- `config_types.py`: `BackendConfig` and `ModelConfig` for provider/model presets.
- `command_types.py`: `CommandType`, `CommandResult`, and `CommandEffect` for command parsing/execution.
- `deps.py`: `RunDeps` context and `NextAgent` alias for dependency-injected agent runs.
- `deps.py`: `RunDeps` context for dependency-injected agent runs.
- `config.py`: Centralized system constants (output caps, suffixes, default skill dirs, `DEFAULT_MODEL`, `DEFAULT_PROVIDER_DIRS`). Must be UI-agnostic.
- `loop.py`: `AgentSession` implementing the bidirectional async generator loop and mapping pydantic_ai events to `AgentEvent`.
- `factory.py`: `create_agent` factory assembling the `pydantic_ai.Agent` using the model preset configured in `providers.toml` (default preset: `local-oss` on the `ollama` backend), plus the shared toolset and skills table.
- `commands.py`: Command parsing (`CommandParser`) and effect-based execution (`execute_command`). Commands produce pure `CommandEffect` data; UIs apply effects.
- `commands.py`: Command parsing (`CommandParser`) and effect-based execution (`execute_command`).
- `CommandParser` performs pure parsing without validation
- Commands produce pure `CommandEffect` data containing `SessionConfig`
- Session factories validate model names and apply configuration to create new sessions
- `tool_parsing.py`: Robust JSON/dict argument handling for tool calls.
- `tools/`: Tool package organized by category (see Available Tools section)
- `skill_loader.py`: Discovers `SKILL.md` skills from project directories (`.github/skills`, `.claude/skills`), user directory (`~/.agentc/skills`), and bundled skills (installed to platform-specific user data directory). Earlier directories take precedence.
Expand All @@ -66,11 +69,17 @@ Event-driven, layered architecture with:
- `textual_messages.py`: Textual-specific `Message` types (e.g., `AgentText`, `AgentApprovalRequest`).
- `console_messages.py`: Console event dataclasses.

- **`ui/`**: User interface implementations (Textual TUI, Console)
- **`ui/`**: User interface implementations (Textual TUI widgets and components)
- `textual_app.py`: The main Textual `App` implementation.
- Receives model names list via dependency injection from composition root
- Each backend's entry point discovers models using backend-specific mechanisms
- UI layer remains completely backend-agnostic
- `widgets.py`: Reusable UI components (status bar, approval forms, etc.).
- `run_textual.py`: Launcher for the Textual UI (entry point: `agent-c`).
- `run_console.py`: Launcher for the Console UI demo (auto-approval sample prompt, entry point: `run-console`).

- **`entrypoints/`**: Application composition roots (dependency injection and bootstrapping)
- `run_textual.py`: Pydantic AI backend launcher (entry point: `agent-c`, `run-textual`)
- `run_textual_gh.py`: GitHub Copilot SDK backend launcher (entry point: `run-textual-gh`)
- `run_console.py`: Console UI demo launcher (entry point: `run-console`)

- **`skills/`**: Bundled skills (e.g., fibonacci-number) packaged with the application

Expand All @@ -93,6 +102,16 @@ Supported commands: `/clear`, `/reset`, `/exit`, `/quit`, `/bye`, `/model <name>

Framework-specific commands (`/exit`, unknown commands) return `None` and are handled directly by the UI layer.

### Session Factory Pattern

Commands produce `SessionConfig` (pure data), which session factories translate into backend-specific sessions:

- **`SessionFactoryProtocol`** (`types.py`): Interface for session creation
- **`PydanticAISessionFactory`** (`backends/pydantic_ai/`): Uses `create_agent()` + `AgentSession`
- **`GhCopilotSessionFactory`** (`backends/github_copilot/`): Uses `CopilotClient.create_session()`

Entry points inject concrete factories into the UI layer, which uses the Protocol abstraction.

### Skills System

`SkillLoader` scans bundled skills (installed to user data directory) and project directories (`.github/skills` and `.claude/skills` by default) for `SKILL.md` descriptors. `create_agent` injects a table of discovered skills into the system prompt, with guidance to `cd` into the skill base directory before running documented commands.
Expand Down Expand Up @@ -275,6 +294,7 @@ When making changes, include in your response:

- **Build system**: `uv_build`
- **Entry points**:
- `agentc.ui.run_textual:main` (agent-c command - default Textual UI)
- `agentc.ui.run_console:main` (run-console command)
- `agentc.ui.run_textual:main` (run-textual command)
- `agentc.entrypoints.run_textual:main` (agent-c command - default Textual UI)
- `agentc.entrypoints.run_console:main` (run-console command)
- `agentc.entrypoints.run_textual:main` (run-textual command)
- `agentc.entrypoints.run_textual_gh:main_sync` (run-textual-gh command)
10 changes: 6 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
[project]
name = "agent-c"
version = "0.1.0"
description = "Agent C is a simple code editing assistant powered by Pydantic AI, supporting multiple LLM providers and delegation to sub-agents."
description = "Agent C is a simple code editing assistant powered by Pydantic AI or the GitHub Copilot SDK."
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"github-copilot-sdk>=0.1.15",
"pathspec>=0.12.1",
"platformdirs>=4.5.0",
"pydantic-ai>=1.6.0",
Expand All @@ -28,6 +29,7 @@ source-include = [
]

[project.scripts]
agent-c = "agentc.ui.run_textual:main"
run-console = "agentc.ui.run_console:main"
run-textual = "agentc.ui.run_textual:main"
agent-c = "agentc.entrypoints.run_textual:main"
run-console = "agentc.entrypoints.run_console:main"
run-textual = "agentc.entrypoints.run_textual:main"
run-textual-gh = "agentc.entrypoints.run_textual_gh:main_sync"
2 changes: 1 addition & 1 deletion src/agentc/adapters/textual.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def __init__(
session: AgentSessionProtocol,
prompt: str,
cancellation_event: asyncio.Event | None = None,
debounce_threshold: int = 40,
debounce_threshold: int = 1, # TODO: make configurable
):
self._app = app
self._session = session
Expand Down
1 change: 1 addition & 0 deletions src/agentc/core/backends/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Backend-specific implementations for Agent C."""
6 changes: 6 additions & 0 deletions src/agentc/core/backends/github_copilot/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""GitHub Copilot backend implementation for Agent C."""

from .loop import GhAgentSession
from .session_factory import GhCopilotSessionFactory

__all__ = ["GhAgentSession", "GhCopilotSessionFactory"]
101 changes: 101 additions & 0 deletions src/agentc/core/backends/github_copilot/loop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import asyncio
from asyncio import Event, AbstractEventLoop
from typing import Optional

from copilot import CopilotSession
from copilot.generated.session_events import SessionEvent, SessionEventType

from ...types import (
AgentChunk,
AgentDone,
AgentSessionProtocol,
ToolCallInfo,
ToolCallResultInfo,
ToolResult,
AgentEventStream,
)


class GhAgentSession(AgentSessionProtocol):
"""
Encapsulates the state of a GitHub agentic session.

This class keeps the history and agent implementation details opaque to the UI.
"""

def __init__(
self,
session: CopilotSession,
):
self._session = session
self._event_queue: asyncio.Queue[SessionEvent] = asyncio.Queue()
self._loop: Optional[AbstractEventLoop]
try:
self._loop = asyncio.get_running_loop()
except RuntimeError:
self._loop = None

def _on_event(event: SessionEvent):
# Ensure we enqueue events on the asyncio event loop thread-safely.
if self._loop and self._loop.is_running():
try:
self._loop.call_soon_threadsafe(self._event_queue.put_nowait, event)
except Exception:
# Fallback to direct put if scheduling fails.
try:
self._event_queue.put_nowait(event)
except Exception:
pass
else:
try:
self._event_queue.put_nowait(event)
except Exception:
pass

self._session.on(_on_event)

def _on_event(self, event: SessionEvent):
self._event_queue.put_nowait(event)

async def run(
self,
prompt: str,
cancellation_event: Event | None = None,
) -> AgentEventStream:
"""Run the agentic session with the given prompt."""
await self._session.send({"prompt": prompt})

# Yield events from the queue until SESSION_IDLE.
while True:
if cancellation_event is not None and cancellation_event.is_set():
break

event = await self._event_queue.get()

match event.type:
case SessionEventType.ASSISTANT_MESSAGE_DELTA:
delta = event.data.delta_content or ""
yield AgentChunk(content=delta, is_thought=False)
case SessionEventType.ASSISTANT_REASONING_DELTA:
delta = event.data.delta_content or ""
yield AgentChunk(content=delta, is_thought=True)
case SessionEventType.TOOL_EXECUTION_START:
internal_tools = {"report_intent"}
if event.data.tool_name not in internal_tools:
yield ToolCallInfo(
tool_name=event.data.tool_name or "unknown",
args=event.data.arguments,
tool_call_id=event.data.tool_call_id or "unknown",
)
case SessionEventType.TOOL_EXECUTION_COMPLETE:
yield ToolCallResultInfo(
tool_call_id=event.data.tool_call_id or "unknown",
result=ToolResult(
success=event.data.success if event.data.success is not None else False,
content=str(event.data.result) if event.data.result is not None else "",
error=str(event.data.error) if event.data.error else None,
),
)
case SessionEventType.SESSION_IDLE:
yield AgentDone(history=None) # TOOD: populate history
break
86 changes: 86 additions & 0 deletions src/agentc/core/backends/github_copilot/session_factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""Session factory for the GitHub Copilot SDK backend."""

from __future__ import annotations

from typing import Any

from copilot import CopilotClient
from copilot.types import SessionConfig as CopilotSessionConfig

from ...command_types import SessionConfig
from ...types import AgentSessionProtocol
from .loop import GhAgentSession


class GhCopilotSessionFactory:
"""Factory for creating GitHub Copilot SDK sessions.

Implements the `SessionFactoryProtocol` by wrapping CopilotClient
session creation. Manages SDK-level session lifecycle (destroying
old sessions before creating new ones).
"""

def __init__(
self,
client: CopilotClient,
base_config: CopilotSessionConfig,
) -> None:
"""Initialize with a running CopilotClient and base configuration.

Args:
client: Running CopilotClient instance (managed by entry point).
base_config: Base SDK configuration (model, permissions, system
message, etc). Model and skill_dirs can be overridden per
session via SessionConfig.
"""
self._client = client
self._base_config = base_config
self._current_copilot_session: Any = None

async def create_session(self, config: SessionConfig) -> AgentSessionProtocol:
"""Create a GitHub Copilot SDK session.

Destroys any existing session before creating a new one, since the
SDK manages session state internally.

Args:
config: Backend-agnostic session configuration.

Returns:
GhAgentSession wrapping a CopilotSession.
"""
if self._current_copilot_session is not None:
try:
await self._current_copilot_session.destroy()
except Exception:
pass
finally:
self._current_copilot_session = None

sdk_config = CopilotSessionConfig(
model=config.model_name or self._base_config["model"], # type: ignore
skill_directories=(
[str(d) for d in config.skill_dirs]
if config.skill_dirs
else self._base_config["skill_directories"] # type: ignore
),
streaming=self._base_config["streaming"], # type: ignore
system_message=self._base_config["system_message"], # type: ignore
on_permission_request=self._base_config["on_permission_request"], # type: ignore
)

self._current_copilot_session = await self._client.create_session(sdk_config)
return GhAgentSession(self._current_copilot_session)

async def cleanup(self) -> None:
"""Clean up the current session (called on app shutdown)."""
if self._current_copilot_session is not None:
try:
await self._current_copilot_session.destroy()
except Exception:
pass
finally:
self._current_copilot_session = None


__all__ = ["GhCopilotSessionFactory"]
5 changes: 5 additions & 0 deletions src/agentc/core/backends/pydantic_ai/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Pydantic_AI backend implementation for Agent C."""

from .session_factory import PydanticAISessionFactory

__all__ = ["PydanticAISessionFactory"]
Original file line number Diff line number Diff line change
@@ -1,26 +1,19 @@
"""
Agent factory for Agent C Next.

This module assembles the agent using tools and types.
"""
"""Agent factory for the Pydantic_AI backend."""

from dataclasses import replace
from pathlib import Path
from typing import Any

from pydantic_ai import (
Agent,
DeferredToolRequests,
Tool,
)
from pydantic_ai import Agent, DeferredToolRequests, Tool

from .config import DEFAULT_MODEL
from ...config import DEFAULT_MODEL
from ...skill_loader import SkillLoader
from ...deps import RunDeps
from .types import NextAgent
from .provider_loader import load_providers, build_model
from .tools.filesystem import list_files, glob_paths, search_files
from .tools.editing import read_file, create_file, edit_file, apply_hunks
from .tools.execution import run_command
from .skill_loader import SkillLoader
from .deps import RunDeps, NextAgent
from .provider_loader import load_providers, build_model


def create_agent(
Expand All @@ -29,14 +22,8 @@ def create_agent(
override_model_name: str | None = None,
**model_params: Any,
) -> NextAgent:
"""Factory function to create a configured agent instance.

Args:
skill_dirs: Optional skill directories to include during skill discovery.
model_name: Name of the model preset to load from providers.toml.
override_model_name: Runtime override for the model string passed to the backend.
**model_params: Extra model keyword arguments merged with preset params.
"""
"""Factory function to create a configured pydantic_ai agent instance."""

loader = SkillLoader()
if skill_dirs is None:
skill_dirs = loader.get_default_skill_dirs()
Expand Down Expand Up @@ -75,7 +62,6 @@ def create_agent(
Tool(run_command, takes_ctx=True, requires_approval=True),
]

# Dynamically load skills
discovered_skills = loader.discover_skills(skill_dirs)
skills_summary = loader.get_skills_summary(discovered_skills)

Expand All @@ -84,7 +70,7 @@ def create_agent(
tools=tools,
deps_type=RunDeps,
output_type=str | DeferredToolRequests, # type: ignore
system_prompt=f"""\
system_prompt=f"""
You are an expert coding assistant with comprehensive file system access and command execution capabilities. You help users navigate, analyze, edit, and manage their codebase efficiently.

## Tool Usage Strategy
Expand Down Expand Up @@ -125,6 +111,4 @@ def create_agent(
)


__all__ = [
"create_agent",
]
__all__ = ["create_agent"]
Loading