diff --git a/containers/agent-pod/healthz.py b/containers/agent-pod/healthz.py index a1eb19b..d71e03f 100644 --- a/containers/agent-pod/healthz.py +++ b/containers/agent-pod/healthz.py @@ -5,11 +5,17 @@ GET /activity → POST-only sentinel: bumps /workspace/.last-activity so the idle watcher resets. """ + from __future__ import annotations -import http.server, os, shutil, socketserver, time +import http.server +import os +import shutil +import socketserver +import time LAST_ACTIVITY = "/workspace/.last-activity" + class Handler(http.server.BaseHTTPRequestHandler): def _ok(self, body=b"ok"): self.send_response(200) @@ -35,6 +41,7 @@ def do_POST(self): def log_message(self, *args, **kwargs): pass # quiet probes + if __name__ == "__main__": port = int(os.environ.get("HEALTHZ_PORT", "8081")) with socketserver.TCPServer(("0.0.0.0", port), Handler) as srv: diff --git a/containers/model-gateway/refresh-aad-token.py b/containers/model-gateway/refresh-aad-token.py index 3599ded..9f6328e 100644 --- a/containers/model-gateway/refresh-aad-token.py +++ b/containers/model-gateway/refresh-aad-token.py @@ -5,14 +5,19 @@ azure-workload-identity webhook), exchanges it for a Cognitive Services access token, and writes it to /etc/aad/token plus the AZURE_AD_TOKEN env file. """ + from __future__ import annotations -import os, time, pathlib, sys +import time +import pathlib +import sys from azure.identity import DefaultAzureCredential SCOPE = "https://cognitiveservices.azure.com/.default" -OUT_DIR = pathlib.Path("/etc/aad"); OUT_DIR.mkdir(parents=True, exist_ok=True) +OUT_DIR = pathlib.Path("/etc/aad") +OUT_DIR.mkdir(parents=True, exist_ok=True) TOKEN_FILE = OUT_DIR / "token" -ENV_FILE = OUT_DIR / "env" +ENV_FILE = OUT_DIR / "env" + def refresh() -> int: cred = DefaultAzureCredential() @@ -20,9 +25,12 @@ def refresh() -> int: TOKEN_FILE.write_text(tok.token) ENV_FILE.write_text(f"AZURE_AD_TOKEN={tok.token}\n") expires_in = max(60, tok.expires_on - int(time.time()) - 300) # refresh 5 min early - print(f"[refresh-aad] token len={len(tok.token)} expires_in={expires_in}s", flush=True) + print( + f"[refresh-aad] token len={len(tok.token)} expires_in={expires_in}s", flush=True + ) return expires_in + if __name__ == "__main__": while True: try: diff --git a/examples/run.py b/examples/run.py index 1656503..88c138b 100644 --- a/examples/run.py +++ b/examples/run.py @@ -80,8 +80,11 @@ async def main() -> None: foundry = os.getenv("CLAUDE_CODE_USE_FOUNDRY") == "1" print( "▶ Claude backend: " - + (f"Microsoft Foundry ({os.getenv('ANTHROPIC_FOUNDRY_RESOURCE','?')})" - if foundry else "Anthropic public API") + + ( + f"Microsoft Foundry ({os.getenv('ANTHROPIC_FOUNDRY_RESOURCE','?')})" + if foundry + else "Anthropic public API" + ) ) print("▶ Provider routing:") for role, provider in routing.items(): @@ -98,7 +101,9 @@ async def main() -> None: exec_id = getattr(event, "executor_id", "") # Only treat as error if the attribute is actually set data, # not the inherited WorkflowEvent.error class method. - err_attr = event.__dict__.get("error") or event.__dict__.get("exception") + err_attr = event.__dict__.get("error") or event.__dict__.get( + "exception" + ) if err_attr is not None: line = f"{kind}: {exec_id} ERROR={err_attr!r}" print(f"!! {line}") diff --git a/src/code_forge/__init__.py b/src/code_forge/__init__.py index 42120b4..27e93b6 100644 --- a/src/code_forge/__init__.py +++ b/src/code_forge/__init__.py @@ -1,2 +1,3 @@ """Code Forge — Microsoft Agent Framework graph workflow.""" + __version__ = "0.1.0" diff --git a/src/code_forge/agents.py b/src/code_forge/agents.py index 63a591c..847445d 100644 --- a/src/code_forge/agents.py +++ b/src/code_forge/agents.py @@ -113,7 +113,9 @@ def _make_openai_agent(client: Any, name: str, instructions: str) -> BaseAgent: return client.as_agent(name=name, instructions=instructions) -def _make_claude_agent(name: str, instructions: str, sandbox_root: Path | None = None) -> BaseAgent: +def _make_claude_agent( + name: str, instructions: str, sandbox_root: Path | None = None +) -> BaseAgent: """Build a ClaudeAgent backed by the Claude Code CLI / Claude Agent SDK. Two production-grade behaviours wired here: @@ -226,7 +228,9 @@ def _resolve_routing() -> dict[str, str]: return routing -def build_agents(sandbox_root: Path | None = None) -> tuple[dict[str, BaseAgent], dict[str, str]]: +def build_agents( + sandbox_root: Path | None = None, +) -> tuple[dict[str, BaseAgent], dict[str, str]]: """Instantiate the five role agents and return (agents, routing). When ``sandbox_root`` is provided, every Claude-backed agent gets its own @@ -244,7 +248,9 @@ def build_agents(sandbox_root: Path | None = None) -> tuple[dict[str, BaseAgent] provider = routing[role] display_name = role.replace("_", " ").title().replace(" ", "") if provider == "claude": - agents[role] = _make_claude_agent(display_name, instructions, sandbox_root=sandbox_root) + agents[role] = _make_claude_agent( + display_name, instructions, sandbox_root=sandbox_root + ) else: assert openai_client is not None agents[role] = _make_openai_agent(openai_client, display_name, instructions) diff --git a/src/code_forge/workflow.py b/src/code_forge/workflow.py index 17929dd..995981d 100644 --- a/src/code_forge/workflow.py +++ b/src/code_forge/workflow.py @@ -44,8 +44,8 @@ from __future__ import annotations import re -from dataclasses import dataclass, field -from typing import Any +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any from agent_framework import ( AgentExecutorRequest, @@ -62,6 +62,11 @@ from .agents import build_agents +if TYPE_CHECKING: + from pathlib import Path + + from agent_framework import BaseAgent + # --------------------------------------------------------------------------- # Message types flowing through the graph # --------------------------------------------------------------------------- @@ -130,9 +135,7 @@ def _strip_code_fence(text: str) -> str: def _parse_verdict(text: str) -> str: """Find VERDICT: APPROVED|CHANGES_REQUESTED in the security report.""" - m = re.search( - r"VERDICT\s*:\s*(APPROVED|CHANGES_REQUESTED)", text, re.IGNORECASE - ) + m = re.search(r"VERDICT\s*:\s*(APPROVED|CHANGES_REQUESTED)", text, re.IGNORECASE) return m.group(1).upper() if m else "CHANGES_REQUESTED" @@ -192,9 +195,7 @@ async def run( # One outbound message per branch — fan-out via multiple edges. await ctx.send_message( - _as_request( - "Write pytest tests for the following.\n\n" + package - ), + _as_request("Write pytest tests for the following.\n\n" + package), target_id="test_writer", ) await ctx.send_message( @@ -206,9 +207,7 @@ async def run( target_id="security_reviewer", ) await ctx.send_message( - _as_request( - "Write a README section for the following.\n\n" + package - ), + _as_request("Write a README section for the following.\n\n" + package), target_id="doc_writer", ) @@ -367,8 +366,7 @@ def build_workflow( isolated sub-directory under it (one per role) plus bash sandboxing so concurrent file/bash operations can't collide. """ - from agent_framework import BaseAgent # noqa: F401 (for the type hint above) - from pathlib import Path # noqa: F401 (forward-ref above) + from agent_framework import AgentExecutor agents, routing = build_agents(sandbox_root=sandbox_root) @@ -383,8 +381,6 @@ def build_workflow( finalize = Finalize(id="finalize") # Wrap agents with stable ids that match the target_ids used in fanout. - from agent_framework import AgentExecutor - spec_analyst = AgentExecutor(agents["spec_analyst"], id="spec_analyst") implementer = AgentExecutor(agents["implementer"], id="implementer") test_writer = AgentExecutor(agents["test_writer"], id="test_writer") @@ -413,9 +409,7 @@ def build_workflow( builder.add_edge(test_writer, tagged_tests) builder.add_edge(security_reviewer, tagged_security) builder.add_edge(doc_writer, tagged_docs) - builder.add_fan_in_edges( - [tagged_tests, tagged_security, tagged_docs], aggregator - ) + builder.add_fan_in_edges([tagged_tests, tagged_security, tagged_docs], aggregator) # Switch-case on verdict + revision count. def _changes_and_under_cap(r: AggregatedReview) -> bool: diff --git a/tests/test_graph_smoke.py b/tests/test_graph_smoke.py new file mode 100644 index 0000000..c8f70fa --- /dev/null +++ b/tests/test_graph_smoke.py @@ -0,0 +1,205 @@ +"""Graph-build smoke tests. + +These tests prove the workflow graph wires up correctly under each +provider mode without making any network calls. They do NOT exercise the +LLMs themselves — they assert structural correctness: + + * `build_workflow()` returns the (workflow, agents, routing) triple + * Every documented executor ID is present in `Workflow.executors` + * Provider routing honours `CODE_FORGE_PROVIDERS=all-openai` + * Every executor is reachable from the start node via `edge_groups` + +This is the layer of correctness CI can guarantee deterministically. Real +end-to-end runs against a live model belong in a manual smoke job, not on +every pull request. +""" + +from __future__ import annotations + +import os +from collections import deque + +# CODE_FORGE_PROVIDERS=all-openai must be set BEFORE importing code_forge so +# the agent factory doesn't import the optional Claude path. +os.environ.setdefault("CODE_FORGE_PROVIDERS", "all-openai") +os.environ.setdefault("OPENAI_API_KEY", "sk-ci-fake-not-used") +os.environ.setdefault("OPENAI_CHAT_MODEL_ID", "gpt-4o-mini") + + +# --------------------------------------------------------------------------- +# 1. Imports +# --------------------------------------------------------------------------- + + +def test_imports_succeed(): + """The package and its public surface import without optional deps.""" + from code_forge import agents, workflow # noqa: F401 + from code_forge.workflow import ( # noqa: F401 + AggregatedReview, + CodePackage, + FinalArtifact, + ReviewBundle, + build_workflow, + ) + + +# --------------------------------------------------------------------------- +# 2. Builder +# --------------------------------------------------------------------------- + + +def test_build_workflow_returns_expected_triple(): + """Builder returns (Workflow, agents dict, routing dict).""" + from agent_framework import Workflow + + from code_forge.workflow import build_workflow + + wf, agents, routing = build_workflow() + + assert isinstance(wf, Workflow) + assert isinstance(agents, dict) and agents + assert isinstance(routing, dict) and routing + assert set(routing.keys()) == set(agents.keys()) + + +# --------------------------------------------------------------------------- +# 3. Topology +# --------------------------------------------------------------------------- + + +EXPECTED_EXECUTOR_IDS = { + "spec_analyst", + "spec_to_request", + "implementer", + "fanout_for_review", + "test_writer", + "security_reviewer", + "doc_writer", + "tagged_tests", + "tagged_security", + "tagged_docs", + "aggregator", + "revision_loop", + "finalize", +} + + +def test_executors_match_documented_topology(): + """`Workflow.executors` contains every documented node, no surprises.""" + from code_forge.workflow import build_workflow + + wf, _, _ = build_workflow() + actual = set(wf.executors.keys()) + + missing = EXPECTED_EXECUTOR_IDS - actual + assert not missing, f"missing executors: {missing}" + + extra = actual - EXPECTED_EXECUTOR_IDS + assert not extra, f"unexpected executors: {extra}" + + +def test_start_executor_is_spec_analyst(): + from code_forge.workflow import build_workflow + + wf, _, _ = build_workflow() + assert wf.start_executor_id == "spec_analyst" + + +def test_every_executor_is_reachable_from_start(): + """Walk `edge_groups` BFS from the start node; everything must be reachable.""" + from code_forge.workflow import build_workflow + + wf, _, _ = build_workflow() + + adjacency: dict[str, set[str]] = {} + for group in wf.edge_groups: + for src in group.source_executor_ids: + for tgt in group.target_executor_ids: + adjacency.setdefault(src, set()).add(tgt) + + visited: set[str] = set() + queue: deque[str] = deque([wf.start_executor_id]) + while queue: + node = queue.popleft() + if node in visited: + continue + visited.add(node) + queue.extend(adjacency.get(node, ())) + + # `internal:` prefixed pseudo-nodes from edge_groups don't appear in + # `executors`; filter them out before asserting full coverage. + visited_real = {n for n in visited if not n.startswith("internal:")} + + missing = EXPECTED_EXECUTOR_IDS - visited_real + assert ( + not missing + ), f"executors unreachable from start: {missing}; visited: {sorted(visited_real)}" + + +# --------------------------------------------------------------------------- +# 4. Provider routing +# --------------------------------------------------------------------------- + + +def test_provider_routing_all_openai(): + """In all-openai mode every role routes to OpenAI and no Claude objects exist.""" + from code_forge.workflow import build_workflow + + _, agents, routing = build_workflow() + + assert all(provider == "openai" for provider in routing.values()), ( + f"expected every role to resolve to openai under " + f"CODE_FORGE_PROVIDERS=all-openai, got: {routing}" + ) + + for name, agent in agents.items(): + cls_name = type(agent).__name__ + assert ( + "Claude" not in cls_name + ), f"agent {name!r} resolved to {cls_name} despite all-openai mode" + + +# --------------------------------------------------------------------------- +# 5. Typed-message dataclasses +# --------------------------------------------------------------------------- + + +def test_dataclass_message_types_are_well_formed(): + """The typed messages flowing through the graph behave as dataclasses.""" + from code_forge.workflow import ( + AggregatedReview, + CodePackage, + FinalArtifact, + ReviewBundle, + ) + + pkg = CodePackage(spec="s", implementation="i") + assert pkg.spec == "s" + assert pkg.implementation == "i" + + rb = ReviewBundle( + kind="security", content="ok", spec="s", implementation="i", revision=0 + ) + assert rb.kind == "security" + + agg = AggregatedReview( + spec="s", + implementation="i", + tests="t", + docs="d", + security_report="r", + verdict="APPROVED", + revision=0, + ) + assert agg.verdict == "APPROVED" + assert agg.revision == 0 + + fa = FinalArtifact( + spec="s", + implementation="i", + tests="t", + docs="d", + security_report="r", + revisions=0, + ) + assert fa.revisions == 0