Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ ANTHROPIC_API_KEY=sk-ant-api03-...
# Google Gemini
# GOOGLE_API_KEY=...

# Z.AI / Zhipu AI (GLM-4.7, GLM-4.5, etc.) — direct access without OpenRouter
# Z.AI / Zhipu AI (GLM-4.7, GLM-4.5, GLM-5, etc.) — direct access without OpenRouter
# Get your key at https://z.ai/manage-apikey/apikey-list
# ZHIPU_API_KEY=...

Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ services:
- NODE_ID=swe-planner
- PORT=8003
- AGENT_CALLBACK_URL=http://swe-agent:8003
- ZHIPU_API_KEY=${ZHIPU_API_KEY:-}
ports:
- "8003:8003"
volumes:
Expand All @@ -44,6 +45,7 @@ services:
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- ZHIPU_API_KEY=${ZHIPU_API_KEY:-}
- OPENCODE_MODEL=${OPENCODE_MODEL:-}
ports:
- "8004:8004"
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ dependencies = [
]

[project.optional-dependencies]
dev = ["pytest", "ruff"]
dev = ["pytest", "pytest-asyncio", "ruff"]

[project.scripts]
swe-af = "swe_af.app:main"
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
#
# Install: python -m pip install -r requirements.txt

agentfield>=0.1.9
agentfield>=0.1.41
pydantic>=2.0
claude-agent-sdk>=0.1.20
63 changes: 56 additions & 7 deletions swe_af/agent_ai/providers/opencode/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@

import asyncio
import json
import logging
import os
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import IO, Any, Type, TypeVar

from pydantic import BaseModel
from pydantic import BaseModel, ValidationError

from swe_af.agent_ai.types import (
AgentResponse,
Expand All @@ -24,6 +25,8 @@

T = TypeVar("T", bound=BaseModel)

logger = logging.getLogger(__name__)

_TRANSIENT_PATTERNS = frozenset(
{
"rate limit",
Expand Down Expand Up @@ -84,12 +87,17 @@ def _build_schema_suffix(output_path: str, schema_json: str) -> str:

def _read_and_parse_json_file(path: str, schema: Type[T]) -> T | None:
"""Read a JSON file and parse against schema. Returns None on failure."""
text = ""
try:
if not os.path.exists(path):
logger.debug("Schema output file does not exist: %s", path)
return None
with open(path, "r", encoding="utf-8") as f:
raw = f.read()
text = raw.strip()
if not text:
logger.debug("Schema output file is empty: %s", path)
return None
# Strip markdown fences if present
if text.startswith("```"):
lines = text.split("\n", 1)
Expand All @@ -98,8 +106,28 @@ def _read_and_parse_json_file(path: str, schema: Type[T]) -> T | None:
text = text[: -len("```")]
text = text.strip()
data = json.loads(text)
logger.debug(
"Schema output parsed successfully (keys=%s)",
list(data.keys()) if isinstance(data, dict) else type(data).__name__,
)
return schema.model_validate(data)
except Exception:
except FileNotFoundError:
logger.debug("Schema output file not found: %s", path)
return None
except json.JSONDecodeError as e:
logger.warning(
"JSON decode error parsing schema output: %s | Raw content (first 500 chars): %r",
e,
text[:500],
)
return None
except ValidationError as e:
logger.warning("Pydantic validation error parsing schema output: %s", e)
return None
except Exception as e:
logger.warning(
"Unexpected error parsing schema output: %s: %s", type(e).__name__, e
)
return None


Expand Down Expand Up @@ -182,7 +210,9 @@ async def run(
effective_model = model or cfg.model
effective_cwd = str(cwd or cfg.cwd)
effective_turns = max_turns or cfg.max_turns
effective_tools = allowed_tools if allowed_tools is not None else list(cfg.allowed_tools)
effective_tools = (
allowed_tools if allowed_tools is not None else list(cfg.allowed_tools)
)
effective_retries = max_retries if max_retries is not None else cfg.max_retries
effective_env = {**cfg.env, **(env or {})}
effective_system = system_prompt or cfg.system_prompt
Expand Down Expand Up @@ -302,7 +332,9 @@ async def _run_with_retries(

# Structured output parsing failed
if log_fh:
_write_log(log_fh, "end", is_error=True, reason="schema parse failed")
_write_log(
log_fh, "end", is_error=True, reason="schema parse failed"
)
return AgentResponse(
result=response.result,
parsed=None,
Expand All @@ -329,8 +361,12 @@ async def _run_with_retries(
if output_schema:
output_path = _schema_output_path(effective_cwd)
temp_files.append(output_path)
schema_json = json.dumps(output_schema.model_json_schema(), indent=2)
final_prompt = prompt + _build_schema_suffix(output_path, schema_json)
schema_json = json.dumps(
output_schema.model_json_schema(), indent=2
)
final_prompt = prompt + _build_schema_suffix(
output_path, schema_json
)
continue
# Non-transient error or out of retries
if log_fh:
Expand Down Expand Up @@ -387,8 +423,21 @@ async def _execute(
stdout_text = stdout_b.decode("utf-8", errors="replace")
stderr_text = stderr_b.decode("utf-8", errors="replace")

logger.debug(
"opencode _execute completed: exit_code=%s, stdout_len=%d, stderr_len=%d, duration_ms=%s",
proc.returncode,
len(stdout_text),
len(stderr_text),
duration_ms,
)
logger.debug("opencode stdout (first 2000 chars): %r", stdout_text[:2000])
if stderr_text.strip():
logger.debug("opencode stderr (first 2000 chars): %r", stderr_text[:2000])

if proc.returncode != 0:
error_msg = f"opencode failed with exit code {proc.returncode}: {stderr_text}"
error_msg = (
f"opencode failed with exit code {proc.returncode}: {stderr_text}"
)
raise RuntimeError(error_msg)

# Parse output - OpenCode writes response to stdout
Expand Down
17 changes: 12 additions & 5 deletions swe_af/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -658,7 +658,13 @@ async def plan(
permission_mode=permission_mode,
ai_provider=ai_provider,
))
writer_results = await asyncio.gather(*writer_tasks, return_exceptions=True)
writer_results_raw = await asyncio.gather(*writer_tasks, return_exceptions=True)
writer_results: list[Any] = []
for r in writer_results_raw:
if isinstance(r, Exception):
writer_results.append(r)
else:
writer_results.append(_unwrap(r, f"{NODE_ID}.run_issue_writer"))

succeeded = sum(1 for r in writer_results if isinstance(r, dict) and r.get("success"))
failed = len(writer_results) - succeeded
Expand Down Expand Up @@ -717,11 +723,12 @@ async def execute(
if execute_fn_target:
# External coder agent (existing path)
async def execute_fn(issue, dag_state):
return await app.call(
raw = await app.call(
execute_fn_target,
issue=issue,
repo_path=dag_state.repo_path,
)
return _unwrap(raw, execute_fn_target)
else:
# Built-in coding loop — dag_executor will use call_fn + coding_loop
execute_fn = None
Expand Down Expand Up @@ -769,7 +776,7 @@ async def resume_build(
rationale_path = os.path.join(base, "rationale.md")

# We need the plan_result dict — reconstruct from checkpoint's DAGState
with open(plan_path, "r") as f:
with open(plan_path, "r", encoding="utf-8") as f:
checkpoint = json.load(f)

plan_result = {
Expand All @@ -785,7 +792,7 @@ async def resume_build(

app.note("Resuming build from checkpoint", tags=["build", "resume"])

result = await app.call(
raw = await app.call(
f"{NODE_ID}.execute",
plan_result=plan_result,
repo_path=repo_path,
Expand All @@ -794,7 +801,7 @@ async def resume_build(
resume=True,
)

return result
return _unwrap(raw, f"{NODE_ID}.execute")


def main():
Expand Down
3 changes: 2 additions & 1 deletion swe_af/execution/dag_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -888,14 +888,15 @@ async def _invoke_replanner_via_call(
"adaptations": [a.model_dump() for a in f.adaptations],
})

decision_dict = await call_fn(
raw = await call_fn(
f"{node_id}.run_replanner",
dag_state=dag_state.model_dump(),
failed_issues=[f.model_dump() for f in unrecoverable],
replan_model=config.replan_model,
ai_provider=config.ai_provider,
escalation_notes=escalation_notes,
)
decision_dict = unwrap_call_result(raw, f"{node_id}.run_replanner")
return ReplanDecision(**decision_dict)


Expand Down
2 changes: 1 addition & 1 deletion swe_af/fast/planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def _note(msg: str, tags: list[str] | None = None) -> None:
"""Log a message via fast_router.note() when attached, else fall back to logger."""
try:
fast_router.note(msg, tags=tags or [])
except RuntimeError:
except Exception: # noqa: BLE001
logger.debug("[fast_planner] %s (tags=%s)", msg, tags)


Expand Down
4 changes: 3 additions & 1 deletion swe_af/fast/verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import logging
from typing import Any

from swe_af.execution.envelope import unwrap_call_result as _unwrap
from swe_af.fast import fast_router
from swe_af.fast.schemas import FastVerificationResult

Expand Down Expand Up @@ -47,7 +48,7 @@ async def fast_verify(
else:
failed_issues.append(entry)

result: dict[str, Any] = await _app.app.call(
raw = await _app.app.call(
f"{_app.NODE_ID}.run_verifier",
prd=prd,
repo_path=repo_path,
Expand All @@ -59,6 +60,7 @@ async def fast_verify(
permission_mode=permission_mode,
ai_provider=ai_provider,
)
result = _unwrap(raw, f"{_app.NODE_ID}.run_verifier")
verification = FastVerificationResult(
passed=result.get("passed", False),
summary=result.get("summary", ""),
Expand Down
6 changes: 3 additions & 3 deletions tests/fast/test_app_planner_executor_verifier_wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
import os
import sys
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import MagicMock, patch

import pytest

Expand Down Expand Up @@ -102,8 +102,8 @@ class TestAppStubState:

def test_app_module_is_importable(self) -> None:
"""swe_af.fast.app must import without error (AC-1)."""
import swe_af.fast.app # noqa: F401, PLC0415
assert swe_af.fast.app is not None
import swe_af.fast.app as fast_app_module # noqa: F401, PLC0415
assert fast_app_module is not None

def test_app_module_has_app_attribute(self) -> None:
"""swe_af.fast.app must expose an 'app' AgentField node (AC-8).
Expand Down
Loading