Skip to content
Open
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
14 changes: 13 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down
12 changes: 5 additions & 7 deletions prompts/builder_agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---
Expand All @@ -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)

Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion prompts/code_reviewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
94 changes: 92 additions & 2 deletions src/quacking/agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
136 changes: 136 additions & 0 deletions src/quacking/agents/claude_print.py
Original file line number Diff line number Diff line change
@@ -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},
)
Loading
Loading