diff --git a/.env.example b/.env.example index 93a762c..d9a9f77 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,19 @@ -# Required: Anthropic API key for the VDD pipeline. +# Required only when runtime.provider is "anthropic" (legacy direct API runtime). # See https://console.anthropic.com/ to obtain one. ANTHROPIC_API_KEY= +# Select "claude_print" to run Claude-backed roles through `claude -p`. +# Select "openai" for the OpenAI Agents SDK or "umans" for its +# OpenAI-compatible endpoint. The default remains "anthropic" for migration safety. +QUACKING_AGENT_RUNTIME=claude_print + +# Required when QUACKING_AGENT_RUNTIME=openai. +OPENAI_API_KEY= + +# Required when QUACKING_AGENT_RUNTIME=umans. Use a wallet or service-account +# key for unattended automation. +UMANS_API_KEY= + # Optional: only needed if you run the benchmark suite (benchmarks/). # Path to the external target repo (e.g. a checkout of textstats) that # Quacking will build from spec, and the baseline commit to reset to. diff --git a/README.md b/README.md index 55ab438..58f60d4 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,19 @@ Tests under `tests/gui_next/` require the `gui-next` extra. To run the suite wit > **Note:** the older Streamlit GUI (`--extra gui`, code under `src/quacking/gui/`) is deprecated. New work should target the NiceGUI-based `gui_next`. The Streamlit code will be removed in a future release. -### Authentication +### Runtime and Authentication + +Quacking supports provider runtimes configured in `.quacking/config.toml`: + +```toml +[runtime] +provider = "claude_print" # anthropic | claude_print | openai | umans +``` + +- `claude_print` invokes non-interactive roles with `claude -p` and uses your Claude Code login. +- `openai` uses the OpenAI Agents SDK and requires `OPENAI_API_KEY`. +- `umans` uses its OpenAI-compatible endpoint and requires `UMANS_API_KEY`. +- `anthropic` is retained as a legacy compatibility runtime and requires `ANTHROPIC_API_KEY`. **API Key** diff --git a/prompts/builder_agent.md b/prompts/builder_agent.md index f3c1690..56a2359 100644 --- a/prompts/builder_agent.md +++ b/prompts/builder_agent.md @@ -4,12 +4,12 @@ **Characteristics:** -- Long-lived agent maintaining context across the entire build process +- Receives only the context explicitly supplied for the current build step - Has read/write access to the Project Repository only - Produces structured outputs (code + tests + proposed spec changes) - Receives feedback via the Builder Specification updates and Feedback Document - Responsible for expanding the specification where gaps are identified -- Each build step concludes with a git commit +- The orchestrator owns git commits after validating the resulting changes - Must write unit and integration tests for all code --- @@ -22,7 +22,7 @@ You are the Builder Agent in a Verification-Driven Development system. Your role 2. **Mandatory Testing**: Write unit AND integration tests for ALL components 3. **Specification Expansion**: When you identify gaps, ambiguities, or missing details in the specification, propose additions 4. **Structured Output**: Always output in the required format (see below) -5. **Git Discipline**: Each build step MUST conclude with a commit +5. **Git Discipline**: Report a commit message; the orchestrator creates the commit ## Testing Requirements (MANDATORY) @@ -74,13 +74,11 @@ See `schemas/builder_output.json` for the full schema. ## Git Commit Requirements -Every build step MUST end with a commit: +Every build step MUST provide a commit message: - Use conventional commit format: `type(scope): message [VDD step-XXX]` - Include all modified files (source AND tests) -- The commit marks the boundary for review -- Do NOT amend previous commits—each step is a discrete commit -- Commits trigger automated hooks (linting, test execution) +- The orchestrator creates the review boundary and runs automated hooks ## Constraints diff --git a/prompts/code_reviewer.md b/prompts/code_reviewer.md index b5a4c40..6b15ce9 100644 --- a/prompts/code_reviewer.md +++ b/prompts/code_reviewer.md @@ -44,7 +44,7 @@ You are the Code Reviewer in a Verification-Driven Development system. Your role 2. DIFF the Canonical vs Builder specs to identify any drift 3. Review code against Canonical requirements (not Builder's interpretation) 4. Generate review test cases that CI likely missed -5. Execute additional tests +5. Propose additional tests for the orchestrator to execute 6. Document findings, distinguishing CI failures from new discoveries ## Output Format diff --git a/pyproject.toml b/pyproject.toml index ef6beea..04a698b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,8 @@ dependencies = [ "structlog>=24.0.0", "python-dotenv>=1.0.0", "claude-code-sdk>=0.0.25", + "openai>=1.0.0", + "openai-agents>=0.14.0", ] [project.optional-dependencies] diff --git a/src/quacking/agents/base.py b/src/quacking/agents/base.py index 5ed25c9..0884c5e 100644 --- a/src/quacking/agents/base.py +++ b/src/quacking/agents/base.py @@ -20,6 +20,13 @@ import anthropic +from quacking.agents.runtime import ( + AgentDefinition, + AgentRequest, + AgentResult, + AgentRuntime, + RuntimeMessage, +) from quacking.core.approval import get_approval_manager from quacking.core.config import calculate_cost from quacking.core.logging import get_logger @@ -220,6 +227,8 @@ class AgentMetrics: cost: float = 0.0 success: bool = True error: str | None = None + runtime: str | None = None + trace_id: str | None = None @dataclass @@ -252,6 +261,8 @@ def __init__( batch_client: Any | None = None, use_batch_api: bool = False, use_cc_sdk_transport: bool = False, + runtime: AgentRuntime | None = None, + definition: AgentDefinition | None = None, ): """ Initialize the agent. @@ -271,13 +282,20 @@ def __init__( self.temperature = temperature self.system_prompt = system_prompt or self._default_system_prompt() self.api_key = api_key + self.runtime = runtime + self.definition = definition or AgentDefinition( + role=self.agent_type, + prompt_id=self.agent_type, + ) + if self.runtime is not None: + self.runtime.validate(self.definition) # CC SDK transport (routes through Claude Code SDK instead of direct API) self._use_cc_sdk_transport = use_cc_sdk_transport # Batch API support (50% cost savings) self.batch_client = batch_client - self.use_batch_api = use_batch_api and batch_client is not None + self.use_batch_api = use_batch_api and batch_client is not None and runtime is None # Initialize async client (required for non-blocking event loop) self.client = anthropic.AsyncAnthropic(api_key=api_key) @@ -291,6 +309,7 @@ def __init__( self.total_cache_creation_tokens = 0 self.total_cache_read_tokens = 0 self.invocation_count = 0 + self._last_runtime_result: AgentResult | None = None # Prompt caching - enabled by default for cost savings self.use_prompt_caching = True @@ -588,6 +607,9 @@ async def _call_api( Returns: Tuple of (response_text, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens) """ + if self.runtime is not None: + return await self._call_runtime(messages) + if self._use_cc_sdk_transport: return await self._call_api_via_cc_sdk(messages) @@ -873,7 +895,69 @@ async def _call_api( else: raise AgentError(f"API call failed: {e}") - raise AgentError(f"API call failed after {max_retries} retries: {last_error}") + raise AgentError(f"API call failed after {max_retries} retries: {last_error}") + + async def _call_runtime( + self, + messages: list[dict[str, Any]], + ) -> tuple[str, int, int, int, int]: + """Invoke the configured provider-neutral runtime.""" + if self.runtime is None: + raise AgentError("No runtime configured") + + request = AgentRequest( + definition=self.definition, + model=self.model, + system_prompt=self.system_prompt, + messages=tuple( + RuntimeMessage(role=message["role"], content=self._message_content(message)) + for message in messages + ), + max_tokens=self.max_tokens, + temperature=self.temperature, + ) + result = await self.runtime.invoke(request) + self._record_runtime_result(result) + write_invocation_file( + agent_type=self.agent_type, + prompt_content=request.messages[-1].content if request.messages else "", + response_content=result.content, + step_id=self._current_step_id, + phase=self._current_phase, + subtask_id=self._current_subtask_id, + system_prompt=self.system_prompt if self.invocation_count == 1 else None, + input_tokens=result.input_tokens, + output_tokens=result.output_tokens, + service_tier=self.runtime.name, + ) + return ( + result.content, + result.input_tokens, + result.output_tokens, + result.cache_creation_tokens, + result.cache_read_tokens, + ) + + @staticmethod + def _message_content(message: dict[str, Any]) -> str: + """Normalize an Anthropic-style message payload to plain text.""" + content = message.get("content", "") + if isinstance(content, str): + return content + if isinstance(content, list): + return "\n".join( + block.get("text", "") for block in content if isinstance(block, dict) + ) + return str(content) + + def _record_runtime_result(self, result: AgentResult) -> None: + """Update aggregate counters from a normalized runtime result.""" + self.total_input_tokens += result.input_tokens + self.total_output_tokens += result.output_tokens + self.total_cache_creation_tokens += result.cache_creation_tokens + self.total_cache_read_tokens += result.cache_read_tokens + self.invocation_count += 1 + self._last_runtime_result = result def queue_for_batch( self, @@ -1048,6 +1132,12 @@ async def _invoke_streaming(self, user_message: str) -> tuple[str, AgentMetrics] duration_seconds=duration, cost=self._calculate_cost(input_tokens, output_tokens, cache_creation, cache_read), success=True, + runtime=self.runtime.name if self.runtime is not None else "anthropic", + trace_id=( + self._last_runtime_result.trace_id + if self._last_runtime_result is not None + else None + ), ) return response, metrics diff --git a/src/quacking/agents/claude_print.py b/src/quacking/agents/claude_print.py new file mode 100644 index 0000000..ef71587 --- /dev/null +++ b/src/quacking/agents/claude_print.py @@ -0,0 +1,136 @@ +"""Claude Code print-mode runtime for non-interactive Quacking agents.""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any + +from quacking.agents.runtime import ( + AgentRequest, + AgentResult, + AgentRuntime, + AgentRuntimeError, + RuntimeCapability, +) + + +class ClaudePrintRuntime(AgentRuntime): + """Invoke Claude Code with ``claude -p`` and normalized JSON output.""" + + name = "claude_print" + capabilities = frozenset( + { + RuntimeCapability.STRUCTURED_OUTPUT, + RuntimeCapability.TOOL_USE, + RuntimeCapability.TOKEN_USAGE, + } + ) + + def __init__( + self, + executable: str = "claude", + default_timeout_seconds: float = 900.0, + ) -> None: + self.executable = executable + self.default_timeout_seconds = default_timeout_seconds + + async def invoke(self, request: AgentRequest) -> AgentResult: + """Run one non-interactive Claude Code session.""" + self.validate(request.definition) + command = self._build_command(request) + prompt = self._build_prompt(request) + timeout = request.timeout_seconds or self.default_timeout_seconds + + try: + process = await asyncio.create_subprocess_exec( + *command, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=request.definition.tool_policy.working_directory, + ) + except OSError as error: + raise AgentRuntimeError(f"Unable to start Claude CLI: {error}") from error + + try: + stdout, stderr = await asyncio.wait_for( + process.communicate(prompt.encode()), timeout=timeout + ) + except TimeoutError as error: + process.kill() + await process.wait() + raise AgentRuntimeError(f"Claude CLI timed out after {timeout} seconds") from error + + stdout_text = stdout.decode(errors="replace") + stderr_text = stderr.decode(errors="replace") + if process.returncode != 0: + detail = stderr_text.strip() or stdout_text.strip() or "no output" + raise AgentRuntimeError( + f"Claude CLI exited with status {process.returncode}: {detail}" + ) + + return self._parse_result(stdout_text) + + def _build_command(self, request: AgentRequest) -> list[str]: + """Build an argv list without shell interpolation.""" + policy = request.definition.tool_policy + command = [ + self.executable, + "-p", + "--output-format", + "json", + "--no-session-persistence", + "--model", + request.model, + "--system-prompt", + request.system_prompt, + ] + if request.definition.output_schema is not None: + command.extend(["--json-schema", json.dumps(request.definition.output_schema)]) + if policy.allowed_tools: + command.extend(["--allowed-tools", *policy.allowed_tools]) + else: + command.extend(["--tools", ""]) + if policy.denied_tools: + command.extend(["--disallowed-tools", *policy.denied_tools]) + if policy.permission_mode: + command.extend(["--permission-mode", policy.permission_mode]) + return command + + @staticmethod + def _build_prompt(request: AgentRequest) -> str: + """Serialize prior turns for a stateless CLI invocation.""" + if len(request.messages) <= 1: + return request.messages[-1].content if request.messages else "" + + history = "\n\n".join( + f"[{message.role.upper()}]\n{message.content}" for message in request.messages[:-1] + ) + return f"## Conversation history\n{history}\n\n## Current request\n{request.messages[-1].content}" + + @staticmethod + def _parse_result(stdout: str) -> AgentResult: + """Parse Claude CLI JSON while tolerating usage-field revisions.""" + try: + payload: dict[str, Any] = json.loads(stdout) + except json.JSONDecodeError as error: + raise AgentRuntimeError(f"Claude CLI returned invalid JSON: {error}") from error + + content = payload.get("result") or payload.get("output_text") or payload.get("content") + if not isinstance(content, str): + raise AgentRuntimeError("Claude CLI JSON did not contain a text result") + + usage = payload.get("usage") or {} + if not isinstance(usage, dict): + usage = {} + return AgentResult( + content=content, + input_tokens=int(usage.get("input_tokens", 0)), + output_tokens=int(usage.get("output_tokens", 0)), + cache_creation_tokens=int(usage.get("cache_creation_input_tokens", 0)), + cache_read_tokens=int(usage.get("cache_read_input_tokens", 0)), + cost=payload.get("total_cost_usd"), + trace_id=payload.get("session_id"), + provider_metadata={"raw_result": payload}, + ) diff --git a/src/quacking/agents/openai_runtime.py b/src/quacking/agents/openai_runtime.py new file mode 100644 index 0000000..9fcc05f --- /dev/null +++ b/src/quacking/agents/openai_runtime.py @@ -0,0 +1,164 @@ +"""OpenAI Agents SDK and OpenAI-compatible agent runtimes.""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any + +from quacking.agents.runtime import ( + AgentRequest, + AgentResult, + AgentRuntime, + AgentRuntimeError, + RuntimeCapability, +) + + +def _prompt_from_request(request: AgentRequest) -> str: + """Serialize a role's conversation for a stateless provider call.""" + return "\n\n".join( + f"[{message.role.upper()}]\n{message.content}" for message in request.messages + ) + + +def _validate_structured_output(content: str, schema: dict[str, Any] | None) -> None: + """Perform minimal local validation for providers without schema guarantees.""" + if schema is None: + return + try: + decoded = json.loads(content) + except json.JSONDecodeError as error: + raise AgentRuntimeError(f"Provider returned invalid JSON: {error}") from error + expected_type = schema.get("type") + if expected_type == "object" and not isinstance(decoded, dict): + raise AgentRuntimeError("Provider returned JSON that is not an object") + if expected_type == "array" and not isinstance(decoded, list): + raise AgentRuntimeError("Provider returned JSON that is not an array") + + +class OpenAIAgentsRuntime(AgentRuntime): + """Run tool-free Quacking roles through the OpenAI Agents SDK.""" + + name = "openai_agents" + capabilities = frozenset( + { + RuntimeCapability.STRUCTURED_OUTPUT, + RuntimeCapability.TOOL_USE, + RuntimeCapability.TOKEN_USAGE, + RuntimeCapability.TRACE_ID, + } + ) + + def __init__( + self, + api_key: str, + base_url: str | None = None, + timeout_seconds: float = 900.0, + ) -> None: + self.api_key = api_key + self.base_url = base_url + self.timeout_seconds = timeout_seconds + + async def invoke(self, request: AgentRequest) -> AgentResult: + """Invoke the SDK without provider-directed handoffs or tools.""" + self.validate(request.definition) + try: + from agents import Agent as OpenAIAgent + from agents import RunConfig, Runner + from agents.models.openai_provider import OpenAIProvider + except ImportError as error: + raise AgentRuntimeError( + "OpenAI Agents SDK is not installed. Install the 'openai-agents' dependency." + ) from error + + agent = OpenAIAgent( + name=request.definition.role, + instructions=request.system_prompt, + model=request.model, + ) + run_config = RunConfig( + model_provider=OpenAIProvider(api_key=self.api_key, base_url=self.base_url), + ) + try: + run = await asyncio.wait_for( + Runner.run(agent, _prompt_from_request(request), run_config=run_config), + timeout=request.timeout_seconds or self.timeout_seconds, + ) + except TimeoutError as error: + raise AgentRuntimeError("OpenAI agent run timed out") from error + except Exception as error: + raise AgentRuntimeError(f"OpenAI agent run failed: {error}") from error + + content = str(run.final_output) + _validate_structured_output(content, request.definition.output_schema) + return AgentResult( + content=content, + trace_id=getattr(run, "trace_id", None), + provider_metadata={"runtime": self.name}, + ) + + +class OpenAICompatibleRuntime(AgentRuntime): + """Run a role against an OpenAI-compatible chat-completions endpoint.""" + + name = "openai_compatible" + capabilities = frozenset( + { + RuntimeCapability.STRUCTURED_OUTPUT, + RuntimeCapability.TOKEN_USAGE, + } + ) + + def __init__( + self, + api_key: str, + base_url: str, + timeout_seconds: float = 900.0, + ) -> None: + self.api_key = api_key + self.base_url = base_url + self.timeout_seconds = timeout_seconds + + async def invoke(self, request: AgentRequest) -> AgentResult: + """Invoke a compatible endpoint and validate structured output locally.""" + self.validate(request.definition) + try: + from openai import AsyncOpenAI + except ImportError as error: + raise AgentRuntimeError( + "OpenAI SDK is not installed. Install the 'openai' dependency." + ) from error + + client = AsyncOpenAI(api_key=self.api_key, base_url=self.base_url) + messages = [{"role": "system", "content": request.system_prompt}] + messages.extend( + {"role": message.role, "content": message.content} for message in request.messages + ) + try: + response = await asyncio.wait_for( + client.chat.completions.create( + model=request.model, + messages=messages, + max_completion_tokens=request.max_tokens, + temperature=request.temperature, + ), + timeout=request.timeout_seconds or self.timeout_seconds, + ) + except TimeoutError as error: + raise AgentRuntimeError("Compatible provider request timed out") from error + except Exception as error: + raise AgentRuntimeError(f"Compatible provider request failed: {error}") from error + + content = response.choices[0].message.content + if not content: + raise AgentRuntimeError("Compatible provider returned no content") + _validate_structured_output(content, request.definition.output_schema) + usage = response.usage + return AgentResult( + content=content, + input_tokens=getattr(usage, "prompt_tokens", 0) if usage else 0, + output_tokens=getattr(usage, "completion_tokens", 0) if usage else 0, + trace_id=getattr(response, "id", None), + provider_metadata={"base_url": self.base_url}, + ) diff --git a/src/quacking/agents/prompts.py b/src/quacking/agents/prompts.py new file mode 100644 index 0000000..144e38d --- /dev/null +++ b/src/quacking/agents/prompts.py @@ -0,0 +1,13 @@ +"""Authoritative role-prompt loading.""" + +from pathlib import Path + +PROMPT_DIR = Path(__file__).resolve().parents[3] / "prompts" + + +def load_role_prompt(role: str) -> str: + """Load a version-controlled prompt for a known agent role.""" + path = PROMPT_DIR / f"{role}_agent.md" + if not path.exists(): + raise FileNotFoundError(f"Prompt file not found for role '{role}': {path}") + return path.read_text() diff --git a/src/quacking/agents/runtime.py b/src/quacking/agents/runtime.py new file mode 100644 index 0000000..82b8f81 --- /dev/null +++ b/src/quacking/agents/runtime.py @@ -0,0 +1,122 @@ +"""Provider-neutral runtime contracts for Quacking agents.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any + + +class RuntimeCapability(StrEnum): + """Features a runtime can provide to an agent role.""" + + STRUCTURED_OUTPUT = "structured_output" + TOOL_USE = "tool_use" + PERSISTENT_SESSION = "persistent_session" + TOKEN_USAGE = "token_usage" + TRACE_ID = "trace_id" + + +@dataclass(frozen=True) +class ToolPolicy: + """Runtime-independent tool and filesystem policy for one invocation.""" + + allowed_tools: tuple[str, ...] = () + denied_tools: tuple[str, ...] = () + working_directory: str | None = None + permission_mode: str | None = None + + +@dataclass(frozen=True) +class AgentDefinition: + """Static contract for a Quacking role.""" + + role: str + prompt_id: str + output_schema: dict[str, Any] | None = None + required_capabilities: frozenset[RuntimeCapability] = frozenset() + tool_policy: ToolPolicy = field(default_factory=ToolPolicy) + persistent: bool = False + + +@dataclass(frozen=True) +class RuntimeMessage: + """A provider-neutral conversation message.""" + + role: str + content: str + + +@dataclass(frozen=True) +class AgentRequest: + """A single provider-neutral agent invocation.""" + + definition: AgentDefinition + model: str + system_prompt: str + messages: tuple[RuntimeMessage, ...] + max_tokens: int + temperature: float + timeout_seconds: float | None = None + + +@dataclass(frozen=True) +class AgentResult: + """Normalized response returned by any agent runtime.""" + + content: str + input_tokens: int = 0 + output_tokens: int = 0 + cache_creation_tokens: int = 0 + cache_read_tokens: int = 0 + cost: float | None = None + trace_id: str | None = None + artifacts: tuple[str, ...] = () + provider_metadata: dict[str, Any] = field(default_factory=dict) + + +class AgentRuntimeError(RuntimeError): + """Raised when a runtime cannot complete an agent request.""" + + +class AgentRuntime(ABC): + """Runs an agent request using a provider-specific transport.""" + + name: str + capabilities: frozenset[RuntimeCapability] + + def supports(self, definition: AgentDefinition) -> bool: + """Return whether this runtime fulfills a role's required capabilities.""" + return definition.required_capabilities.issubset(self.capabilities) + + def validate(self, definition: AgentDefinition) -> None: + """Raise a clear error when a role requires unsupported capabilities.""" + missing = definition.required_capabilities - self.capabilities + if missing: + names = ", ".join(sorted(capability.value for capability in missing)) + raise AgentRuntimeError( + f"Runtime '{self.name}' cannot run role '{definition.role}': missing {names}" + ) + + @abstractmethod + async def invoke(self, request: AgentRequest) -> AgentResult: + """Execute a request and return its normalized result.""" + + +class FakeRuntime(AgentRuntime): + """Deterministic runtime for focused orchestration and agent tests.""" + + name = "fake" + capabilities = frozenset(RuntimeCapability) + + def __init__(self, results: list[AgentResult] | None = None) -> None: + self.results = list(results or []) + self.requests: list[AgentRequest] = [] + + async def invoke(self, request: AgentRequest) -> AgentResult: + self.validate(request.definition) + self.requests.append(request) + if not self.results: + raise AgentRuntimeError("FakeRuntime has no configured result") + return self.results.pop(0) diff --git a/src/quacking/cli.py b/src/quacking/cli.py index 64bcd58..44e6fd1 100644 --- a/src/quacking/cli.py +++ b/src/quacking/cli.py @@ -211,8 +211,8 @@ def run(ctx: click.Context, spec: str | None, resume: bool, dry_run: bool, cc_bu if cc_sdk_transport is not None: config.cc_sdk_transport = cc_sdk_transport - # Check for API key - if not config.anthropic_api_key: + # Anthropic credentials are only required for the legacy direct runtime. + if config.runtime.provider == "anthropic" and not config.anthropic_api_key: console.print("[red]Error:[/red] ANTHROPIC_API_KEY not set") console.print("Set the environment variable or add it to your config") sys.exit(1) @@ -1689,8 +1689,8 @@ async def run_orchestration( console.print(f"[cyan]Starting orchestration for project {project_name}[/cyan]") try: - # Check API key first - if not config.anthropic_api_key: + # Anthropic credentials are only required for the legacy direct runtime. + if config.runtime.provider == "anthropic" and not config.anthropic_api_key: raise ValueError( "ANTHROPIC_API_KEY not set. Export the environment variable first." ) diff --git a/src/quacking/core/config.py b/src/quacking/core/config.py index f8e1039..a1b3531 100644 --- a/src/quacking/core/config.py +++ b/src/quacking/core/config.py @@ -194,6 +194,30 @@ class ModelConfig: analysis: str = CLAUDE_HAIKU_4_5 # Lightweight model for step analysis/classification +@dataclass +class AgentRuntimeConfig: + """Select the transport used for role invocations.""" + + provider: str = "anthropic" + claude_executable: str = "claude" + timeout_seconds: float = 900.0 + openai_api_key: str = "" + openai_base_url: str | None = None + umans_api_key: str = "" + umans_base_url: str = "https://api.code.umans.ai/v1" + + +@dataclass +class PlaneTrackingConfig: + """Optional Plane synchronization settings.""" + + enabled: bool = False + base_url: str = "https://plane.swagner.tech" + workspace: str = "" + project_id: str = "" + api_key: str = "" + + @dataclass class PlanningAgentConfig: """Configuration for the Planning Agent.""" @@ -405,6 +429,8 @@ class QuackingConfig: # Models models: ModelConfig = field(default_factory=ModelConfig) + runtime: AgentRuntimeConfig = field(default_factory=AgentRuntimeConfig) + plane: PlaneTrackingConfig = field(default_factory=PlaneTrackingConfig) # Agent settings planning: PlanningAgentConfig = field(default_factory=PlanningAgentConfig) @@ -526,6 +552,27 @@ def _load_from_file(cls, path: Path) -> "QuackingConfig": analysis=models.get("analysis", config.models.analysis), ) + if "runtime" in data: + runtime = data["runtime"] + config.runtime = AgentRuntimeConfig( + provider=runtime.get("provider", config.runtime.provider), + claude_executable=runtime.get( + "claude_executable", config.runtime.claude_executable + ), + timeout_seconds=runtime.get("timeout_seconds", config.runtime.timeout_seconds), + openai_api_key=runtime.get("openai_api_key", config.runtime.openai_api_key), + openai_base_url=runtime.get("openai_base_url", config.runtime.openai_base_url), + umans_api_key=runtime.get("umans_api_key", config.runtime.umans_api_key), + umans_base_url=runtime.get("umans_base_url", config.runtime.umans_base_url), + ) + + if "plane" in data: + plane = data["plane"] + config.plane = PlaneTrackingConfig( + enabled=plane.get("enabled", False), base_url=plane.get("base_url", config.plane.base_url), + workspace=plane.get("workspace", ""), project_id=plane.get("project_id", ""), + ) + # Agent settings if "agents" in data: if "planning" in data["agents"]: @@ -694,6 +741,15 @@ def _apply_env_overrides(cls, config: "QuackingConfig") -> "QuackingConfig": if cc_sdk_env := os.environ.get("QUACKING_CC_SDK_TRANSPORT"): config.cc_sdk_transport = cc_sdk_env.lower() in ("true", "1", "yes") + if runtime_provider := os.environ.get("QUACKING_AGENT_RUNTIME"): + config.runtime.provider = runtime_provider + if openai_api_key := os.environ.get("OPENAI_API_KEY"): + config.runtime.openai_api_key = openai_api_key + if umans_api_key := os.environ.get("UMANS_API_KEY"): + config.runtime.umans_api_key = umans_api_key + config.plane.api_key = os.environ.get("PLANE_API_KEY", config.plane.api_key) + config.plane.workspace = os.environ.get("PLANE_WORKSPACE", config.plane.workspace) + # CC Builder mode if cc_builder_env := os.environ.get("QUACKING_CC_BUILDER"): if cc_builder_env.lower() in ("1", "true", "yes"): @@ -715,7 +771,17 @@ def validate(self) -> list[str]: errors = [] if not self.anthropic_api_key: - errors.append("ANTHROPIC_API_KEY not set") + if self.runtime.provider == "anthropic": + errors.append("ANTHROPIC_API_KEY not set") + + if self.runtime.provider not in {"anthropic", "claude_print", "openai", "umans"}: + errors.append(f"Unknown agent runtime provider: {self.runtime.provider}") + if self.runtime.provider == "openai" and not self.runtime.openai_api_key: + errors.append("OPENAI_API_KEY not set") + if self.runtime.provider == "umans" and not self.runtime.umans_api_key: + errors.append("UMANS_API_KEY not set") + if self.plane.enabled and not all((self.plane.workspace, self.plane.project_id, self.plane.api_key)): + errors.append("Plane tracking requires workspace, project_id, and PLANE_API_KEY") if not self.project_id: errors.append("project_id not configured") @@ -744,6 +810,13 @@ def to_dict(self) -> dict[str, Any]: "spec_manager": self.models.spec_manager, "analysis": self.models.analysis, }, + "runtime": { + "provider": self.runtime.provider, + "claude_executable": self.runtime.claude_executable, + "timeout_seconds": self.runtime.timeout_seconds, + "openai_base_url": self.runtime.openai_base_url, + "umans_base_url": self.runtime.umans_base_url, + }, "agents": { "planning": { "max_tokens": self.planning.max_tokens, diff --git a/src/quacking/core/escalation_handler.py b/src/quacking/core/escalation_handler.py index 4967b13..9b38c02 100644 --- a/src/quacking/core/escalation_handler.py +++ b/src/quacking/core/escalation_handler.py @@ -62,6 +62,7 @@ def __init__( self, escalation_dir: Path, callback: Callable[[Escalation], str | None] | None = None, + on_created: Callable[[Escalation], None] | None = None, ): """ Initialize the escalation handler. @@ -74,6 +75,7 @@ def __init__( self.escalation_dir.mkdir(parents=True, exist_ok=True) self.callback = callback + self.on_created = on_created # In-memory escalation storage self._escalations: dict[str, Escalation] = {} @@ -193,6 +195,8 @@ def create_escalation( self._escalations[escalation.id] = escalation self._persist_escalation(escalation) + if self.on_created: + self.on_created(escalation) return escalation diff --git a/src/quacking/core/orchestrator.py b/src/quacking/core/orchestrator.py index dc25ad0..0564269 100644 --- a/src/quacking/core/orchestrator.py +++ b/src/quacking/core/orchestrator.py @@ -18,6 +18,8 @@ from quacking.agents.builder import BuilderAgent, FileChange from quacking.agents.code_reviewer import CodeReviewerAgent from quacking.agents.planning import PlanningAgent, PlanningResult +from quacking.agents.prompts import load_role_prompt +from quacking.agents.runtime import AgentDefinition, AgentRuntime from quacking.agents.spec_manager import SpecManagerAgent from quacking.agents.spec_reviewer import SpecReviewerAgent from quacking.core.batch_api import BatchAPIClient @@ -44,6 +46,7 @@ ) from quacking.core.persistence import PersistentOrchestrator, WALEntry from quacking.core.work_scheduler import SchedulerError, WorkScheduler +from quacking.core.work_tracker import PlaneWorkTracker, TrackerEvent from quacking.tracking.database import TrackingDatabase from quacking.tracking.metrics import MetricsTracker @@ -152,7 +155,15 @@ def __init__( ) self.history_mgr = HistoryManager(self.quacking_data_dir, self.project_id) self.ci_mgr = CIManager(self.proj_repo) - self.escalation_handler = EscalationHandler(self.quacking_data_dir / "escalations") + self.work_tracker: PlaneWorkTracker | None = None + if config.plane.enabled: + self.work_tracker = PlaneWorkTracker( + self.quacking_data_dir / "plane-outbox.db", config.plane.base_url, + config.plane.workspace, config.plane.project_id, config.plane.api_key, + ) + self.escalation_handler = EscalationHandler( + self.quacking_data_dir / "escalations", on_created=self._track_escalation + ) # WAL persistence for crash recovery self.persistence = PersistentOrchestrator(self.quacking_data_dir) @@ -185,6 +196,8 @@ def __init__( # Batch API support (50% cost savings) self.use_batch_api = config.use_batch_api + if config.runtime.provider != "anthropic": + self.use_batch_api = False self._batch_client: BatchAPIClient | None = None if self.use_batch_api: self._batch_client = BatchAPIClient( @@ -244,9 +257,55 @@ def skip_step(self, step_id: str) -> None: # AGENT PROPERTIES (lazy initialization) # ========================================================================= + def _track_escalation(self, escalation: Any) -> None: + """Queue external human visibility without giving agents Plane access.""" + if self.work_tracker is None: + return + self.work_tracker.enqueue( + TrackerEvent( + key=f"escalation:{escalation.id}", title=f"Quacking escalation {escalation.id}", + description=f"## Question\n{escalation.question}\n\n## Context\n{escalation.context}", + ) + ) + + def _runtime_for(self, role: str) -> tuple[AgentRuntime | None, AgentDefinition | None]: + """Build the selected invocation runtime and a role definition.""" + if self.config.runtime.provider == "anthropic": + return None, None + if self.config.runtime.provider == "claude_print": + from quacking.agents.claude_print import ClaudePrintRuntime + + runtime = ClaudePrintRuntime( + executable=self.config.runtime.claude_executable, + default_timeout_seconds=self.config.runtime.timeout_seconds, + ) + return runtime, AgentDefinition(role=role, prompt_id=role) + if self.config.runtime.provider == "openai": + from quacking.agents.openai_runtime import OpenAIAgentsRuntime + + runtime = OpenAIAgentsRuntime( + api_key=self.config.runtime.openai_api_key, + base_url=self.config.runtime.openai_base_url, + timeout_seconds=self.config.runtime.timeout_seconds, + ) + return runtime, AgentDefinition(role=role, prompt_id=role) + if self.config.runtime.provider == "umans": + from quacking.agents.openai_runtime import OpenAICompatibleRuntime + + runtime = OpenAICompatibleRuntime( + api_key=self.config.runtime.umans_api_key, + base_url=self.config.runtime.umans_base_url, + timeout_seconds=self.config.runtime.timeout_seconds, + ) + return runtime, AgentDefinition(role=role, prompt_id=role) + raise OrchestratorError( + f"Runtime provider '{self.config.runtime.provider}' is not implemented yet" + ) + @property def planning_agent(self) -> PlanningAgent: if self._planning_agent is None: + runtime, definition = self._runtime_for("planning") self._planning_agent = PlanningAgent( model=self.config.models.planning, api_key=self.config.anthropic_api_key, @@ -255,6 +314,9 @@ def planning_agent(self) -> PlanningAgent: batch_client=self._batch_client, use_batch_api=self.use_batch_api, use_cc_sdk_transport=self.config.cc_sdk_transport, + runtime=runtime, + definition=definition, + system_prompt=load_role_prompt("planning"), ) self._planning_agent._on_rate_limit = self._handle_rate_limit return self._planning_agent @@ -262,6 +324,7 @@ def planning_agent(self) -> PlanningAgent: @property def builder_agent(self) -> BuilderAgent: if self._builder_agent is None: + runtime, definition = self._runtime_for("builder") max_tokens = self.config.builder.max_tokens_per_step if max_tokens is None: max_tokens = get_model_max_output_tokens(self.config.models.builder) @@ -277,6 +340,9 @@ def builder_agent(self) -> BuilderAgent: 4096, get_model_max_output_tokens(self.config.models.analysis) ), use_cc_sdk_transport=self.config.cc_sdk_transport, + runtime=runtime, + definition=definition, + system_prompt=load_role_prompt("builder"), ) self._builder_agent._on_rate_limit = self._handle_rate_limit return self._builder_agent @@ -295,13 +361,14 @@ def cc_builder_agent(self) -> Any: @property def active_builder(self) -> Any: """Return the active builder agent (CC or standard based on config).""" - if self.config.cc_builder.enabled: + if self.config.runtime.provider == "anthropic" and self.config.cc_builder.enabled: return self.cc_builder_agent return self.builder_agent @property def spec_reviewer(self) -> SpecReviewerAgent: # Always create fresh instance (ephemeral) + runtime, definition = self._runtime_for("spec_reviewer") agent = SpecReviewerAgent( model=self.config.models.reviewer, api_key=self.config.anthropic_api_key, @@ -310,6 +377,9 @@ def spec_reviewer(self) -> SpecReviewerAgent: batch_client=self._batch_client, use_batch_api=self.use_batch_api, use_cc_sdk_transport=self.config.cc_sdk_transport, + runtime=runtime, + definition=definition, + system_prompt=load_role_prompt("spec_reviewer"), ) agent._on_rate_limit = self._handle_rate_limit return agent @@ -317,6 +387,7 @@ def spec_reviewer(self) -> SpecReviewerAgent: @property def code_reviewer(self) -> CodeReviewerAgent: # Always create fresh instance (ephemeral) + runtime, definition = self._runtime_for("code_reviewer") agent = CodeReviewerAgent( model=self.config.models.reviewer, api_key=self.config.anthropic_api_key, @@ -325,6 +396,9 @@ def code_reviewer(self) -> CodeReviewerAgent: batch_client=self._batch_client, use_batch_api=self.use_batch_api, use_cc_sdk_transport=self.config.cc_sdk_transport, + runtime=runtime, + definition=definition, + system_prompt=load_role_prompt("code_reviewer"), ) agent._on_rate_limit = self._handle_rate_limit return agent @@ -332,12 +406,16 @@ def code_reviewer(self) -> CodeReviewerAgent: @property def spec_manager(self) -> SpecManagerAgent: if self._spec_manager is None: + runtime, definition = self._runtime_for("spec_manager") self._spec_manager = SpecManagerAgent( model=self.config.models.spec_manager, api_key=self.config.anthropic_api_key, batch_client=self._batch_client, use_batch_api=self.use_batch_api, use_cc_sdk_transport=self.config.cc_sdk_transport, + runtime=runtime, + definition=definition, + system_prompt=load_role_prompt("spec_manager"), ) self._spec_manager._on_rate_limit = self._handle_rate_limit return self._spec_manager @@ -744,6 +822,12 @@ async def _tick(self) -> None: """Single iteration of the orchestration loop.""" log = getattr(self, "_log", lambda x: None) + if self.work_tracker is not None: + try: + await asyncio.to_thread(self.work_tracker.flush) + except Exception as error: + self._logger.warning("plane_tracker_sync_failed", error=str(error)) + # Check for pending escalations if self.escalation_handler.has_blocking_escalations(): self._phase = "paused" diff --git a/src/quacking/core/work_tracker.py b/src/quacking/core/work_tracker.py new file mode 100644 index 0000000..554bcf8 --- /dev/null +++ b/src/quacking/core/work_tracker.py @@ -0,0 +1,95 @@ +"""Durable external work-tracker synchronization.""" + +from __future__ import annotations + +import json +import sqlite3 +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +@dataclass(frozen=True) +class TrackerEvent: + """A local lifecycle event projected to an external work tracker.""" + + key: str + title: str + description: str + state: str = "Backlog" + + +class PlaneWorkTracker: + """Synchronize idempotent work items to Plane through a SQLite outbox.""" + + def __init__( + self, + database_path: Path, + base_url: str, + workspace: str, + project_id: str, + api_key: str, + ) -> None: + self.database_path = database_path + self.base_url = base_url.rstrip("/") + self.workspace = workspace + self.project_id = project_id + self.api_key = api_key + database_path.parent.mkdir(parents=True, exist_ok=True) + with self._connection() as conn: + conn.execute( + """CREATE TABLE IF NOT EXISTS plane_outbox ( + key TEXT PRIMARY KEY, title TEXT NOT NULL, description TEXT NOT NULL, + state TEXT NOT NULL, plane_issue_id TEXT, delivered INTEGER NOT NULL DEFAULT 0)""" + ) + + def _connection(self) -> sqlite3.Connection: + return sqlite3.connect(self.database_path) + + def enqueue(self, event: TrackerEvent) -> None: + """Persist an event before attempting any external mutation.""" + with self._connection() as conn: + conn.execute( + """INSERT INTO plane_outbox (key, title, description, state, delivered) + VALUES (?, ?, ?, ?, 0) + ON CONFLICT(key) DO UPDATE SET title=excluded.title, description=excluded.description, + state=excluded.state, delivered=0""", + (event.key, event.title, event.description, event.state), + ) + + def flush(self) -> None: + """Deliver queued records. Failures leave records pending for retry.""" + with self._connection() as conn: + rows = conn.execute( + "SELECT key, title, description, state, plane_issue_id FROM plane_outbox WHERE delivered = 0" + ).fetchall() + for key, title, description, state, plane_issue_id in rows: + if plane_issue_id: + self._request("PATCH", f"/issues/{plane_issue_id}/", {"description_html": description}) + else: + response = self._request( + "POST", + "/issues/", + {"name": title, "description_html": description}, + ) + plane_issue_id = response["id"] + conn.execute( + "UPDATE plane_outbox SET plane_issue_id = ?, delivered = 1 WHERE key = ?", + (plane_issue_id, key), + ) + + def _request(self, method: str, path: str, payload: dict[str, Any]) -> dict[str, Any]: + body = json.dumps(payload).encode() + request = urllib.request.Request( + f"{self.base_url}/api/v1/workspaces/{self.workspace}/projects/{self.project_id}{path}", + data=body, + method=method, + headers={ + "X-API-Key": self.api_key, + "Content-Type": "application/json", + "User-Agent": "quacking", + }, + ) + with urllib.request.urlopen(request, timeout=30) as response: + return json.loads(response.read().decode()) diff --git a/tests/test_agents_claude_print.py b/tests/test_agents_claude_print.py new file mode 100644 index 0000000..03d382a --- /dev/null +++ b/tests/test_agents_claude_print.py @@ -0,0 +1,55 @@ +"""Tests for the Claude print-mode runtime.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from quacking.agents.claude_print import ClaudePrintRuntime +from quacking.agents.runtime import AgentDefinition, AgentRequest, RuntimeMessage, ToolPolicy + + +def make_request() -> AgentRequest: + return AgentRequest( + definition=AgentDefinition( + role="reviewer", + prompt_id="reviewer", + output_schema={"type": "object"}, + tool_policy=ToolPolicy(working_directory="/tmp/project"), + ), + model="claude-test", + system_prompt="system prompt", + messages=(RuntimeMessage(role="user", content="review this"),), + max_tokens=100, + temperature=0.0, + ) + + +@pytest.mark.asyncio +async def test_claude_print_invokes_cli_with_structured_output() -> None: + process = AsyncMock() + process.communicate.return_value = ( + b'{"result":"done","usage":{"input_tokens":3,"output_tokens":2}}', + b"", + ) + process.returncode = 0 + + with patch("asyncio.create_subprocess_exec", return_value=process) as create_process: + result = await ClaudePrintRuntime().invoke(make_request()) + + assert result.content == "done" + assert result.input_tokens == 3 + command = create_process.call_args.args + assert command[:4] == ("claude", "-p", "--output-format", "json") + assert "--json-schema" in command + assert create_process.call_args.kwargs["cwd"] == "/tmp/project" + + +@pytest.mark.asyncio +async def test_claude_print_reports_cli_failures() -> None: + process = AsyncMock() + process.communicate.return_value = (b"", b"authentication failed") + process.returncode = 1 + + with patch("asyncio.create_subprocess_exec", return_value=process): + with pytest.raises(RuntimeError, match="authentication failed"): + await ClaudePrintRuntime().invoke(make_request()) diff --git a/tests/test_agents_openai_runtime.py b/tests/test_agents_openai_runtime.py new file mode 100644 index 0000000..00d0632 --- /dev/null +++ b/tests/test_agents_openai_runtime.py @@ -0,0 +1,55 @@ +"""Tests for OpenAI and OpenAI-compatible runtimes.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from quacking.agents.openai_runtime import OpenAIAgentsRuntime, OpenAICompatibleRuntime +from quacking.agents.runtime import AgentDefinition, AgentRequest, RuntimeMessage + + +def make_request(schema: dict | None = None) -> AgentRequest: + return AgentRequest( + definition=AgentDefinition(role="planner", prompt_id="planner", output_schema=schema), + model="test-model", + system_prompt="system prompt", + messages=(RuntimeMessage(role="user", content="plan this"),), + max_tokens=100, + temperature=0.0, + ) + + +@pytest.mark.asyncio +async def test_openai_agents_runtime_uses_configured_provider() -> None: + result = SimpleNamespace(final_output="{\"ok\": true}", trace_id="trace-1") + + with patch("agents.Runner.run", new=AsyncMock(return_value=result)) as run: + response = await OpenAIAgentsRuntime(api_key="key").invoke( + make_request({"type": "object"}) + ) + + assert response.content == '{"ok": true}' + assert response.trace_id == "trace-1" + assert run.await_count == 1 + + +@pytest.mark.asyncio +async def test_openai_compatible_runtime_validates_json() -> None: + completion = SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content="{\"ok\": true}"))], + usage=SimpleNamespace(prompt_tokens=4, completion_tokens=3), + id="completion-1", + ) + client = SimpleNamespace( + chat=SimpleNamespace(completions=SimpleNamespace(create=AsyncMock(return_value=completion))) + ) + + with patch("openai.AsyncOpenAI", return_value=client): + response = await OpenAICompatibleRuntime( + api_key="key", base_url="https://example.test/v1" + ).invoke(make_request({"type": "object"})) + + assert response.content == '{"ok": true}' + assert response.input_tokens == 4 + assert response.output_tokens == 3 diff --git a/tests/test_agents_prompts.py b/tests/test_agents_prompts.py new file mode 100644 index 0000000..574b902 --- /dev/null +++ b/tests/test_agents_prompts.py @@ -0,0 +1,7 @@ +"""Tests for version-controlled role prompts.""" + +from quacking.agents.prompts import load_role_prompt + + +def test_load_role_prompt_reads_authoritative_file() -> None: + assert "Planning Agent" in load_role_prompt("planning") diff --git a/tests/test_agents_runtime.py b/tests/test_agents_runtime.py new file mode 100644 index 0000000..75da647 --- /dev/null +++ b/tests/test_agents_runtime.py @@ -0,0 +1,75 @@ +"""Tests for provider-neutral agent runtime contracts.""" + +from unittest.mock import patch + +import pytest + +from quacking.agents.base import Agent +from quacking.agents.runtime import ( + AgentDefinition, + AgentResult, + FakeRuntime, + RuntimeCapability, +) + + +class RuntimeTestAgent(Agent): + """Minimal concrete agent for runtime tests.""" + + @property + def agent_type(self) -> str: + return "runtime_test" + + def _default_system_prompt(self) -> str: + return "Runtime test system prompt" + + +@pytest.mark.asyncio +async def test_agent_invokes_configured_runtime() -> None: + runtime = FakeRuntime( + [ + AgentResult( + content="runtime response", + input_tokens=12, + output_tokens=8, + trace_id="trace-123", + ) + ] + ) + definition = AgentDefinition( + role="runtime_test", + prompt_id="runtime_test", + required_capabilities=frozenset({RuntimeCapability.STRUCTURED_OUTPUT}), + ) + + with patch("quacking.agents.base.anthropic.AsyncAnthropic"): + agent = RuntimeTestAgent( + model="test-model", + runtime=runtime, + definition=definition, + use_batch_api=True, + batch_client=object(), + ) + + response, metrics = await agent.invoke("hello") + + assert response == "runtime response" + assert metrics.input_tokens == 12 + assert metrics.output_tokens == 8 + assert agent.use_batch_api is False + assert len(runtime.requests) == 1 + assert runtime.requests[0].messages[-1].content == "hello" + + +def test_agent_rejects_unsupported_runtime_capabilities() -> None: + runtime = FakeRuntime() + runtime.capabilities = frozenset() + definition = AgentDefinition( + role="runtime_test", + prompt_id="runtime_test", + required_capabilities=frozenset({RuntimeCapability.TOOL_USE}), + ) + + with patch("quacking.agents.base.anthropic.AsyncAnthropic"): + with pytest.raises(RuntimeError, match="missing tool_use"): + RuntimeTestAgent(model="test-model", runtime=runtime, definition=definition) diff --git a/tests/test_work_tracker.py b/tests/test_work_tracker.py new file mode 100644 index 0000000..6737d7c --- /dev/null +++ b/tests/test_work_tracker.py @@ -0,0 +1,27 @@ +"""Tests for durable Plane work-tracker delivery.""" + +from unittest.mock import patch + +from quacking.core.work_tracker import PlaneWorkTracker, TrackerEvent + + +def test_plane_tracker_retries_and_updates_existing_item(tmp_path) -> None: + tracker = PlaneWorkTracker( + tmp_path / "outbox.db", "https://plane.example", "workspace", "project", "key" + ) + tracker.enqueue(TrackerEvent("escalation:1", "Escalation", "First description")) + + with patch.object(tracker, "_request", return_value={"id": "issue-1"}) as request: + tracker.flush() + + request.assert_called_once_with( + "POST", "/issues/", {"name": "Escalation", "description_html": "First description"} + ) + + tracker.enqueue(TrackerEvent("escalation:1", "Escalation", "Updated description")) + with patch.object(tracker, "_request", return_value={}) as request: + tracker.flush() + + request.assert_called_once_with( + "PATCH", "/issues/issue-1/", {"description_html": "Updated description"} + ) diff --git a/uv.lock b/uv.lock index 80185bf..7f80ae4 100644 --- a/uv.lock +++ b/uv.lock @@ -614,6 +614,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" }, ] +[[package]] +name = "griffelib" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234, upload-time = "2026-06-19T12:05:42.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -1191,6 +1200,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ad/0d/eca3d962f9eef265f01a8e0d20085c6dd1f443cbffc11b6dede81fd82356/numpy-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6436cffb4f2bf26c974344439439c95e152c9a527013f26b3577be6c2ca64295", size = 10667121, upload-time = "2026-01-10T06:44:41.644Z" }, ] +[[package]] +name = "openai" +version = "2.46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/ac/f725c4efbda8657d02be684607e5a2e5ce362e4790fdbcbdfb7c15018647/openai-2.46.0.tar.gz", hash = "sha256:0421e0735ac41451cad894af4cddf0435bfbf8cbc538ac0e15b3c062f2ddc06a", size = 1114628, upload-time = "2026-07-17T02:48:06.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/7b/206238ebcb50b235942b1c66dba4974776f2057402a8d91c399be587d66a/openai-2.46.0-py3-none-any.whl", hash = "sha256:672381db55efb3a1e2610f29304c130cccdd0b319bace4d492b2443cb64c1e7c", size = 1637556, upload-time = "2026-07-17T02:48:03.695Z" }, +] + +[[package]] +name = "openai-agents" +version = "0.18.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, + { name = "mcp" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/3f/b1162cad8720fafc9cf658d6896027385967f5006adfb92ae0dab2b54a70/openai_agents-0.18.3.tar.gz", hash = "sha256:e637f5f5a50692ccbedb0e4f7f2e4f8e2facfcddd41142f35faf90c89b700fc3", size = 5577652, upload-time = "2026-07-17T03:40:25.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/7c/08b9929a15131081898e38c5612d44ee293851d5c80f31ca1153efe82143/openai_agents-0.18.3-py3-none-any.whl", hash = "sha256:c6ed971fdeb34d39a9931787bd3960c1e84dc5d7345705794cc5cab8a1158d07", size = 880799, upload-time = "2026-07-17T03:40:23.163Z" }, +] + [[package]] name = "orjson" version = "3.11.7" @@ -1860,6 +1906,8 @@ dependencies = [ { name = "anthropic" }, { name = "claude-code-sdk" }, { name = "click" }, + { name = "openai" }, + { name = "openai-agents" }, { name = "pydantic" }, { name = "python-dotenv" }, { name = "rich" }, @@ -1903,6 +1951,8 @@ requires-dist = [ { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10.0" }, { name = "networkx", marker = "extra == 'gui-next'", specifier = ">=3.0" }, { name = "nicegui", marker = "extra == 'gui-next'", specifier = ">=2.0.0" }, + { name = "openai", specifier = ">=1.0.0" }, + { name = "openai-agents", specifier = ">=0.14.0" }, { name = "plotly", marker = "extra == 'gui-next'", specifier = ">=5.0.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.0.0" }, { name = "pydantic", specifier = ">=2.0.0" }, @@ -2196,6 +2246,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/50/49/8dc3fd90902f70084bd2cd059d576ddb4f8bb44c2c7c0e33a11422acb17e/tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1", size = 445910, upload-time = "2025-12-15T19:21:02.571Z" }, ] +[[package]] +name = "tqdm" +version = "4.69.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/69/40407dfc835517f058b603dbf37a6df094d8582b015a51eddc988febbcb7/tqdm-4.69.0.tar.gz", hash = "sha256:700c5e85dcd5f009dd6222588a29180a193a748247a5d855b4d67db93d79a53b", size = 792569, upload-time = "2026-07-17T18:09:06.2Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/21/99a0cdaf54eb35e77623c41b5a2c9472ee4404bba687052791fe2aba6773/tqdm-4.69.0-py3-none-any.whl", hash = "sha256:9979978912be667a6ef21fd5d8abf54e324e63d82f7f43c360792ebc2bc4e622", size = 676680, upload-time = "2026-07-17T18:09:04.172Z" }, +] + [[package]] name = "types-toml" version = "0.10.8.20240310"