chore: release api v0.3.4
Build and Push API / release (push) Successful in 4s
Build and Push API / build (push) Successful in 1m18s

This commit is contained in:
2026-01-11 20:21:50 +01:00
parent b5b2346db5
commit 470b7448ac
8 changed files with 909 additions and 1 deletions
+1
View File
@@ -10,6 +10,7 @@ from src.domains.agents.base import get_agent, list_agents
# Import agents to ensure they're registered
import src.domains.agents.explore # noqa: F401
import src.domains.agents.plan # noqa: F401
import src.domains.agents.task # noqa: F401
from src.domains.agents.schemas import (
AgentRunRequest,
AgentRunResponse,
@@ -0,0 +1,33 @@
"""
Task Agent - Full orchestrator for autonomous task execution.
The Task agent can:
- Execute multi-step tasks autonomously
- Use all tools (read + write + bash)
- Spawn sub-agents (Explore, Plan) for focused work
- Return consolidated task summaries
Usage:
from src.domains.agents.task import task_agent, task
# Direct agent access
result = await task_agent.run("Create a new user model with tests")
# Convenience function
result = await task("Create a new user model with tests")
"""
from src.domains.agents.task.agent import (
TaskAgentImpl,
TaskContext,
task_agent,
task,
task_stream,
)
__all__ = [
"TaskAgentImpl",
"TaskContext",
"task_agent",
"task",
"task_stream",
]
+174
View File
@@ -0,0 +1,174 @@
"""
Task Agent implementation using PydanticAI.
Full orchestrator agent that can:
- Execute multi-step tasks autonomously
- Use all tools (read + write)
- Spawn sub-agents (Explore, Plan) for focused work
"""
import os
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import Any
from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIModel
from src.domains.agents.base import BaseAgent, AgentContext, register_agent
from src.domains.agents.task.prompts import TASK_SYSTEM_PROMPT
from src.ollama.provider import get_ollama_provider
from src.shared.config import get_settings
from src.shared.logging import logged, get_logger, trace_span
logger = get_logger(__name__)
@dataclass
class TaskContext(AgentContext):
"""
Context for task agent tools.
Passed to all tool functions via RunContext.
Uses the same fields as base AgentContext.
"""
pass
class TaskAgentImpl(BaseAgent):
"""
Full orchestrator agent for autonomous task execution.
Has access to ALL tools:
- Read-only: read_file, glob_files, grep_content, bash_readonly
- Write: edit_file, write_file, bash
- External: web_search
- Orchestration: spawn_agent (launch sub-agents)
Can spawn Explore and Plan agents to offload focused tasks,
keeping context efficient across complex multi-step work.
"""
name = "task"
description = "Autonomous multi-step task execution with sub-agent orchestration"
def __init__(self):
"""Initialize the task agent."""
self._agent: Agent[TaskContext, str] | None = None
self._settings = get_settings()
def _create_agent(self) -> Agent[TaskContext, str]:
"""Create the PydanticAI agent with Ollama backend."""
# Use sanitized Ollama provider to fix content: null issues
model = OpenAIModel(
model_name=self._settings.ollama_agent_model,
provider=get_ollama_provider(),
)
agent: Agent[TaskContext, str] = Agent(
model=model,
system_prompt=TASK_SYSTEM_PROMPT,
deps_type=TaskContext,
output_type=str,
# Mistral Nemo settings:
# - temperature 0.3 (Nemo needs slightly higher than 0.0)
# - tool_choice "required" forces tool use
model_settings={
"temperature": 0.3,
"extra_body": {"tool_choice": "required"},
},
)
# Register all tools including orchestration
self._register_tools(agent)
return agent
def _register_tools(self, agent: Agent[TaskContext, str]) -> None:
"""Register all tools with the agent."""
from src.domains.agents.task.tools import register_task_tools
register_task_tools(agent)
@logged()
async def run(
self,
prompt: str,
working_dir: str | None = None,
allowed_paths: list[str] | None = None,
**kwargs: Any
) -> str:
"""
Run the task agent to execute a multi-step task.
Args:
prompt: Description of the task to execute
working_dir: Working directory for the agent
allowed_paths: Restrict tool access to these paths
Returns:
Consolidated task summary with results
"""
ctx = TaskContext(
working_dir=working_dir or os.getcwd(),
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
timeout_seconds=self._settings.tool_timeout_seconds,
)
async with trace_span("task_agent_run"):
try:
# Use run() not run_stream() - Ollama has bugs with streaming + tools
result = await self.agent.run(prompt, deps=ctx)
return result.output
except Exception as e:
logger.exception(f"Task agent error: {e}")
raise
async def run_stream(
self,
prompt: str,
working_dir: str | None = None,
allowed_paths: list[str] | None = None,
**kwargs: Any
) -> AsyncIterator[str]:
"""
Run the task agent with streaming output.
Yields text chunks as they become available.
"""
ctx = TaskContext(
working_dir=working_dir or os.getcwd(),
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
timeout_seconds=self._settings.tool_timeout_seconds,
)
async with trace_span("task_agent_stream"):
try:
async with self.agent.run_stream(prompt, deps=ctx) as result:
async for chunk in result.stream_text():
yield chunk
except Exception as e:
logger.exception(f"Task agent stream error: {e}")
raise
# Create and register the singleton instance
task_agent = TaskAgentImpl()
register_agent(task_agent)
async def task(
prompt: str,
working_dir: str | None = None,
**kwargs: Any
) -> str:
"""Run task execution."""
return await task_agent.run(prompt, working_dir=working_dir, **kwargs)
async def task_stream(
prompt: str,
working_dir: str | None = None,
**kwargs: Any
) -> AsyncIterator[str]:
"""Run task execution with streaming."""
async for chunk in task_agent.run_stream(prompt, working_dir=working_dir, **kwargs):
yield chunk
@@ -0,0 +1,83 @@
"""
System prompts for the Task agent.
The Task agent is a full orchestrator that can:
- Execute multi-step tasks autonomously
- Use all tools (read + write)
- Spawn sub-agents (Explore, Plan) for focused work
"""
TASK_SYSTEM_PROMPT = """You are an autonomous task execution agent.
You have access to ALL tools including file editing, writing, and bash execution.
You can also spawn sub-agents to help with complex tasks.
AVAILABLE TOOLS:
File Operations:
- read_file: Read file contents with line numbers
- glob_files: Find files by pattern
- grep_content: Search file contents with regex
- edit_file: Make targeted edits via find-and-replace
- write_file: Create or overwrite files
Shell:
- bash_readonly: Read-only commands (ls, git status, git log, etc.)
- bash: Full bash execution (git commit, pytest, mkdir, etc.)
External:
- web_search: Search the web for current information
Orchestration:
- spawn_agent: Launch sub-agents for focused tasks
WORKFLOW:
1. Understand the task requirements
2. Break down into sub-tasks if complex
3. Use spawn_agent for research (explore) or planning (plan)
4. Execute implementation steps using write tools
5. Validate changes (run tests if applicable)
6. Return consolidated summary
TOOL CALL EXAMPLES:
To spawn an Explore agent for research:
Call spawn_agent with agent_type="explore" and prompt="find all config files"
To spawn a Plan agent for design:
Call spawn_agent with agent_type="plan" and prompt="design user auth feature"
To edit a file:
Call edit_file with file_path="/path/to/file.py" and old_string="old" and new_string="new"
To run tests:
Call bash with command="pytest tests/ -v"
SPAWN_AGENT USAGE:
- Use spawn_agent to offload focused tasks to specialized agents
- Explore agent: Fast codebase searches and analysis
- Plan agent: Design implementation strategies
- Keep each agent's context focused and efficient
GIT DISCIPLINE:
- Create feature branches for changes
- Use conventional commit format (feat:, fix:, docs:, etc.)
- Never commit directly to main
- Run tests before committing
RULES:
- ALWAYS use tools first, then analyze results
- Never guess file contents - read them first
- Prefer edit_file over write_file for existing files
- Use spawn_agent to keep context focused
- Validate changes by running tests when applicable
OUTPUT FORMAT:
End your response with a summary:
### Task Summary
- **Accomplished:** What was done
- **Files modified:** List of changed files
- **Commands run:** Key commands executed
- **Issues:** Any problems encountered
"""
+350
View File
@@ -0,0 +1,350 @@
"""
Tool registrations for the Task agent.
The Task agent has access to ALL tools:
- Read-only tools (same as Explore/Plan)
- Write tools (edit, write, bash full)
- External tools (web search)
- Orchestration (spawn sub-agents)
"""
from pydantic_ai import Agent, RunContext
from src.domains.agents.base import AgentContext
from src.domains.tools.file.read import ReadFileTool
from src.domains.tools.file.glob import GlobFilesTool
from src.domains.tools.file.edit import EditFileTool
from src.domains.tools.file.write import WriteFileTool
from src.domains.tools.search.grep import GrepContentTool
from src.domains.tools.search.web import WebSearchTool
from src.domains.tools.shell.bash import BashReadOnlyTool
from src.domains.tools.shell.bash_full import BashTool
def register_task_tools(agent: Agent[AgentContext, str]) -> None:
"""
Register all tools with the Task agent.
Includes:
- Read-only tools: read_file, glob_files, grep_content, bash_readonly
- Write tools: edit_file, write_file, bash
- External: web_search
- Orchestration: spawn_agent
"""
# === Read-only tools ===
@agent.tool
async def read_file(
ctx: RunContext[AgentContext],
file_path: str,
offset: int = 0,
limit: int = 2000
) -> str:
"""Read contents of a file with line numbers.
Args:
file_path: Absolute path to the file to read
offset: Line number to start from (0-based, default: 0)
limit: Maximum number of lines to read (default: 2000)
Returns:
File contents with line numbers, or error message.
IMPORTANT: Always use absolute paths. Read files before editing them.
"""
tool = ReadFileTool(allowed_paths=ctx.deps.allowed_paths)
result = await tool.execute(
file_path=file_path,
offset=offset,
limit=limit
)
return result.to_string()
@agent.tool
async def glob_files(
ctx: RunContext[AgentContext],
pattern: str,
path: str | None = None,
limit: int = 100
) -> str:
"""Find files matching a glob pattern.
Args:
pattern: Glob pattern (e.g., "**/*.py", "src/**/*.ts", "*.md")
path: Directory to search in (default: working directory)
limit: Maximum number of files to return (default: 100)
Returns:
List of absolute file paths, sorted by modification time (newest first).
Examples:
- "**/*.py" finds all Python files
- "src/**/*.ts" finds TypeScript files in src/
- "**/test_*.py" finds all test files
"""
tool = GlobFilesTool(allowed_paths=ctx.deps.allowed_paths)
search_path = path or ctx.deps.working_dir
result = await tool.execute(
pattern=pattern,
path=search_path,
limit=limit
)
return result.to_string()
@agent.tool
async def grep_content(
ctx: RunContext[AgentContext],
pattern: str,
path: str | None = None,
file_glob: str | None = None,
context_lines: int = 0,
case_sensitive: bool = True
) -> str:
"""Search file contents using regex pattern.
Args:
pattern: Regex pattern to search for (Python re syntax)
path: Directory or file to search (default: working directory)
file_glob: Filter files by glob (e.g., "*.py", "*.ts")
context_lines: Lines of context before/after matches (default: 0)
case_sensitive: Case-sensitive search (default: True)
Returns:
Matching lines with file paths and line numbers.
Format: "filepath:line_num: content"
"""
tool = GrepContentTool(allowed_paths=ctx.deps.allowed_paths)
search_path = path or ctx.deps.working_dir
result = await tool.execute(
pattern=pattern,
path=search_path,
file_glob=file_glob,
context_lines=context_lines,
case_sensitive=case_sensitive
)
return result.to_string()
@agent.tool
async def bash_readonly(
ctx: RunContext[AgentContext],
command: str,
cwd: str | None = None,
timeout: int = 30
) -> str:
"""Execute a read-only bash command.
ALLOWED commands:
- File inspection: ls, find, cat, head, tail, wc, file, stat, tree, du
- Git (read-only): git status, git log, git diff, git show, git branch
- Text processing: grep, awk, sed (read-only), sort, uniq
- System info: pwd, whoami, hostname, which
FORBIDDEN:
- File modification (rm, mv, cp, mkdir, touch)
- Redirects (>, >>)
- Command chaining (&&, ||, ;)
- Network (curl, wget)
Args:
command: The bash command to execute
cwd: Working directory (default: agent working directory)
timeout: Timeout in seconds (default: 30)
"""
tool = BashReadOnlyTool(allowed_paths=ctx.deps.allowed_paths)
working_dir = cwd or ctx.deps.working_dir
result = await tool.execute(
command=command,
cwd=working_dir,
timeout=min(timeout, ctx.deps.timeout_seconds)
)
return result.to_string()
# === Write tools ===
@agent.tool
async def edit_file(
ctx: RunContext[AgentContext],
file_path: str,
old_string: str,
new_string: str,
replace_all: bool = False
) -> str:
"""Make targeted edits to a file using find-and-replace.
Args:
file_path: Absolute path to the file to edit
old_string: The exact text to find and replace (must exist in file)
new_string: The replacement text
replace_all: If True, replace all occurrences. If False (default),
old_string must be unique (appear exactly once).
Returns:
Success message with diff preview, or error.
IMPORTANT:
- old_string must exactly match file content (including whitespace)
- By default, old_string must appear exactly once (for safety)
- Always read the file first to verify exact content before editing
"""
tool = EditFileTool(allowed_paths=ctx.deps.allowed_paths)
result = await tool.execute(
file_path=file_path,
old_string=old_string,
new_string=new_string,
replace_all=replace_all
)
return result.to_string()
@agent.tool
async def write_file(
ctx: RunContext[AgentContext],
file_path: str,
content: str
) -> str:
"""Create a new file or overwrite an existing file.
Args:
file_path: Absolute path to the file to create/write
content: The content to write to the file
Returns:
Success message with file path and size.
IMPORTANT:
- Parent directory must exist (use bash mkdir first if needed)
- For editing existing files, prefer edit_file instead
- Will overwrite existing files without confirmation
"""
tool = WriteFileTool(allowed_paths=ctx.deps.allowed_paths)
result = await tool.execute(
file_path=file_path,
content=content
)
return result.to_string()
@agent.tool
async def bash(
ctx: RunContext[AgentContext],
command: str,
cwd: str | None = None,
timeout: int = 60
) -> str:
"""Execute a bash command with write capabilities.
ALLOWED:
- File operations: ls, find, mkdir, touch, cp, mv, rm (single files)
- Git (full): git add, git commit, git checkout, git merge, git pull
- Python: python, pip install, pytest, mypy, ruff
- Text processing: grep, awk, sed, sort
- Command chaining: && and || are allowed
FORBIDDEN:
- sudo, su (privilege escalation)
- Network: curl, wget, ssh, scp, rsync
- Dangerous: rm -rf, chmod 777, dd, mkfs
Args:
command: The bash command to execute
cwd: Working directory (default: agent working directory)
timeout: Timeout in seconds (default: 60)
Examples:
- "mkdir -p src/utils" creates directory
- "git add . && git commit -m 'fix: bug'" commits changes
- "pytest tests/ -v" runs tests
"""
tool = BashTool(allowed_paths=ctx.deps.allowed_paths)
working_dir = cwd or ctx.deps.working_dir
result = await tool.execute(
command=command,
cwd=working_dir,
timeout=min(timeout, ctx.deps.timeout_seconds)
)
return result.to_string()
# === External tools ===
@agent.tool
async def web_search(
ctx: RunContext[AgentContext],
query: str,
num_results: int = 5,
categories: str | None = None
) -> str:
"""Search the web for current information.
Args:
query: Search query (e.g., "Python 3.12 new features")
num_results: Number of results to return (1-10, default: 5)
categories: Optional category filter ("general", "it", "news", "science")
Returns:
Search results with titles, URLs, and snippets.
Use this for:
- Current events or recent information
- Documentation updates
- Technical references with URLs
"""
tool = WebSearchTool()
result = await tool.execute(
query=query,
num_results=num_results,
categories=categories
)
return result.to_string()
# === Orchestration tools ===
@agent.tool
async def spawn_agent(
ctx: RunContext[AgentContext],
agent_type: str,
prompt: str,
working_dir: str | None = None
) -> str:
"""Spawn a sub-agent to handle a focused task.
Use this to offload work to specialized agents:
- "explore": Fast codebase searches and analysis (read-only)
- "plan": Design implementation strategies (read-only)
Args:
agent_type: Type of agent to spawn ("explore" or "plan")
prompt: Task description for the sub-agent
working_dir: Working directory for the sub-agent (default: current)
Returns:
Sub-agent's consolidated response.
Examples:
- spawn_agent(agent_type="explore", prompt="find all test files")
- spawn_agent(agent_type="plan", prompt="design user auth feature")
IMPORTANT:
- Use sub-agents to keep context focused and efficient
- Explore agent for research, Plan agent for design
- Cannot spawn nested Task agents (recursion risk)
"""
from src.domains.agents.base import get_agent
# Validate agent type
allowed_types = ["explore", "plan"]
if agent_type not in allowed_types:
if agent_type == "task":
return "Error: Cannot spawn nested Task agents (recursion risk)"
return f"Error: Unknown agent type '{agent_type}'. Allowed: {allowed_types}"
sub_agent = get_agent(agent_type)
if not sub_agent:
return f"Error: Agent '{agent_type}' not found in registry"
try:
result = await sub_agent.run(
prompt=prompt,
working_dir=working_dir or ctx.deps.working_dir,
allowed_paths=ctx.deps.allowed_paths,
)
return result
except Exception as e:
return f"Sub-agent error: {e}"