From b9b2e49d8ba859f7ad5b92b21f46948a99683f8a Mon Sep 17 00:00:00 2001 From: Roey Zalta Date: Thu, 25 Jun 2026 07:21:12 +0300 Subject: [PATCH 01/17] docs(phase2): design spec for live Azure OpenAI + GitHub agents Approved brainstorming design: planner/github/approval go live via Azure OpenAI (MAF) with real GitHub branch+plan-file+PR writes; AKS stays mock. LLM-plans/activity-executes, response_format schemas, env-selected Azure auth, PAT + owner-guard for GitHub, mocked-client tests + opt-in live smoke. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01U8d6DuACgKfMFiFbaQ2AFQ --- ...-phase2-live-azure-openai-github-design.md | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 temporal-maf-agents-poc/docs/superpowers/specs/2026-06-25-phase2-live-azure-openai-github-design.md diff --git a/temporal-maf-agents-poc/docs/superpowers/specs/2026-06-25-phase2-live-azure-openai-github-design.md b/temporal-maf-agents-poc/docs/superpowers/specs/2026-06-25-phase2-live-azure-openai-github-design.md new file mode 100644 index 0000000..a160e55 --- /dev/null +++ b/temporal-maf-agents-poc/docs/superpowers/specs/2026-06-25-phase2-live-azure-openai-github-design.md @@ -0,0 +1,158 @@ +# Phase 2 — Live Azure OpenAI + GitHub agents + +**Project:** `temporal-maf-agents-poc` (inside the `code-forge-workflow` repo) +**Date:** 2026-06-25 +**Status:** Approved design, ready for implementation planning + +## Objective + +Replace the Phase-1 mocks for three of the four agents with **real** integrations, +without changing the Temporal orchestration model: + +- **Planner**, **GitHub**, and **Approval** agents run live. + - LLM reasoning via **Azure OpenAI** (through Microsoft Agent Framework). + - The GitHub agent additionally performs **real GitHub API writes** (branch, plan file, PR). +- **AKS agent stays mocked** this phase. + +Temporal remains the single orchestration authority. All real I/O happens **inside +activities only**. Workflow code, the `AgentOutput` contract + enums, the two-layer +retry policy, the orchestrator, the k8s Deployments, and the KEDA ScaledObjects are +**unchanged**. + +## Locked decisions + +| Topic | Decision | +|-------|----------| +| Live agents | Planner (Azure OpenAI), GitHub (Azure OpenAI + GitHub API), Approval (Azure OpenAI); AKS stays mock | +| GitHub action | Create branch + commit a generated plan file + open a PR | +| Tool pattern | LLM plans, the **activity executes** GitHub writes deterministically (no agentic side-effect tools) | +| Azure auth | Env-selected: `DefaultAzureCredential` (Entra ID / AKS workload identity) when no key set, else API key | +| GitHub auth | Fine-grained Personal Access Token (`GITHUB_TOKEN`) | +| Structured output | Azure OpenAI `response_format` JSON schema per agent; activity maps to `AgentOutput` | +| Write guard | Owner/allowlist guard (`GITHUB_ALLOWED_OWNER`); **fail-closed** in live mode if unset | +| Testing | Mocked-client unit tests in CI + opt-in creds-gated live smoke; `AGENT_MODE=mock` stays default | +| Code structure | **Centralized seams**: extend `shared/maf.py`, add `shared/github.py`; agents stay thin | + +## Architecture & module layout + +The `AGENT_MODE` dispatch in `shared/maf.run_agent(...)` is the only entry point; +`mock` (default) vs `live` is purely an env flip. + +| File | Change | +|------|--------| +| `src/shared/maf.py` | Implement `run_live_agent(...)`: build an Azure OpenAI–backed MAF agent (`AzureOpenAIChatClient().as_agent(...)`), run with a per-agent `response_format` schema, return validated JSON. Factor client construction into `build_chat_client()` (env-selected key vs `DefaultAzureCredential`). Lazy imports of the live packages. | +| `src/shared/github.py` *(new)* | LLM-free, idempotent GitHub write client (PyGithub): repo guard, ensure-branch, upsert-file, ensure-PR, error classification. | +| `src/shared/config.py` | Add Phase-2 settings (Azure endpoint/deployment/api-version/key, GitHub token, allowed owner). Read lazily; never imported by workflow code paths that run in the sandbox. | +| `src/planner_agent_worker/agent.py` | Add response schema + prompt builder + `to_output()` mapper. Keep `mock()`. | +| `src/github_agent_worker/agent.py` | Add response schema + prompt builder + `to_output()`; hand the LLM plan to `shared/github.py`. Keep `mock()`. | +| `src/approval_agent_worker/agent.py` | Add response schema + prompt builder + `to_output()` (risk classification → gate). Keep `mock()`. | +| `src/aks_agent_worker/agent.py` | **Unchanged** (stays mock). | +| `requirements.txt` / `requirements-live.txt` / `pyproject.toml` | Keep mock runtime minimal; live extras (`agent-framework-azure-ai`, `azure-identity`, `PyGithub`) in `[live]` extra + `requirements-live.txt` for the Docker image. | +| `Dockerfile` | Build arg / second requirements layer to build a mock image or a live image. | +| `k8s/` | Optional `Secret` (Azure key if used + GitHub PAT) via `envFrom` on the three live workers; document workload-identity as the keyless alternative. No workflow/KEDA changes. | +| `tests/` | New `test_live_mapping.py` (mocked clients) + opt-in `test_live_smoke.py`. | +| `README.md` / `.env.example` | Phase-2 env table + run instructions; fill real vars. | + +## Per-agent live behavior + +Each agent's LLM returns a **small per-agent schema** (not the full `AgentOutput`). +The activity still owns `agent_name`/`stage` and derives `status`/`next_action`/`retryable`. + +### Planner (`planner`, stage `planning`) +- Prompt: goal + repo_url + environment. +- `response_format`: `{ summary: str, steps: [str], risk_level: "low|medium|high", rationale: str }` +- `to_output()`: `status=success`, `retryable=false`, `next_action=continue`, + `summary=`, `details={steps, risk_level, rationale}`. + +### GitHub (`github`, stage `github`) +- Prompt: goal + environment + planner `steps` (from `upstream["planning"]`). +- `response_format` (content only — not git mechanics): + `{ pr_title: str, pr_body_markdown: str, plan_file_markdown: str, commit_message: str }` +- Activity supplies deterministic mechanics: `branch = feat/`, + `path = docs/agent-plan-.md`; calls `shared/github.py` (see below). +- `to_output()`: `status=success`, `next_action=continue`, `summary="opened PR #"`, + `details={branch, pr_number, pr_url, plan_file, created_or_existed}`. +- Guard rejection → `status=failed`, `retryable=false`, `next_action=fail`. + +### Approval (`approval`, stage `approval`) +- Prompt: goal + environment + planner risk + github PR + whether AKS staged a pending promotion. +- `response_format`: `{ recommendation: "approve|reject|needs_human", risk_level: "low|medium|high", reasons: [str] }` +- The **workflow still owns the durable human-signal gate** (unchanged). The agent only classifies. Mapping: + - `needs_approval` / `ask_human` when **any** of: `approval_required`, AKS `promotion_pending`, + `recommendation == needs_human`, or `risk_level == high` (lets the LLM **escalate**). + `details` carries `recommendation`/`risk_level`/`reasons` **plus** the existing + `auto_approve`/`timeout_seconds` fields the workflow reads. + - Otherwise `success` / `continue`. + +## GitHub write flow (`shared/github.py`) + +Idempotent because Temporal's layer-1 retry can re-run after a partial success. + +**Guard (before any write):** parse `owner/repo` from `repo_url`; if `GITHUB_ALLOWED_OWNER` +(or an `owner/repo` allowlist) is set and `owner` doesn't match → raise `GitHubWriteNotAllowed`. +If no allowlist is configured in live mode → **fail-closed** (refuse). + +**Sequence (each step check-then-act):** +1. `base = repo.default_branch` +2. `ensure_branch(feat/)` — reuse ref if present, else create from `base` HEAD. +3. `upsert_file(docs/agent-plan-.md)` — update with existing blob SHA if present, else create. +4. `ensure_pr(head=feat/, base=default)` — return existing open PR for that head, else create. +5. Return `{branch, pr_number, pr_url, created_or_existed}`. + +**Error classification (shared by `maf.py` and `github.py`):** +- **Transient** — 5xx, rate-limit/secondary-limit, network/timeout → **raise** → Temporal layer-1 retry (10s / ×2 / 120s, 3 attempts). +- **Permanent** — 401/403 auth, 404 repo, 422 validation, guard violation → `status=failed, retryable=false, next_action=fail`. + +## Auth, config & dependencies + +New `shared/config.py` settings (read lazily): + +| Env var | Purpose | Default | +|---------|---------|---------| +| `AGENT_MODE` | `mock` / `live` | `mock` | +| `AZURE_OPENAI_ENDPOINT` | resource endpoint | required in live | +| `AZURE_OPENAI_CHAT_DEPLOYMENT` | deployment name (e.g. `gpt-4o`) | required in live | +| `AZURE_OPENAI_API_VERSION` | API version | recent default | +| `AZURE_OPENAI_API_KEY` | optional key | unset → `DefaultAzureCredential` | +| `GITHUB_TOKEN` | fine-grained PAT | required in live | +| `GITHUB_ALLOWED_OWNER` | owner/allowlist guard | unset → live fail-closed | + +- `build_chat_client()`: key auth if `AZURE_OPENAI_API_KEY` set, else `DefaultAzureCredential` + (local `az login` / AKS workload identity). Same image local and on-cluster. +- Mock-mode stays dependency-free: live packages imported **lazily inside the live path**; + a clear error tells you to `pip install -e ".[live]"` if `AGENT_MODE=live` without them. +- Dockerfile: mock image (default) or live image via build arg / second requirements layer. +- k8s: optional `Secret` via `envFrom` on the three live workers; workload identity documented as keyless path. + +## Testing + +CI (no creds): +- `test_live_mapping.py` with **mocked** Azure + GitHub clients: + - Each agent `to_output()` maps a sample LLM JSON → valid `AgentOutput` (schema + enum validation). + - Approval escalation matrix (`needs_human` / `risk_level=high` / `approval_required` / AKS-pending → `needs_approval`; low-risk + not-required → `success`). + - GitHub guard: disallowed owner → `failed/non-retryable`; fail-closed when unset. + - `shared/github.py` idempotency with a fake PyGithub (branch-exists, PR-exists → `existed`, no double create). + - Error classification: fake 429/5xx **raises**; 401/422 → `failed/non-retryable`. +- Existing 16 Phase-1 tests stay green (`AGENT_MODE=mock` default unchanged). + +Opt-in: +- `test_live_smoke.py` — `skipif` unless real creds + `RUN_LIVE_SMOKE=1`: one planner round-trip + against Azure OpenAI and one branch+file+PR against a sandbox repo, with cleanup. + +## Docs + +- `README.md`: "Phase 2 — going live" section (env table, `pip install -e ".[live]"`, + `AGENT_MODE=live`, the owner guard, keyless-on-AKS note) and flip the acceptance-criteria checkbox. +- `.env.example`: fill real Azure/GitHub vars (replace current TODO comments). + +## Explicitly unchanged + +All `*/workflows.py`, `shared/contracts.py` (enums + `decide()`), `shared/child.py` +(retry policy), the orchestrator, k8s Deployments, KEDA ScaledObjects, and the AKS agent. + +## Out of scope (future phases) + +- Live AKS / Kubernetes agent (stays mock). +- Full feature code-generation by the GitHub agent (plan file + PR only). +- GitHub App auth (PAT only this phase). +- Agentic MAF side-effect tools. From 4782cfd1bdd121ad3481c113639cb16c0ac6450b Mon Sep 17 00:00:00 2001 From: Roey Zalta Date: Thu, 25 Jun 2026 10:15:23 +0300 Subject: [PATCH 02/17] docs(phase2): implementation plan for live Azure OpenAI + GitHub agents 9 TDD tasks: config, MAF live seam, idempotent GitHub client, planner/ github/approval live wiring, AKS-stays-mock regression, Docker/k8s, docs + opt-in live smoke. Workflows/contracts/KEDA untouched. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01U8d6DuACgKfMFiFbaQ2AFQ --- ...6-06-25-phase2-live-azure-openai-github.md | 1562 +++++++++++++++++ 1 file changed, 1562 insertions(+) create mode 100644 temporal-maf-agents-poc/docs/superpowers/plans/2026-06-25-phase2-live-azure-openai-github.md diff --git a/temporal-maf-agents-poc/docs/superpowers/plans/2026-06-25-phase2-live-azure-openai-github.md b/temporal-maf-agents-poc/docs/superpowers/plans/2026-06-25-phase2-live-azure-openai-github.md new file mode 100644 index 0000000..2d4f320 --- /dev/null +++ b/temporal-maf-agents-poc/docs/superpowers/plans/2026-06-25-phase2-live-azure-openai-github.md @@ -0,0 +1,1562 @@ +# Phase 2 — Live Azure OpenAI + GitHub Agents Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the Phase-1 mocks for the planner, github, and approval agents with real Azure OpenAI reasoning (via Microsoft Agent Framework) and real GitHub branch/plan-file/PR writes, without changing any Temporal workflow, contract, or KEDA code. + +**Architecture:** All new I/O lives in activity-side code reached through the existing `shared/maf.py` seam. `shared/maf.py` gains a real `run_live_agent` that drives an Azure OpenAI–backed MAF agent with a per-agent Pydantic `response_format`; a new `shared/github.py` performs idempotent, guarded GitHub writes. Each agent module gains a Pydantic response model, a `build_prompt`, and an async `to_output` mapper. `AGENT_MODE` (mock|live) stays the single switch; the AKS agent stays mock by simply not providing live wiring. + +**Tech Stack:** Python 3.10+, Temporal Python SDK (`temporalio`), Microsoft Agent Framework (`agent-framework` / `agent_framework.openai.OpenAIChatClient`), `azure-identity` (`DefaultAzureCredential`), `PyGithub`, `pydantic` v2, `pytest` + `pytest-asyncio`. + +## Global Constraints + +- **Determinism rule:** No LLM / Azure / GitHub calls in any `*/workflows.py`, `shared/contracts.py`, `shared/config.py`, `shared/child.py`. All live I/O is activity-side only. (Do not import `agent_framework`, `azure`, `github`, or `pydantic` models from workflow modules.) +- **Default unchanged:** `AGENT_MODE` defaults to `mock`. All 16 existing Phase-1 tests must stay green. +- **Live extras stay optional at runtime:** mock mode must run/import without `agent-framework`, `azure-identity`, or `PyGithub` installed. Import those three lazily, inside the live code paths only. (`pydantic` is the one exception — it moves into base requirements because response models are defined at agent-module import time.) +- **MAF API (current, verified Jan 2026):** `from agent_framework.openai import OpenAIChatClient`; `client = OpenAIChatClient(model=, azure_endpoint=, api_version=, api_key=)` or `credential=`; `agent = client.as_agent(name=..., instructions=...)`; `result = await agent.run(prompt, options={"response_format": })`; `result.value` is the parsed model instance or `None` on validation failure. +- **GitHub writes are idempotent:** fixed `branch = feat/`, fixed `path = docs/agent-plan-.md`; every step is check-then-act so a Temporal retry after partial success converges. +- **Write guard fail-closed:** in live mode, refuse GitHub writes unless `GITHUB_ALLOWED_OWNER` matches the repo owner. +- **Error classification:** transient (5xx, rate-limit, timeout, `result.value is None`) → raise so Temporal layer-1 retry handles it; permanent (auth, 404, 422, guard violation) → return `status=failed, retryable=False, next_action=fail` (github) or raise `ApplicationError(non_retryable=True)` (azure). +- Run all commands from the project root: `temporal-maf-agents-poc/`. Tests use `PYTHONPATH=src`. + +--- + +### Task 1: Dependencies + Phase-2 config + +**Files:** +- Modify: `requirements.txt` +- Create: `requirements-live.txt` +- Modify: `pyproject.toml` (the `[project.optional-dependencies] live` list) +- Modify: `src/shared/config.py` (add fields to `Settings` + reads in `get_settings`) +- Test: `tests/test_config_phase2.py` + +**Interfaces:** +- Produces: `Settings` gains fields `azure_openai_endpoint: str | None`, `azure_openai_deployment: str | None`, `azure_openai_api_version: str`, `azure_openai_api_key: str | None`, `github_token: str | None`, `github_allowed_owner: str | None`. `get_settings()` signature is unchanged (still `() -> Settings`). + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_config_phase2.py`: + +```python +from __future__ import annotations + +from shared.config import get_settings + + +def test_phase2_defaults(monkeypatch): + for var in ( + "AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_CHAT_DEPLOYMENT", + "AZURE_OPENAI_API_KEY", "GITHUB_TOKEN", "GITHUB_ALLOWED_OWNER", + ): + monkeypatch.delenv(var, raising=False) + s = get_settings() + assert s.azure_openai_endpoint is None + assert s.azure_openai_deployment is None + assert s.azure_openai_api_key is None + assert s.github_token is None + assert s.github_allowed_owner is None + assert s.azure_openai_api_version # has a non-empty default + + +def test_phase2_reads_env(monkeypatch): + monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://x.openai.azure.com") + monkeypatch.setenv("AZURE_OPENAI_CHAT_DEPLOYMENT", "gpt-4o") + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "k") + monkeypatch.setenv("GITHUB_TOKEN", "t") + monkeypatch.setenv("GITHUB_ALLOWED_OWNER", "example-org") + s = get_settings() + assert s.azure_openai_endpoint == "https://x.openai.azure.com" + assert s.azure_openai_deployment == "gpt-4o" + assert s.azure_openai_api_key == "k" + assert s.github_token == "t" + assert s.github_allowed_owner == "example-org" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=src python -m pytest tests/test_config_phase2.py -v` +Expected: FAIL with `AttributeError: 'Settings' object has no attribute 'azure_openai_endpoint'` + +- [ ] **Step 3: Add the fields to `Settings` and `get_settings`** + +In `src/shared/config.py`, add these fields to the `Settings` dataclass (after `agent_mode`): + +```python + # Phase 2 — Azure OpenAI (read lazily; activity-side only) + azure_openai_endpoint: str | None + azure_openai_deployment: str | None + azure_openai_api_version: str + azure_openai_api_key: str | None + # Phase 2 — GitHub + github_token: str | None + github_allowed_owner: str | None +``` + +And add these reads inside the `Settings(...)` constructor call in `get_settings()` (after `agent_mode=...`): + +```python + azure_openai_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), + azure_openai_deployment=os.getenv("AZURE_OPENAI_CHAT_DEPLOYMENT"), + azure_openai_api_version=os.getenv("AZURE_OPENAI_API_VERSION", "2024-10-21"), + azure_openai_api_key=os.getenv("AZURE_OPENAI_API_KEY"), + github_token=os.getenv("GITHUB_TOKEN"), + github_allowed_owner=os.getenv("GITHUB_ALLOWED_OWNER"), +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `PYTHONPATH=src python -m pytest tests/test_config_phase2.py -v` +Expected: PASS (2 passed) + +- [ ] **Step 5: Update dependency manifests** + +Replace `requirements.txt` with (adds `pydantic` to the always-installed base, since response models import at agent-module load): + +``` +# Phase 1 runtime (mock agents) + response-model definitions. +temporalio>=1.7,<2 +python-dotenv>=1.0 +pydantic>=2.7 +``` + +Create `requirements-live.txt`: + +``` +# Phase 2 live integrations. Install with: pip install -r requirements-live.txt +-r requirements.txt +agent-framework>=0.0.0a1 +azure-identity>=1.17 +PyGithub>=2.3 +``` + +In `pyproject.toml`, replace the `live = [...]` list under `[project.optional-dependencies]` with: + +```toml +live = [ + "agent-framework>=0.0.0a1", + "azure-identity>=1.17", + "PyGithub>=2.3", +] +``` + +and add `"pydantic>=2.7"` to the main `[project] dependencies` list. + +- [ ] **Step 6: Run the full suite to confirm nothing regressed** + +Run: `PYTHONPATH=src python -m pytest -q --timeout=120 --timeout-method=thread` +Expected: PASS (18 passed — the prior 16 plus 2 new) + +- [ ] **Step 7: Commit** + +```bash +git add requirements.txt requirements-live.txt pyproject.toml src/shared/config.py tests/test_config_phase2.py +git commit -m "feat(phase2): add Azure OpenAI + GitHub config settings and live deps" +``` + +--- + +### Task 2: MAF live seam (`shared/maf.py`) + +**Files:** +- Modify: `src/shared/maf.py` +- Test: `tests/test_maf_seam.py` + +**Interfaces:** +- Consumes: `Settings` fields from Task 1; `AgentOutput`, `AgentRequest` from `shared.contracts`. +- Produces: + - `build_chat_client(settings) -> Any` — constructs `OpenAIChatClient` (key vs `DefaultAzureCredential`). + - `async run_live_agent(*, agent_name: str, instructions: str, prompt: str, response_model: type) -> Any` — returns the parsed Pydantic instance; raises on transient/empty output. + - `ToOutput = Callable[[AgentRequest, Any], Awaitable[AgentOutput]]` + - `async run_agent(*, agent_name, stage, request, mock, instructions=None, build_prompt=None, response_model=None, to_output=None) -> AgentOutput` — live when `agent_mode=="live"` **and** both `response_model` and `to_output` are provided; otherwise mock (this is how the AKS agent stays mock). + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_maf_seam.py`: + +```python +from __future__ import annotations + +import pytest + +from shared.contracts import ( + ACTION_CONTINUE, STATUS_SUCCESS, AgentOutput, AgentRequest, +) +from shared import maf + + +def _req(stage="planning"): + return AgentRequest( + request_id="r1", goal="g", repo_url="https://github.com/o/r", + environment="dev", stage=stage, + ) + + +def _mock_output(req): + return AgentOutput( + agent_name="x", stage=req.stage, status=STATUS_SUCCESS, + retryable=False, summary="mock", next_action=ACTION_CONTINUE, + ) + + +async def test_run_agent_uses_mock_when_mode_mock(monkeypatch): + monkeypatch.setenv("AGENT_MODE", "mock") + out = await maf.run_agent( + agent_name="x", stage="planning", request=_req(), mock=_mock_output, + ) + assert out.summary == "mock" + + +async def test_run_agent_falls_back_to_mock_when_live_wiring_absent(monkeypatch): + # AKS-style: live mode but no response_model/to_output -> stays mock. + monkeypatch.setenv("AGENT_MODE", "live") + out = await maf.run_agent( + agent_name="aks", stage="aks", request=_req("aks"), mock=_mock_output, + ) + assert out.summary == "mock" + + +async def test_run_agent_live_path_calls_to_output(monkeypatch): + monkeypatch.setenv("AGENT_MODE", "live") + + class Parsed: + value = 42 + + async def fake_live(*, agent_name, instructions, prompt, response_model): + return Parsed() + + captured = {} + + async def to_output(req, parsed): + captured["parsed"] = parsed + return AgentOutput( + agent_name="x", stage=req.stage, status=STATUS_SUCCESS, + retryable=False, summary="live", next_action=ACTION_CONTINUE, + ) + + monkeypatch.setattr(maf, "run_live_agent", fake_live) + out = await maf.run_agent( + agent_name="x", stage="planning", request=_req(), mock=_mock_output, + instructions="i", build_prompt=lambda r: "p", + response_model=Parsed, to_output=to_output, + ) + assert out.summary == "live" + assert captured["parsed"].value == 42 + + +async def test_run_live_agent_raises_on_none_value(monkeypatch): + class FakeResult: + value = None + text = "garbage" + + class FakeAgent: + async def run(self, prompt, options=None): + return FakeResult() + + class FakeClient: + def as_agent(self, **kwargs): + return FakeAgent() + + monkeypatch.setattr(maf, "build_chat_client", lambda settings: FakeClient()) + with pytest.raises(Exception): + await maf.run_live_agent( + agent_name="x", instructions="i", prompt="p", response_model=object, + ) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=src python -m pytest tests/test_maf_seam.py -v` +Expected: FAIL (`run_live_agent` is the old stub that raises `NotImplementedError`; `run_agent` doesn't accept `to_output`). + +- [ ] **Step 3: Rewrite `shared/maf.py`** + +Replace the body of `src/shared/maf.py` (keep the module docstring) from the imports down with: + +```python +from __future__ import annotations + +from typing import Any, Awaitable, Callable + +from temporalio.exceptions import ApplicationError + +from shared.config import Settings, get_settings +from shared.contracts import AgentOutput, AgentRequest + +MockFactory = Callable[[AgentRequest], AgentOutput] +BuildPrompt = Callable[[AgentRequest], str] +ToOutput = Callable[[AgentRequest, Any], Awaitable[AgentOutput]] + + +async def run_agent( + *, + agent_name: str, + stage: str, + request: AgentRequest, + mock: MockFactory, + instructions: str | None = None, + build_prompt: BuildPrompt | None = None, + response_model: type | None = None, + to_output: ToOutput | None = None, +) -> AgentOutput: + """Run one agent and return its structured output. + + Goes live only when AGENT_MODE=live AND the agent supplied both a + response_model and a to_output mapper. Agents without live wiring (e.g. + AKS) stay on the deterministic mock even in live mode. + """ + settings = get_settings() + live_supported = response_model is not None and to_output is not None + if settings.agent_mode == "live" and live_supported: + prompt = (build_prompt or _default_prompt)(request) + parsed = await run_live_agent( + agent_name=agent_name, + instructions=instructions or "", + prompt=prompt, + response_model=response_model, + ) + out = await to_output(request, parsed) + return out.validate() + return mock(request).validate() + + +def _default_prompt(request: AgentRequest) -> str: + return ( + f"Goal: {request.goal}\n" + f"Repository: {request.repo_url}\n" + f"Environment: {request.environment}\n" + f"Stage: {request.stage}\n" + f"Upstream results: {request.upstream}" + ) + + +def build_chat_client(settings: Settings) -> Any: + """Construct an Azure-OpenAI-backed MAF chat client. + + Uses the API key when present, otherwise DefaultAzureCredential (Entra ID / + AKS workload identity). Imports the live packages lazily so mock mode runs + without them installed. + """ + from agent_framework.openai import OpenAIChatClient # type: ignore + + if not settings.azure_openai_endpoint or not settings.azure_openai_deployment: + raise ApplicationError( + "AGENT_MODE=live requires AZURE_OPENAI_ENDPOINT and " + "AZURE_OPENAI_CHAT_DEPLOYMENT", + type="ConfigError", + non_retryable=True, + ) + + kwargs: dict[str, Any] = { + "model": settings.azure_openai_deployment, + "azure_endpoint": settings.azure_openai_endpoint, + "api_version": settings.azure_openai_api_version, + } + if settings.azure_openai_api_key: + kwargs["api_key"] = settings.azure_openai_api_key + else: + from azure.identity.aio import DefaultAzureCredential # type: ignore + + kwargs["credential"] = DefaultAzureCredential() + return OpenAIChatClient(**kwargs) + + +async def run_live_agent( + *, + agent_name: str, + instructions: str, + prompt: str, + response_model: type, +) -> Any: + """Drive a real MAF agent and return the parsed structured output. + + Raises on transient failures (so Temporal's retry policy handles them) and + raises a non-retryable ApplicationError on permanent failures (auth/bad + request) so Temporal fails fast instead of retrying pointlessly. + """ + settings = get_settings() + client = build_chat_client(settings) + agent = client.as_agent(name=agent_name, instructions=instructions) + try: + result = await agent.run(prompt, options={"response_format": response_model}) + except Exception as exc: # noqa: BLE001 - classify then re-raise + if _is_permanent_azure_error(exc): + raise ApplicationError( + f"permanent Azure OpenAI error for {agent_name}: {exc}", + type=type(exc).__name__, + non_retryable=True, + ) from exc + raise # transient -> Temporal layer-1 retry + + parsed = getattr(result, "value", None) + if parsed is None: + # Model returned output that didn't match the schema. Treat as transient + # (a re-generation often succeeds); Temporal retries, then fails. + raise RuntimeError( + f"{agent_name} returned no schema-valid output: " + f"{getattr(result, 'text', '')[:300]}" + ) + return parsed + + +def _is_permanent_azure_error(exc: Exception) -> bool: + """Auth / bad-request style errors should not be retried.""" + name = type(exc).__name__ + if name in {"AuthenticationError", "PermissionDeniedError", "BadRequestError", + "NotFoundError", "UnprocessableEntityError"}: + return True + status = getattr(exc, "status_code", None) + return status in {400, 401, 403, 404, 422} +``` + +> **API note (fast-moving SDK):** the live path is creds-gated and not exercised by CI — only by the Task 9 live smoke test. The `OpenAIChatClient(model=, azure_endpoint=, api_version=, api_key=|credential=)` form is the verified Jan-2026 API. If the installed `agent-framework` version rejects `api_version` on `OpenAIChatClient`, switch `build_chat_client` to the explicit Azure client: `from agent_framework.azure import AzureOpenAIChatClient` with `AzureOpenAIChatClient(endpoint=, deployment_name=, api_version=, api_key=)` (or `credential=`). The rest of `run_live_agent` is unchanged either way. Confirm the exact constructor against the installed version with `python -c "from agent_framework.openai import OpenAIChatClient; help(OpenAIChatClient.__init__)"` before running the live smoke test. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `PYTHONPATH=src python -m pytest tests/test_maf_seam.py -v` +Expected: PASS (4 passed) + +- [ ] **Step 5: Run the full suite (the changed `run_agent` signature must not break existing activities — they still pass only `mock`)** + +Run: `PYTHONPATH=src python -m pytest -q --timeout=120 --timeout-method=thread` +Expected: PASS (22 passed) + +> Note: the existing activities currently call `run_agent(agent_name=..., stage=..., instructions=..., request=..., mock=...)`. The new signature dropped `instructions` from being required but still accepts it as a keyword, so those calls keep working. Verify by running the integration test, which exercises every activity in mock mode. + +- [ ] **Step 6: Commit** + +```bash +git add src/shared/maf.py tests/test_maf_seam.py +git commit -m "feat(phase2): implement live MAF seam (Azure OpenAI + structured output)" +``` + +--- + +### Task 3: GitHub write client (`shared/github.py`) + +**Files:** +- Create: `src/shared/github.py` +- Test: `tests/test_github_client.py` + +**Interfaces:** +- Produces: + - `class GitHubWriteNotAllowed(Exception)` — permanent (guard). + - `class PermanentGitHubError(Exception)` — permanent (auth/404/422). + - `parse_owner_repo(repo_url: str) -> tuple[str, str]` + - `assert_write_allowed(owner: str, allowed_owner: str | None) -> None` + - `create_pr_with_plan(*, repo_url: str, request_id: str, token: str | None, allowed_owner: str | None, pr_title: str, pr_body: str, plan_markdown: str, commit_message: str, client_factory=None) -> dict` returning keys `{"branch", "pr_number", "pr_url", "plan_file", "created_or_existed"}`. Synchronous (PyGithub is blocking); callers invoke it via `asyncio.to_thread`. `client_factory(token)` is injectable for tests; defaults to a real PyGithub client. +- Consumes: nothing from earlier tasks. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_github_client.py`: + +```python +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from shared import github as gh + + +def test_parse_owner_repo(): + assert gh.parse_owner_repo("https://github.com/example-org/svc") == ("example-org", "svc") + assert gh.parse_owner_repo("https://github.com/example-org/svc.git") == ("example-org", "svc") + assert gh.parse_owner_repo("git@github.com:example-org/svc.git") == ("example-org", "svc") + + +def test_assert_write_allowed_fail_closed_when_unset(): + with pytest.raises(gh.GitHubWriteNotAllowed): + gh.assert_write_allowed("example-org", None) + + +def test_assert_write_allowed_rejects_mismatch(): + with pytest.raises(gh.GitHubWriteNotAllowed): + gh.assert_write_allowed("someone-else", "example-org") + + +def test_assert_write_allowed_accepts_match(): + gh.assert_write_allowed("example-org", "example-org") # no raise + + +def _fake_repo(*, branch_exists, file_exists, pr_exists): + repo = MagicMock() + repo.default_branch = "main" + base_ref = SimpleNamespace(object=SimpleNamespace(sha="basesha")) + + def get_git_ref(ref): + if ref == "heads/main": + return base_ref + if ref == f"heads/feat/req-1" and branch_exists: + return SimpleNamespace(object=SimpleNamespace(sha="branchsha")) + from github import GithubException + raise GithubException(404, {"message": "Not Found"}, {}) + + repo.get_git_ref.side_effect = get_git_ref + + if file_exists: + repo.get_contents.return_value = SimpleNamespace(sha="filesha") + else: + from github import GithubException + repo.get_contents.side_effect = GithubException(404, {"message": "nf"}, {}) + + if pr_exists: + existing = SimpleNamespace(number=7, html_url="https://github.com/example-org/svc/pull/7") + repo.get_pulls.return_value = [existing] + else: + repo.get_pulls.return_value = [] + repo.create_pull.return_value = SimpleNamespace( + number=8, html_url="https://github.com/example-org/svc/pull/8" + ) + return repo + + +def _factory_for(repo): + gh_client = MagicMock() + gh_client.get_repo.return_value = repo + return lambda token: gh_client + + +def test_create_pr_fresh(monkeypatch): + repo = _fake_repo(branch_exists=False, file_exists=False, pr_exists=False) + out = gh.create_pr_with_plan( + repo_url="https://github.com/example-org/svc", request_id="req-1", + token="t", allowed_owner="example-org", pr_title="T", pr_body="B", + plan_markdown="# plan", commit_message="add plan", + client_factory=_factory_for(repo), + ) + assert out["branch"] == "feat/req-1" + assert out["pr_number"] == 8 + assert out["created_or_existed"] == "created" + repo.create_git_ref.assert_called_once() # branch created from base + + +def test_create_pr_idempotent_when_everything_exists(monkeypatch): + repo = _fake_repo(branch_exists=True, file_exists=True, pr_exists=True) + out = gh.create_pr_with_plan( + repo_url="https://github.com/example-org/svc", request_id="req-1", + token="t", allowed_owner="example-org", pr_title="T", pr_body="B", + plan_markdown="# plan", commit_message="add plan", + client_factory=_factory_for(repo), + ) + assert out["pr_number"] == 7 + assert out["created_or_existed"] == "existed" + repo.create_git_ref.assert_not_called() # branch reused + repo.update_file.assert_called_once() # file updated, not created + repo.create_pull.assert_not_called() # PR reused + + +def test_create_pr_guard_blocks_disallowed_owner(): + with pytest.raises(gh.GitHubWriteNotAllowed): + gh.create_pr_with_plan( + repo_url="https://github.com/someone-else/svc", request_id="req-1", + token="t", allowed_owner="example-org", pr_title="T", pr_body="B", + plan_markdown="x", commit_message="m", client_factory=lambda token: MagicMock(), + ) + + +def test_permanent_error_on_404_repo(): + from github import GithubException + gh_client = MagicMock() + gh_client.get_repo.side_effect = GithubException(404, {"message": "nf"}, {}) + with pytest.raises(gh.PermanentGitHubError): + gh.create_pr_with_plan( + repo_url="https://github.com/example-org/svc", request_id="req-1", + token="t", allowed_owner="example-org", pr_title="T", pr_body="B", + plan_markdown="x", commit_message="m", client_factory=lambda token: gh_client, + ) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=src python -m pytest tests/test_github_client.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'shared.github'` (and `PyGithub` must be installed for the test: `pip install PyGithub`). + +- [ ] **Step 3: Implement `shared/github.py`** + +Create `src/shared/github.py`: + +```python +"""Idempotent, guarded GitHub write client (activity-side only). + +No LLM here. PyGithub is synchronous, so callers invoke create_pr_with_plan via +asyncio.to_thread. PyGithub is imported lazily so mock mode runs without it. +""" + +from __future__ import annotations + +import re +from typing import Any, Callable + +PLAN_PATH_TEMPLATE = "docs/agent-plan-{request_id}.md" +BRANCH_TEMPLATE = "feat/{request_id}" + +# Statuses we treat as permanent (no point retrying). +_PERMANENT_STATUS = {401, 403, 404, 422} + + +class GitHubWriteNotAllowed(Exception): + """The target repo is not permitted by the owner/allowlist guard.""" + + +class PermanentGitHubError(Exception): + """A non-retryable GitHub failure (auth, missing repo, validation).""" + + +def parse_owner_repo(repo_url: str) -> tuple[str, str]: + """Extract (owner, repo) from an https or ssh GitHub URL.""" + cleaned = repo_url.strip() + cleaned = re.sub(r"\.git$", "", cleaned) + m = re.search(r"github\.com[:/]+([^/]+)/([^/]+)$", cleaned) + if not m: + raise PermanentGitHubError(f"cannot parse owner/repo from {repo_url!r}") + return m.group(1), m.group(2) + + +def assert_write_allowed(owner: str, allowed_owner: str | None) -> None: + """Fail-closed guard: refuse unless the owner matches the allowlist.""" + if not allowed_owner: + raise GitHubWriteNotAllowed( + "GITHUB_ALLOWED_OWNER is not set; refusing to write (fail-closed)" + ) + allowed = {o.strip() for o in allowed_owner.split(",") if o.strip()} + if owner not in allowed: + raise GitHubWriteNotAllowed( + f"owner {owner!r} not in allowed owners {sorted(allowed)}" + ) + + +def _default_client_factory(token: str | None) -> Any: + from github import Auth, Github # type: ignore + + if not token: + raise PermanentGitHubError("GITHUB_TOKEN is required for live GitHub writes") + return Github(auth=Auth.Token(token)) + + +def create_pr_with_plan( + *, + repo_url: str, + request_id: str, + token: str | None, + allowed_owner: str | None, + pr_title: str, + pr_body: str, + plan_markdown: str, + commit_message: str, + client_factory: Callable[[str | None], Any] | None = None, +) -> dict: + """Ensure branch -> upsert plan file -> ensure PR. Idempotent. + + Raises GitHubWriteNotAllowed / PermanentGitHubError for permanent failures; + lets transient GithubException (5xx / rate limit) propagate for retry. + """ + from github import GithubException # type: ignore + + owner, repo_name = parse_owner_repo(repo_url) + assert_write_allowed(owner, allowed_owner) + + factory = client_factory or _default_client_factory + gh_client = factory(token) + + branch = BRANCH_TEMPLATE.format(request_id=request_id) + path = PLAN_PATH_TEMPLATE.format(request_id=request_id) + + try: + repo = gh_client.get_repo(f"{owner}/{repo_name}") + base = repo.default_branch + + # 1. ensure branch + try: + repo.get_git_ref(f"heads/{branch}") + except GithubException as exc: + if exc.status == 404: + base_sha = repo.get_git_ref(f"heads/{base}").object.sha + repo.create_git_ref(ref=f"refs/heads/{branch}", sha=base_sha) + else: + raise + + # 2. upsert plan file on the branch + try: + existing = repo.get_contents(path, ref=branch) + repo.update_file(path, commit_message, plan_markdown, existing.sha, branch=branch) + except GithubException as exc: + if exc.status == 404: + repo.create_file(path, commit_message, plan_markdown, branch=branch) + else: + raise + + # 3. ensure PR + open_pulls = list(repo.get_pulls(state="open", head=f"{owner}:{branch}")) + if open_pulls: + pr = open_pulls[0] + created_or_existed = "existed" + else: + pr = repo.create_pull(title=pr_title, body=pr_body, head=branch, base=base) + created_or_existed = "created" + + return { + "branch": branch, + "pr_number": pr.number, + "pr_url": pr.html_url, + "plan_file": path, + "created_or_existed": created_or_existed, + } + + except GithubException as exc: + if getattr(exc, "status", None) in _PERMANENT_STATUS: + raise PermanentGitHubError(f"GitHub {exc.status}: {exc.data}") from exc + raise # transient (5xx, secondary rate limit) -> Temporal retry +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pip install PyGithub && PYTHONPATH=src python -m pytest tests/test_github_client.py -v` +Expected: PASS (8 passed) + +- [ ] **Step 5: Commit** + +```bash +git add src/shared/github.py tests/test_github_client.py +git commit -m "feat(phase2): idempotent guarded GitHub write client" +``` + +--- + +### Task 4: Planner agent goes live + +**Files:** +- Modify: `src/planner_agent_worker/agent.py` +- Modify: `src/planner_agent_worker/activities.py:43-49` (the `run_agent(...)` call) +- Test: `tests/test_planner_live.py` + +**Interfaces:** +- Consumes: `run_agent` (Task 2). +- Produces in `planner_agent_worker.agent`: `class PlannerResult(BaseModel)` with `summary: str`, `steps: list[str]`, `risk_level: Literal["low","medium","high"]`, `rationale: str`; `RESPONSE_MODEL = PlannerResult`; `build_prompt(request: AgentRequest) -> str`; `async to_output(request: AgentRequest, parsed: PlannerResult) -> AgentOutput`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_planner_live.py`: + +```python +from __future__ import annotations + +from shared.contracts import STATUS_SUCCESS, ACTION_CONTINUE, AgentRequest +from planner_agent_worker import agent + + +def _req(): + return AgentRequest( + request_id="r1", goal="add healthz", repo_url="https://github.com/o/r", + environment="dev", stage="planning", + ) + + +async def test_planner_to_output_maps_to_contract(): + parsed = agent.PlannerResult( + summary="plan ready", steps=["a", "b"], risk_level="low", rationale="because", + ) + out = (await agent.to_output(_req(), parsed)).validate() + assert out.agent_name == "planner" + assert out.status == STATUS_SUCCESS + assert out.next_action == ACTION_CONTINUE + assert out.summary == "plan ready" + assert out.details["steps"] == ["a", "b"] + assert out.details["risk_level"] == "low" + + +def test_planner_build_prompt_includes_goal_and_repo(): + p = agent.build_prompt(_req()) + assert "add healthz" in p + assert "https://github.com/o/r" in p +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=src python -m pytest tests/test_planner_live.py -v` +Expected: FAIL with `AttributeError: module 'planner_agent_worker.agent' has no attribute 'PlannerResult'` + +- [ ] **Step 3: Add live wiring to `planner_agent_worker/agent.py`** + +Add to the imports at the top of `src/planner_agent_worker/agent.py`: + +```python +from typing import Literal + +from pydantic import BaseModel, Field +``` + +Then append to the module (after `mock`): + +```python +class PlannerResult(BaseModel): + """Structured planning output enforced via Azure OpenAI response_format.""" + + summary: str = Field(description="One-line summary of the plan") + steps: list[str] = Field(description="Ordered, concrete deployment steps") + risk_level: Literal["low", "medium", "high"] + rationale: str = Field(description="Why this plan and risk level") + + +RESPONSE_MODEL = PlannerResult + + +def build_prompt(request: AgentRequest) -> str: + return ( + f"Engineering goal: {request.goal}\n" + f"Target repository: {request.repo_url}\n" + f"Environment: {request.environment}\n\n" + "Produce a concrete, ordered deployment plan and assess its risk." + ) + + +async def to_output(request: AgentRequest, parsed: PlannerResult) -> AgentOutput: + return AgentOutput( + agent_name=AGENT_NAME, + stage=STAGE_PLANNING, + status=STATUS_SUCCESS, + retryable=False, + summary=parsed.summary, + next_action=ACTION_CONTINUE, + details={ + "goal": request.goal, + "repo_url": request.repo_url, + "environment": request.environment, + "steps": parsed.steps, + "risk_level": parsed.risk_level, + "rationale": parsed.rationale, + }, + ) +``` + +- [ ] **Step 4: Wire the activity to pass the live params** + +In `src/planner_agent_worker/activities.py`, replace the `run_agent(...)` call with: + +```python + output = await run_agent( + agent_name=agent.AGENT_NAME, + stage=request.stage, + instructions=agent.INSTRUCTIONS, + request=request, + mock=agent.mock, + build_prompt=agent.build_prompt, + response_model=agent.RESPONSE_MODEL, + to_output=agent.to_output, + ) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `PYTHONPATH=src python -m pytest tests/test_planner_live.py -v` +Expected: PASS (2 passed) + +- [ ] **Step 6: Run the full suite (mock-mode integration still green)** + +Run: `PYTHONPATH=src python -m pytest -q --timeout=120 --timeout-method=thread` +Expected: PASS (32 passed) + +- [ ] **Step 7: Commit** + +```bash +git add src/planner_agent_worker/agent.py src/planner_agent_worker/activities.py tests/test_planner_live.py +git commit -m "feat(phase2): planner agent live wiring (Azure OpenAI)" +``` + +--- + +### Task 5: GitHub agent goes live + +**Files:** +- Modify: `src/github_agent_worker/agent.py` +- Modify: `src/github_agent_worker/activities.py` (the `run_agent(...)` call) +- Test: `tests/test_github_live.py` + +**Interfaces:** +- Consumes: `run_agent` (Task 2); `create_pr_with_plan`, `GitHubWriteNotAllowed`, `PermanentGitHubError` (Task 3); settings `github_token`, `github_allowed_owner` (Task 1). +- Produces in `github_agent_worker.agent`: `class GitHubChange(BaseModel)` with `pr_title: str`, `pr_body_markdown: str`, `plan_file_markdown: str`, `commit_message: str`; `RESPONSE_MODEL = GitHubChange`; `build_prompt`; `async to_output` that runs `create_pr_with_plan` via `asyncio.to_thread` and maps guard/permanent errors to `status=failed, retryable=False, next_action=fail`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_github_live.py`: + +```python +from __future__ import annotations + +import pytest + +from shared.contracts import ( + ACTION_CONTINUE, ACTION_FAIL, STATUS_FAILED, STATUS_SUCCESS, + STAGE_PLANNING, AgentOutput, AgentRequest, +) +from shared import github as gh +from github_agent_worker import agent + + +def _req(): + return AgentRequest( + request_id="req-1", goal="add healthz", + repo_url="https://github.com/example-org/svc", environment="dev", + stage="github", + upstream={STAGE_PLANNING: AgentOutput( + agent_name="planner", stage="planning", status=STATUS_SUCCESS, + retryable=False, summary="s", next_action=ACTION_CONTINUE, + details={"steps": ["x"]}, + )}, + ) + + +def _change(): + return agent.GitHubChange( + pr_title="Add healthz", pr_body_markdown="body", + plan_file_markdown="# plan", commit_message="add plan", + ) + + +async def test_github_to_output_success(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "t") + monkeypatch.setenv("GITHUB_ALLOWED_OWNER", "example-org") + + def fake_create(**kwargs): + assert kwargs["request_id"] == "req-1" + return {"branch": "feat/req-1", "pr_number": 5, + "pr_url": "https://github.com/example-org/svc/pull/5", + "plan_file": "docs/agent-plan-req-1.md", "created_or_existed": "created"} + + monkeypatch.setattr(gh, "create_pr_with_plan", fake_create) + out = (await agent.to_output(_req(), _change())).validate() + assert out.status == STATUS_SUCCESS + assert out.next_action == ACTION_CONTINUE + assert out.details["pr_number"] == 5 + assert "#5" in out.summary + + +async def test_github_to_output_guard_violation_fails(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "t") + monkeypatch.delenv("GITHUB_ALLOWED_OWNER", raising=False) + + def fake_create(**kwargs): + raise gh.GitHubWriteNotAllowed("fail-closed") + + monkeypatch.setattr(gh, "create_pr_with_plan", fake_create) + out = (await agent.to_output(_req(), _change())).validate() + assert out.status == STATUS_FAILED + assert out.retryable is False + assert out.next_action == ACTION_FAIL + + +async def test_github_to_output_permanent_error_fails(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "t") + monkeypatch.setenv("GITHUB_ALLOWED_OWNER", "example-org") + + def fake_create(**kwargs): + raise gh.PermanentGitHubError("404") + + monkeypatch.setattr(gh, "create_pr_with_plan", fake_create) + out = (await agent.to_output(_req(), _change())).validate() + assert out.status == STATUS_FAILED + assert out.next_action == ACTION_FAIL +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=src python -m pytest tests/test_github_live.py -v` +Expected: FAIL with `AttributeError: module 'github_agent_worker.agent' has no attribute 'GitHubChange'` + +- [ ] **Step 3: Add live wiring to `github_agent_worker/agent.py`** + +Add to the imports at the top of `src/github_agent_worker/agent.py`: + +```python +import asyncio + +from pydantic import BaseModel, Field + +from shared import github as gh +from shared.config import get_settings +from shared.contracts import ACTION_FAIL, STATUS_FAILED +``` + +Then append to the module (after `mock`): + +```python +class GitHubChange(BaseModel): + """LLM-authored PR content (the git mechanics are owned by the activity).""" + + pr_title: str = Field(description="Concise PR title") + pr_body_markdown: str = Field(description="PR description in markdown") + plan_file_markdown: str = Field(description="Full content for the committed plan file") + commit_message: str = Field(description="Commit message for the plan file") + + +RESPONSE_MODEL = GitHubChange + + +def build_prompt(request: AgentRequest) -> str: + planner = request.upstream.get(STAGE_PLANNING) + steps = planner.details.get("steps", []) if planner else [] + steps_text = "\n".join(f"- {s}" for s in steps) or "- (no upstream plan)" + return ( + f"Engineering goal: {request.goal}\n" + f"Target repository: {request.repo_url}\n" + f"Environment: {request.environment}\n" + f"Planner steps:\n{steps_text}\n\n" + "Write the pull request title, a markdown PR body, the markdown content " + "for a committed plan file documenting this change, and a commit message." + ) + + +async def to_output(request: AgentRequest, parsed: GitHubChange) -> AgentOutput: + settings = get_settings() + try: + details = await asyncio.to_thread( + gh.create_pr_with_plan, + repo_url=request.repo_url, + request_id=request.request_id, + token=settings.github_token, + allowed_owner=settings.github_allowed_owner, + pr_title=parsed.pr_title, + pr_body=parsed.pr_body_markdown, + plan_markdown=parsed.plan_file_markdown, + commit_message=parsed.commit_message, + ) + except (gh.GitHubWriteNotAllowed, gh.PermanentGitHubError) as exc: + return AgentOutput( + agent_name=AGENT_NAME, + stage=STAGE_GITHUB, + status=STATUS_FAILED, + retryable=False, + summary=f"github write failed: {exc}", + next_action=ACTION_FAIL, + details={"error": str(exc), "error_type": type(exc).__name__}, + ) + + return AgentOutput( + agent_name=AGENT_NAME, + stage=STAGE_GITHUB, + status=STATUS_SUCCESS, + retryable=False, + summary=f"opened pull request #{details['pr_number']} on branch {details['branch']}", + next_action=ACTION_CONTINUE, + details=details, + ) +``` + +- [ ] **Step 4: Wire the activity to pass the live params** + +In `src/github_agent_worker/activities.py`, replace the `run_agent(...)` call with: + +```python + output = await run_agent( + agent_name=agent.AGENT_NAME, + stage=request.stage, + instructions=agent.INSTRUCTIONS, + request=request, + mock=agent.mock, + build_prompt=agent.build_prompt, + response_model=agent.RESPONSE_MODEL, + to_output=agent.to_output, + ) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `PYTHONPATH=src python -m pytest tests/test_github_live.py -v` +Expected: PASS (3 passed) + +- [ ] **Step 6: Run the full suite** + +Run: `PYTHONPATH=src python -m pytest -q --timeout=120 --timeout-method=thread` +Expected: PASS (35 passed) + +- [ ] **Step 7: Commit** + +```bash +git add src/github_agent_worker/agent.py src/github_agent_worker/activities.py tests/test_github_live.py +git commit -m "feat(phase2): github agent live wiring (Azure OpenAI + real PR writes)" +``` + +--- + +### Task 6: Approval agent goes live + +**Files:** +- Modify: `src/approval_agent_worker/agent.py` +- Modify: `src/approval_agent_worker/activities.py` (the `run_agent(...)` call) +- Test: `tests/test_approval_live.py` + +**Interfaces:** +- Consumes: `run_agent` (Task 2); `get_settings` for `approval_auto`/`approval_timeout_seconds` (already used by the existing mock). +- Produces in `approval_agent_worker.agent`: `class ApprovalAssessment(BaseModel)` with `recommendation: Literal["approve","reject","needs_human"]`, `risk_level: Literal["low","medium","high"]`, `reasons: list[str]`; `RESPONSE_MODEL = ApprovalAssessment`; `build_prompt`; `async to_output` implementing the escalation matrix. The workflow's durable human-signal gate is unchanged — `to_output` must keep emitting the `auto_approve`/`timeout_seconds` fields in `details` exactly like the mock does. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_approval_live.py`: + +```python +from __future__ import annotations + +import pytest + +from shared.contracts import ( + STATUS_NEEDS_APPROVAL, STATUS_SUCCESS, STAGE_AKS, AgentOutput, AgentRequest, +) +from approval_agent_worker import agent + + +def _req(approval_required, aks_pending=False): + upstream = {} + if aks_pending: + upstream[STAGE_AKS] = AgentOutput( + agent_name="aks", stage="aks", status=STATUS_NEEDS_APPROVAL, + retryable=False, summary="staged", next_action="ask_human", + details={"promotion_pending": True}, + ) + return AgentRequest( + request_id="r1", goal="g", repo_url="https://github.com/o/r", + environment="prod" if approval_required else "dev", stage="approval", + approval_required=approval_required, upstream=upstream, + ) + + +def _assess(recommendation, risk): + return agent.ApprovalAssessment( + recommendation=recommendation, risk_level=risk, reasons=["r"], + ) + + +@pytest.mark.parametrize("required,aks,reco,risk,expected", [ + (True, False, "approve", "low", STATUS_NEEDS_APPROVAL), # required by config + (False, True, "approve", "low", STATUS_NEEDS_APPROVAL), # AKS staged a promotion + (False, False, "needs_human", "low", STATUS_NEEDS_APPROVAL), # LLM asks for a human + (False, False, "approve", "high", STATUS_NEEDS_APPROVAL), # LLM escalates on risk + (False, False, "approve", "low", STATUS_SUCCESS), # low-risk, not required +]) +async def test_approval_escalation_matrix(monkeypatch, required, aks, reco, risk, expected): + monkeypatch.setenv("TEMPORAL_APPROVAL_AUTO", "true") + out = (await agent.to_output(_req(required, aks), _assess(reco, risk))).validate() + assert out.status == expected + # The workflow reads these regardless of branch: + assert "auto_approve" in out.details + assert "timeout_seconds" in out.details + assert out.details["recommendation"] == reco +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=src python -m pytest tests/test_approval_live.py -v` +Expected: FAIL with `AttributeError: module 'approval_agent_worker.agent' has no attribute 'ApprovalAssessment'` + +- [ ] **Step 3: Add live wiring to `approval_agent_worker/agent.py`** + +Add to the imports at the top of `src/approval_agent_worker/agent.py`: + +```python +from typing import Literal + +from pydantic import BaseModel, Field +``` + +Then append to the module (after `mock`): + +```python +class ApprovalAssessment(BaseModel): + """LLM risk classification. The workflow still owns the durable human gate.""" + + recommendation: Literal["approve", "reject", "needs_human"] + risk_level: Literal["low", "medium", "high"] + reasons: list[str] = Field(description="Short bullet reasons for the recommendation") + + +RESPONSE_MODEL = ApprovalAssessment + + +def build_prompt(request: AgentRequest) -> str: + aks = request.upstream.get(STAGE_AKS) + aks_pending = bool(aks and aks.details.get("promotion_pending")) + return ( + f"Engineering goal: {request.goal}\n" + f"Environment: {request.environment}\n" + f"Approval required by policy: {request.approval_required}\n" + f"AKS staged a pending promotion: {aks_pending}\n\n" + "Assess deployment risk and recommend approve, reject, or needs_human." + ) + + +async def to_output(request: AgentRequest, parsed: ApprovalAssessment) -> AgentOutput: + settings = get_settings() + aks = request.upstream.get(STAGE_AKS) + aks_pending = bool(aks and aks.details.get("promotion_pending")) + + needs_approval = ( + request.approval_required + or aks_pending + or parsed.recommendation == "needs_human" + or parsed.risk_level == "high" + ) + + details = { + "auto_approve": settings.approval_auto, + "timeout_seconds": settings.approval_timeout_seconds, + "environment": request.environment, + "recommendation": parsed.recommendation, + "risk_level": parsed.risk_level, + "reasons": parsed.reasons, + } + + if not needs_approval: + return AgentOutput( + agent_name=AGENT_NAME, + stage=STAGE_APPROVAL, + status=STATUS_SUCCESS, + retryable=False, + summary="low risk; auto-promoted without human gate", + next_action=ACTION_CONTINUE, + details=details, + ) + + return AgentOutput( + agent_name=AGENT_NAME, + stage=STAGE_APPROVAL, + status=STATUS_NEEDS_APPROVAL, + retryable=False, + summary=f"human approval required to promote to {request.environment}", + next_action=ACTION_ASK_HUMAN, + details=details, + ) +``` + +- [ ] **Step 4: Wire the activity to pass the live params** + +In `src/approval_agent_worker/activities.py`, replace the `run_agent(...)` call with: + +```python + output = await run_agent( + agent_name=agent.AGENT_NAME, + stage=request.stage, + instructions=agent.INSTRUCTIONS, + request=request, + mock=agent.mock, + build_prompt=agent.build_prompt, + response_model=agent.RESPONSE_MODEL, + to_output=agent.to_output, + ) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `PYTHONPATH=src python -m pytest tests/test_approval_live.py -v` +Expected: PASS (5 passed) + +- [ ] **Step 6: Run the full suite** + +Run: `PYTHONPATH=src python -m pytest -q --timeout=120 --timeout-method=thread` +Expected: PASS (40 passed) + +- [ ] **Step 7: Commit** + +```bash +git add src/approval_agent_worker/agent.py src/approval_agent_worker/activities.py tests/test_approval_live.py +git commit -m "feat(phase2): approval agent live wiring with risk escalation" +``` + +--- + +### Task 7: AKS stays mock — lock it with a regression test + +**Files:** +- Test: `tests/test_aks_stays_mock.py` +- (No source change expected — this task proves the fallback in Task 2 keeps AKS on the mock even when `AGENT_MODE=live`.) + +**Interfaces:** +- Consumes: `aks_agent_worker.agent.mock`, `aks_agent_worker.activities.run_aks_agent`. + +- [ ] **Step 1: Write the test** + +Create `tests/test_aks_stays_mock.py`: + +```python +from __future__ import annotations + +from shared.contracts import STATUS_NEEDS_APPROVAL, STATUS_SUCCESS, AgentRequest +from aks_agent_worker import agent +from aks_agent_worker.activities import run_aks_agent + + +def _req(approval_required=True): + return AgentRequest( + request_id="r1", goal="g", repo_url="https://github.com/o/r", + environment="dev", stage="aks", approval_required=approval_required, + ) + + +def test_aks_module_has_no_live_wiring(): + # AKS must NOT expose a response model / to_output -> run_agent stays mock. + assert not hasattr(agent, "RESPONSE_MODEL") + assert not hasattr(agent, "to_output") + + +async def test_aks_activity_stays_mock_even_in_live_mode(monkeypatch): + monkeypatch.setenv("AGENT_MODE", "live") + out = await run_aks_agent(_req(approval_required=True)) + # Deterministic mock behaviour (needs_approval when approval_required). + assert out.agent_name == "aks" + assert out.status == STATUS_NEEDS_APPROVAL +``` + +- [ ] **Step 2: Run the test** + +Run: `PYTHONPATH=src python -m pytest tests/test_aks_stays_mock.py -v` +Expected: PASS (2 passed). If `test_aks_activity_stays_mock_even_in_live_mode` fails because the activity tried to go live, the fallback in Task 2 (`live_supported`) is wrong — fix `run_agent` so missing `response_model`/`to_output` always falls back to mock. + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_aks_stays_mock.py +git commit -m "test(phase2): lock AKS agent to mock even under AGENT_MODE=live" +``` + +--- + +### Task 8: Docker + k8s for live mode + +**Files:** +- Modify: `Dockerfile` +- Create: `k8s/secrets/live-agents-secret.example.yaml` +- Modify: `k8s/deployments/planner-agent-worker.yaml`, `github-agent-worker.yaml`, `approval-agent-worker.yaml` (add `envFrom` secret + `AGENT_MODE` note) + +**Interfaces:** +- Consumes: env var names from Task 1. + +- [ ] **Step 1: Add a live build stage to the Dockerfile** + +In `Dockerfile`, replace the dependency-install line +`RUN pip install --no-cache-dir -r requirements.txt` +with a build-arg-controlled install: + +```dockerfile +ARG INSTALL_LIVE=false +COPY requirements.txt requirements-live.txt ./ +RUN if [ "$INSTALL_LIVE" = "true" ]; then \ + pip install --no-cache-dir -r requirements-live.txt; \ + else \ + pip install --no-cache-dir -r requirements.txt; \ + fi +``` + +(Also update the earlier `COPY requirements.txt ./` line if present so both files are available — the snippet above already copies both.) + +Build a live image with: `docker build --build-arg INSTALL_LIVE=true -t temporal-maf-agents-poc:live .` + +- [ ] **Step 2: Create the example Secret** + +Create `k8s/secrets/live-agents-secret.example.yaml`: + +```yaml +# Copy to live-agents-secret.yaml, fill in real values, and `kubectl apply -f` it. +# Keyless Azure (AKS workload identity) is preferred: omit AZURE_OPENAI_API_KEY and +# annotate the worker ServiceAccount for workload identity instead. +apiVersion: v1 +kind: Secret +metadata: + name: live-agents-secret + namespace: agent-platform +type: Opaque +stringData: + AZURE_OPENAI_ENDPOINT: "https://.openai.azure.com" + AZURE_OPENAI_CHAT_DEPLOYMENT: "gpt-4o" + AZURE_OPENAI_API_VERSION: "2024-10-21" + # AZURE_OPENAI_API_KEY: "" # omit to use workload identity + GITHUB_TOKEN: "" + GITHUB_ALLOWED_OWNER: "example-org" +``` + +- [ ] **Step 3: Reference the Secret + flip AGENT_MODE in the three live workers** + +In each of `k8s/deployments/planner-agent-worker.yaml`, `github-agent-worker.yaml`, and `approval-agent-worker.yaml`, add an `envFrom` entry alongside the existing `configMapRef` and add `AGENT_MODE: live` via the env list. The container `env`/`envFrom` block becomes: + +```yaml + envFrom: + - configMapRef: + name: temporal-config + - secretRef: + name: live-agents-secret + env: + - name: WORKER_MODULE + value: planner_agent_worker.worker # github_/approval_ in the others + - name: AGENT_MODE + value: live +``` + +(Leave `orchestrator-worker.yaml` and `aks-agent-worker.yaml` unchanged.) + +- [ ] **Step 4: Validate the manifests parse** + +Run: +```bash +PYTHONPATH=src python - <<'PY' +import glob, yaml +for f in sorted(glob.glob("k8s/**/*.yaml", recursive=True)): + list(yaml.safe_load_all(open(f))) + print("ok", f) +PY +``` +Expected: every file prints `ok`. + +- [ ] **Step 5: Commit** + +```bash +git add Dockerfile k8s/secrets/live-agents-secret.example.yaml k8s/deployments/planner-agent-worker.yaml k8s/deployments/github-agent-worker.yaml k8s/deployments/approval-agent-worker.yaml +git commit -m "feat(phase2): live Docker build arg + k8s secret wiring" +``` + +--- + +### Task 9: Docs + opt-in live smoke test + +**Files:** +- Modify: `README.md` (expand the "Phase 2 — going live" section; flip the acceptance-criteria checkbox) +- Modify: `.env.example` (fill real Azure/GitHub vars) +- Create: `tests/test_live_smoke.py` + +**Interfaces:** +- Consumes: everything above. + +- [ ] **Step 1: Create the opt-in live smoke test** + +Create `tests/test_live_smoke.py`: + +```python +"""Opt-in live smoke test. Skipped unless real creds + RUN_LIVE_SMOKE=1. + +Run with: + RUN_LIVE_SMOKE=1 AGENT_MODE=live \ + AZURE_OPENAI_ENDPOINT=... AZURE_OPENAI_CHAT_DEPLOYMENT=... \ + GITHUB_TOKEN=... GITHUB_ALLOWED_OWNER= \ + PYTHONPATH=src pytest tests/test_live_smoke.py -v +""" + +from __future__ import annotations + +import os + +import pytest + +from shared.contracts import STATUS_SUCCESS, AgentRequest +from planner_agent_worker import agent as planner + +pytestmark = pytest.mark.skipif( + os.getenv("RUN_LIVE_SMOKE") != "1" + or not os.getenv("AZURE_OPENAI_ENDPOINT") + or not os.getenv("AZURE_OPENAI_CHAT_DEPLOYMENT"), + reason="live smoke disabled (set RUN_LIVE_SMOKE=1 + Azure env to enable)", +) + + +async def test_planner_round_trip_against_azure_openai(monkeypatch): + monkeypatch.setenv("AGENT_MODE", "live") + from shared.maf import run_agent + + req = AgentRequest( + request_id="smoke-1", goal="add a /healthz endpoint", + repo_url=f"https://github.com/{os.getenv('GITHUB_ALLOWED_OWNER','example-org')}/svc", + environment="dev", stage="planning", + ) + out = await run_agent( + agent_name=planner.AGENT_NAME, stage="planning", instructions=planner.INSTRUCTIONS, + request=req, mock=planner.mock, build_prompt=planner.build_prompt, + response_model=planner.RESPONSE_MODEL, to_output=planner.to_output, + ) + assert out.status == STATUS_SUCCESS + assert out.details["steps"] +``` + +- [ ] **Step 2: Run it to confirm it skips cleanly without creds** + +Run: `PYTHONPATH=src python -m pytest tests/test_live_smoke.py -v` +Expected: `1 skipped` (reason: live smoke disabled). + +- [ ] **Step 3: Update `.env.example`** + +Replace the Phase-2 commented block at the bottom of `.env.example` with real-looking, uncommented placeholders: + +```bash +# --------------------------------------------------------------------------- +# Phase 2 — live integrations (consumed inside activities only) +# Set AGENT_MODE=live above to enable. +# --------------------------------------------------------------------------- +AZURE_OPENAI_ENDPOINT=https://.openai.azure.com +AZURE_OPENAI_CHAT_DEPLOYMENT=gpt-4o +AZURE_OPENAI_API_VERSION=2024-10-21 +# Leave AZURE_OPENAI_API_KEY unset to use DefaultAzureCredential (az login / workload identity) +# AZURE_OPENAI_API_KEY= +GITHUB_TOKEN= +# Required in live mode (fail-closed): the owner the GitHub agent may write to +GITHUB_ALLOWED_OWNER= +``` + +- [ ] **Step 4: Expand the README "Phase 2" section** + +In `README.md`, replace the existing "Phase 2 — going live" section body with: + +```markdown +## Phase 2 — going live (Azure OpenAI + GitHub) + +The planner, github, and approval agents run real Azure OpenAI reasoning (via +Microsoft Agent Framework) and the github agent makes real GitHub writes. The +AKS agent stays mock. + +1. Install live deps: `pip install -r requirements-live.txt` (or `pip install -e ".[live]"`). +2. Set env (see `.env.example`): + - `AGENT_MODE=live` + - `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_CHAT_DEPLOYMENT`, `AZURE_OPENAI_API_VERSION` + - Azure auth: set `AZURE_OPENAI_API_KEY`, **or** leave it unset and use + `DefaultAzureCredential` (`az login` locally / workload identity on AKS). + - `GITHUB_TOKEN` (fine-grained PAT) and `GITHUB_ALLOWED_OWNER` (the github + agent refuses to write unless the target repo's owner matches — fail-closed). +3. Run workers + `python -m starter` as in Phase 1. + +What the github agent does: creates branch `feat/`, commits +`docs/agent-plan-.md`, and opens a PR. All writes are idempotent, so +Temporal activity retries converge instead of duplicating. + +Error handling: transient Azure/GitHub errors (5xx, rate limit, timeout, or a +schema-invalid model response) are raised and retried by Temporal's activity +retry policy; permanent errors (auth, missing repo, validation, guard violation) +fail the workflow without pointless retries. + +Live on AKS: build the live image with `--build-arg INSTALL_LIVE=true`, apply +`k8s/secrets/live-agents-secret.yaml`, and use the updated planner/github/approval +deployments (which set `AGENT_MODE=live` and mount the secret). + +Tests: `pytest` runs everything with mocked clients (no creds). The opt-in live +smoke test runs only with `RUN_LIVE_SMOKE=1` + real Azure env. +``` + +Also flip the acceptance-criteria checkbox from +`- [ ] Phase 2 real integrations (TODO stubs in place)` +to +`- [x] Phase 2 real integrations — Azure OpenAI + GitHub live (AKS still mock)`. + +- [ ] **Step 5: Run the entire suite one last time** + +Run: `PYTHONPATH=src python -m pytest -q --timeout=120 --timeout-method=thread` +Expected: PASS (42 passed, 1 skipped). + +- [ ] **Step 6: Commit** + +```bash +git add README.md .env.example tests/test_live_smoke.py +git commit -m "docs(phase2): live-mode README, .env.example, opt-in live smoke test" +``` + +--- + +## Notes for the implementer + +- **Run from `temporal-maf-agents-poc/`** with a venv that has `temporalio`, `pydantic`, `pytest`, `pytest-asyncio`, `pytest-timeout`, and `PyGithub`. The full suite needs `PyGithub` (Task 3 imports `github.GithubException`) and `pydantic` (response models), but **not** `agent-framework` or `azure-identity` — the MAF tests monkeypatch `build_chat_client`/`run_live_agent`, so no real Azure SDK is imported in CI. Install for testing with: `pip install -e ".[dev]" PyGithub`. `asyncio_mode = "auto"` is already set in `pyproject.toml`, so `async def test_...` functions run without decorators. +- **Test counts** in the "Expected" lines assume the prior task's tests are present and passing; if you run a single file the totals differ — that's fine, just confirm no failures. +- **Do not touch** any `*/workflows.py`, `shared/contracts.py`, `shared/child.py`, the orchestrator, KEDA manifests, or the AKS agent's behavior. +- **Determinism guard:** if a test or import ever pulls `agent_framework`/`azure`/`github` into a workflow module, you've crossed the boundary — move it back into the activity/seam. From e89c2fd41e5aae00dd0d3c6998887451ef5b562b Mon Sep 17 00:00:00 2001 From: Roey Zalta Date: Thu, 25 Jun 2026 10:21:30 +0300 Subject: [PATCH 03/17] feat(phase2): add Azure OpenAI + GitHub config settings and live deps --- temporal-maf-agents-poc/pyproject.toml | 46 ++++++++++ temporal-maf-agents-poc/requirements-live.txt | 5 + temporal-maf-agents-poc/requirements.txt | 4 + temporal-maf-agents-poc/src/shared/config.py | 91 +++++++++++++++++++ .../tests/test_config_phase2.py | 32 +++++++ 5 files changed, 178 insertions(+) create mode 100644 temporal-maf-agents-poc/pyproject.toml create mode 100644 temporal-maf-agents-poc/requirements-live.txt create mode 100644 temporal-maf-agents-poc/requirements.txt create mode 100644 temporal-maf-agents-poc/src/shared/config.py create mode 100644 temporal-maf-agents-poc/tests/test_config_phase2.py diff --git a/temporal-maf-agents-poc/pyproject.toml b/temporal-maf-agents-poc/pyproject.toml new file mode 100644 index 0000000..eb8209c --- /dev/null +++ b/temporal-maf-agents-poc/pyproject.toml @@ -0,0 +1,46 @@ +[project] +name = "temporal-maf-agents-poc" +version = "0.1.0" +description = "Durable multi-agent orchestration: Temporal + Microsoft Agent Framework on AKS, autoscaled by KEDA" +requires-python = ">=3.10" +dependencies = [ + # Durable orchestration layer. + "temporalio>=1.7,<2", + # Microsoft Agent Framework — used ONLY inside Temporal activities. + # Phase 1 (AGENT_MODE=mock) does not import it at runtime; it is declared + # so Phase 2 (AGENT_MODE=live) works without changing dependencies. + "agent-framework>=0.0.0a1", + "python-dotenv>=1.0", + "pydantic>=2.7", +] + +[project.optional-dependencies] +# Phase 2 integrations (real Azure / GitHub / Kubernetes). Install on demand. +live = [ + "agent-framework>=0.0.0a1", + "azure-identity>=1.17", + "PyGithub>=2.3", +] +dev = [ + "pytest>=8", + "pytest-asyncio>=0.23", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = [ + "src/shared", + "src/orchestrator_worker", + "src/planner_agent_worker", + "src/github_agent_worker", + "src/aks_agent_worker", + "src/approval_agent_worker", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +pythonpath = ["src"] +testpaths = ["tests"] diff --git a/temporal-maf-agents-poc/requirements-live.txt b/temporal-maf-agents-poc/requirements-live.txt new file mode 100644 index 0000000..c1c7386 --- /dev/null +++ b/temporal-maf-agents-poc/requirements-live.txt @@ -0,0 +1,5 @@ +# Phase 2 live integrations. Install with: pip install -r requirements-live.txt +-r requirements.txt +agent-framework>=0.0.0a1 +azure-identity>=1.17 +PyGithub>=2.3 diff --git a/temporal-maf-agents-poc/requirements.txt b/temporal-maf-agents-poc/requirements.txt new file mode 100644 index 0000000..f582b0c --- /dev/null +++ b/temporal-maf-agents-poc/requirements.txt @@ -0,0 +1,4 @@ +# Phase 1 runtime (mock agents) + response-model definitions. +temporalio>=1.7,<2 +python-dotenv>=1.0 +pydantic>=2.7 diff --git a/temporal-maf-agents-poc/src/shared/config.py b/temporal-maf-agents-poc/src/shared/config.py new file mode 100644 index 0000000..6a240c5 --- /dev/null +++ b/temporal-maf-agents-poc/src/shared/config.py @@ -0,0 +1,91 @@ +"""Central configuration: Temporal connection details and task-queue names. + +Read from environment variables so the same image runs locally +(docker-compose) and on AKS (Deployment env / ConfigMap) unchanged. + +Importable from workflow code: it only reads ``os.environ`` at *call* time, +never performs I/O at import time, and exposes the task-queue names as plain +constants that workflows use to route child workflows. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass + +# --------------------------------------------------------------------------- +# Namespace + task queues (constants — referenced from deterministic workflows) +# --------------------------------------------------------------------------- + +NAMESPACE = "agent-platform" + +ORCHESTRATOR_TASK_QUEUE = "orchestrator-tq" +PLANNER_TASK_QUEUE = "planner-agent-tq" +GITHUB_TASK_QUEUE = "github-agent-tq" +AKS_TASK_QUEUE = "aks-agent-tq" +APPROVAL_TASK_QUEUE = "approval-agent-tq" + +ALL_AGENT_TASK_QUEUES = ( + PLANNER_TASK_QUEUE, + GITHUB_TASK_QUEUE, + AKS_TASK_QUEUE, + APPROVAL_TASK_QUEUE, +) + +# Workflow / activity type names (string identifiers used when starting +# child workflows by name and when registering with the worker). +ORCHESTRATOR_WORKFLOW = "AgentOrchestratorWorkflow" +PLANNER_WORKFLOW = "PlannerAgentWorkflow" +GITHUB_WORKFLOW = "GitHubAgentWorkflow" +AKS_WORKFLOW = "AKSAgentWorkflow" +APPROVAL_WORKFLOW = "ApprovalAgentWorkflow" + + +# --------------------------------------------------------------------------- +# Runtime settings (read lazily so importing this module is side-effect free) +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Settings: + temporal_address: str + temporal_namespace: str + # Approval behaviour for the POC. With mocks there is no real human in the + # loop, so the approval workflow can auto-resolve after a timeout instead + # of blocking forever. Set TEMPORAL_APPROVAL_AUTO=false to require a signal. + approval_auto: bool + approval_timeout_seconds: int + health_port: int + # Phase toggle: "mock" (Phase 1, default) or "live" (Phase 2 — real + # Azure/GitHub/Kubernetes/MCP calls inside activities only). + agent_mode: str + # Phase 2 — Azure OpenAI (read lazily; activity-side only) + azure_openai_endpoint: str | None + azure_openai_deployment: str | None + azure_openai_api_version: str + azure_openai_api_key: str | None + # Phase 2 — GitHub + github_token: str | None + github_allowed_owner: str | None + + +def get_settings() -> Settings: + """Build a :class:`Settings` from the current environment. + + Call this from worker / activity / starter code — never at module import + time inside workflow modules. + """ + return Settings( + temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"), + temporal_namespace=os.getenv("TEMPORAL_NAMESPACE", NAMESPACE), + approval_auto=os.getenv("TEMPORAL_APPROVAL_AUTO", "true").lower() != "false", + approval_timeout_seconds=int(os.getenv("TEMPORAL_APPROVAL_TIMEOUT_SECONDS", "30")), + health_port=int(os.getenv("HEALTH_PORT", "8080")), + agent_mode=os.getenv("AGENT_MODE", "mock").lower(), + azure_openai_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), + azure_openai_deployment=os.getenv("AZURE_OPENAI_CHAT_DEPLOYMENT"), + azure_openai_api_version=os.getenv("AZURE_OPENAI_API_VERSION", "2024-10-21"), + azure_openai_api_key=os.getenv("AZURE_OPENAI_API_KEY"), + github_token=os.getenv("GITHUB_TOKEN"), + github_allowed_owner=os.getenv("GITHUB_ALLOWED_OWNER"), + ) diff --git a/temporal-maf-agents-poc/tests/test_config_phase2.py b/temporal-maf-agents-poc/tests/test_config_phase2.py new file mode 100644 index 0000000..42fd10e --- /dev/null +++ b/temporal-maf-agents-poc/tests/test_config_phase2.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from shared.config import get_settings + + +def test_phase2_defaults(monkeypatch): + for var in ( + "AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_CHAT_DEPLOYMENT", + "AZURE_OPENAI_API_KEY", "GITHUB_TOKEN", "GITHUB_ALLOWED_OWNER", + ): + monkeypatch.delenv(var, raising=False) + s = get_settings() + assert s.azure_openai_endpoint is None + assert s.azure_openai_deployment is None + assert s.azure_openai_api_key is None + assert s.github_token is None + assert s.github_allowed_owner is None + assert s.azure_openai_api_version # has a non-empty default + + +def test_phase2_reads_env(monkeypatch): + monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://x.openai.azure.com") + monkeypatch.setenv("AZURE_OPENAI_CHAT_DEPLOYMENT", "gpt-4o") + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "k") + monkeypatch.setenv("GITHUB_TOKEN", "t") + monkeypatch.setenv("GITHUB_ALLOWED_OWNER", "example-org") + s = get_settings() + assert s.azure_openai_endpoint == "https://x.openai.azure.com" + assert s.azure_openai_deployment == "gpt-4o" + assert s.azure_openai_api_key == "k" + assert s.github_token == "t" + assert s.github_allowed_owner == "example-org" From 66eb1daeb253a6d34f45d298de589d2107a74609 Mon Sep 17 00:00:00 2001 From: Roey Zalta Date: Thu, 25 Jun 2026 10:24:24 +0300 Subject: [PATCH 04/17] feat(phase1): commit Temporal + MAF POC baseline (mock agents) The Phase-1 implementation (parent + 4 child workflows, mock agents, k8s + KEDA manifests, docker-compose, tests) was authored but never committed. Track it as the baseline before Phase-2 live wiring lands on top. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01U8d6DuACgKfMFiFbaQ2AFQ --- temporal-maf-agents-poc/.dockerignore | 13 + temporal-maf-agents-poc/.env.example | 35 +++ temporal-maf-agents-poc/.gitignore | 10 + temporal-maf-agents-poc/Dockerfile | 32 ++ temporal-maf-agents-poc/Makefile | 44 +++ temporal-maf-agents-poc/README.md | 280 ++++++++++++++++++ temporal-maf-agents-poc/docker-compose.yaml | 123 ++++++++ .../k8s/deployments/aks-agent-worker.yaml | 54 ++++ .../deployments/approval-agent-worker.yaml | 54 ++++ .../k8s/deployments/github-agent-worker.yaml | 54 ++++ .../k8s/deployments/orchestrator-worker.yaml | 55 ++++ .../k8s/deployments/planner-agent-worker.yaml | 54 ++++ .../k8s/keda/aks-agent-scaledobject.yaml | 34 +++ .../k8s/keda/approval-agent-scaledobject.yaml | 34 +++ .../k8s/keda/github-agent-scaledobject.yaml | 34 +++ .../k8s/keda/planner-agent-scaledobject.yaml | 34 +++ temporal-maf-agents-poc/k8s/namespace.yaml | 24 ++ temporal-maf-agents-poc/sample-input.json | 7 + temporal-maf-agents-poc/sample-output.json | 70 +++++ .../src/aks_agent_worker/__init__.py | 1 + .../src/aks_agent_worker/activities.py | 37 +++ .../src/aks_agent_worker/agent.py | 63 ++++ .../src/aks_agent_worker/worker.py | 15 + .../src/aks_agent_worker/workflows.py | 19 ++ .../src/approval_agent_worker/__init__.py | 1 + .../src/approval_agent_worker/activities.py | 37 +++ .../src/approval_agent_worker/agent.py | 71 +++++ .../src/approval_agent_worker/worker.py | 15 + .../src/approval_agent_worker/workflows.py | 128 ++++++++ .../src/github_agent_worker/__init__.py | 1 + .../src/github_agent_worker/activities.py | 37 +++ .../src/github_agent_worker/agent.py | 46 +++ .../src/github_agent_worker/worker.py | 15 + .../src/github_agent_worker/workflows.py | 19 ++ .../src/orchestrator_worker/__init__.py | 1 + .../src/orchestrator_worker/worker.py | 20 ++ .../src/orchestrator_worker/workflows.py | 144 +++++++++ .../src/planner_agent_worker/__init__.py | 1 + .../src/planner_agent_worker/activities.py | 57 ++++ .../src/planner_agent_worker/agent.py | 53 ++++ .../src/planner_agent_worker/worker.py | 15 + .../src/planner_agent_worker/workflows.py | 23 ++ .../src/shared/__init__.py | 11 + temporal-maf-agents-poc/src/shared/child.py | 95 ++++++ .../src/shared/contracts.py | 207 +++++++++++++ temporal-maf-agents-poc/src/shared/logging.py | 103 +++++++ temporal-maf-agents-poc/src/shared/maf.py | 107 +++++++ temporal-maf-agents-poc/src/shared/runtime.py | 70 +++++ temporal-maf-agents-poc/src/starter.py | 62 ++++ temporal-maf-agents-poc/tests/test_agents.py | 60 ++++ .../tests/test_contracts.py | 89 ++++++ .../tests/test_workflow_integration.py | 160 ++++++++++ 52 files changed, 2828 insertions(+) create mode 100644 temporal-maf-agents-poc/.dockerignore create mode 100644 temporal-maf-agents-poc/.env.example create mode 100644 temporal-maf-agents-poc/.gitignore create mode 100644 temporal-maf-agents-poc/Dockerfile create mode 100644 temporal-maf-agents-poc/Makefile create mode 100644 temporal-maf-agents-poc/README.md create mode 100644 temporal-maf-agents-poc/docker-compose.yaml create mode 100644 temporal-maf-agents-poc/k8s/deployments/aks-agent-worker.yaml create mode 100644 temporal-maf-agents-poc/k8s/deployments/approval-agent-worker.yaml create mode 100644 temporal-maf-agents-poc/k8s/deployments/github-agent-worker.yaml create mode 100644 temporal-maf-agents-poc/k8s/deployments/orchestrator-worker.yaml create mode 100644 temporal-maf-agents-poc/k8s/deployments/planner-agent-worker.yaml create mode 100644 temporal-maf-agents-poc/k8s/keda/aks-agent-scaledobject.yaml create mode 100644 temporal-maf-agents-poc/k8s/keda/approval-agent-scaledobject.yaml create mode 100644 temporal-maf-agents-poc/k8s/keda/github-agent-scaledobject.yaml create mode 100644 temporal-maf-agents-poc/k8s/keda/planner-agent-scaledobject.yaml create mode 100644 temporal-maf-agents-poc/k8s/namespace.yaml create mode 100644 temporal-maf-agents-poc/sample-input.json create mode 100644 temporal-maf-agents-poc/sample-output.json create mode 100644 temporal-maf-agents-poc/src/aks_agent_worker/__init__.py create mode 100644 temporal-maf-agents-poc/src/aks_agent_worker/activities.py create mode 100644 temporal-maf-agents-poc/src/aks_agent_worker/agent.py create mode 100644 temporal-maf-agents-poc/src/aks_agent_worker/worker.py create mode 100644 temporal-maf-agents-poc/src/aks_agent_worker/workflows.py create mode 100644 temporal-maf-agents-poc/src/approval_agent_worker/__init__.py create mode 100644 temporal-maf-agents-poc/src/approval_agent_worker/activities.py create mode 100644 temporal-maf-agents-poc/src/approval_agent_worker/agent.py create mode 100644 temporal-maf-agents-poc/src/approval_agent_worker/worker.py create mode 100644 temporal-maf-agents-poc/src/approval_agent_worker/workflows.py create mode 100644 temporal-maf-agents-poc/src/github_agent_worker/__init__.py create mode 100644 temporal-maf-agents-poc/src/github_agent_worker/activities.py create mode 100644 temporal-maf-agents-poc/src/github_agent_worker/agent.py create mode 100644 temporal-maf-agents-poc/src/github_agent_worker/worker.py create mode 100644 temporal-maf-agents-poc/src/github_agent_worker/workflows.py create mode 100644 temporal-maf-agents-poc/src/orchestrator_worker/__init__.py create mode 100644 temporal-maf-agents-poc/src/orchestrator_worker/worker.py create mode 100644 temporal-maf-agents-poc/src/orchestrator_worker/workflows.py create mode 100644 temporal-maf-agents-poc/src/planner_agent_worker/__init__.py create mode 100644 temporal-maf-agents-poc/src/planner_agent_worker/activities.py create mode 100644 temporal-maf-agents-poc/src/planner_agent_worker/agent.py create mode 100644 temporal-maf-agents-poc/src/planner_agent_worker/worker.py create mode 100644 temporal-maf-agents-poc/src/planner_agent_worker/workflows.py create mode 100644 temporal-maf-agents-poc/src/shared/__init__.py create mode 100644 temporal-maf-agents-poc/src/shared/child.py create mode 100644 temporal-maf-agents-poc/src/shared/contracts.py create mode 100644 temporal-maf-agents-poc/src/shared/logging.py create mode 100644 temporal-maf-agents-poc/src/shared/maf.py create mode 100644 temporal-maf-agents-poc/src/shared/runtime.py create mode 100644 temporal-maf-agents-poc/src/starter.py create mode 100644 temporal-maf-agents-poc/tests/test_agents.py create mode 100644 temporal-maf-agents-poc/tests/test_contracts.py create mode 100644 temporal-maf-agents-poc/tests/test_workflow_integration.py diff --git a/temporal-maf-agents-poc/.dockerignore b/temporal-maf-agents-poc/.dockerignore new file mode 100644 index 0000000..4201b09 --- /dev/null +++ b/temporal-maf-agents-poc/.dockerignore @@ -0,0 +1,13 @@ +.git +.venv +venv +__pycache__ +*.pyc +.pytest_cache +.env +dist +build +*.egg-info +k8s +docs +tests diff --git a/temporal-maf-agents-poc/.env.example b/temporal-maf-agents-poc/.env.example new file mode 100644 index 0000000..7fcefc9 --- /dev/null +++ b/temporal-maf-agents-poc/.env.example @@ -0,0 +1,35 @@ +# --------------------------------------------------------------------------- +# Temporal connection (used by workers + starter) +# --------------------------------------------------------------------------- +TEMPORAL_ADDRESS=localhost:7233 +TEMPORAL_NAMESPACE=agent-platform + +# --------------------------------------------------------------------------- +# Agent runtime mode +# mock -> Phase 1: deterministic mocked Agent Framework responses (default) +# live -> Phase 2: real Microsoft Agent Framework agents (TODO stubs) +# --------------------------------------------------------------------------- +AGENT_MODE=mock + +# --------------------------------------------------------------------------- +# Approval gate behaviour (ApprovalAgentWorkflow) +# TEMPORAL_APPROVAL_AUTO=true -> auto-approve after the timeout (POC default) +# TEMPORAL_APPROVAL_AUTO=false -> block until a `submit_decision` signal +# --------------------------------------------------------------------------- +TEMPORAL_APPROVAL_AUTO=true +TEMPORAL_APPROVAL_TIMEOUT_SECONDS=30 + +# Health endpoint port for k8s probes. +HEALTH_PORT=8080 + +# Demo: force the planner activity to fail once so Temporal's retry policy +# visibly re-runs it (set to 1 to enable). +# FORCE_TRANSIENT_ERROR=1 + +# --------------------------------------------------------------------------- +# Phase 2 only — real integrations (TODO). All consumed inside activities. +# --------------------------------------------------------------------------- +# AZURE_OPENAI_ENDPOINT=https://.openai.azure.com/ +# AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=gpt-4o +# GITHUB_TOKEN= +# KUBECONFIG= diff --git a/temporal-maf-agents-poc/.gitignore b/temporal-maf-agents-poc/.gitignore new file mode 100644 index 0000000..898843d --- /dev/null +++ b/temporal-maf-agents-poc/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +.venv/ +venv/ +.env +*.egg-info/ +.pytest_cache/ +dist/ +build/ +.DS_Store diff --git a/temporal-maf-agents-poc/Dockerfile b/temporal-maf-agents-poc/Dockerfile new file mode 100644 index 0000000..8d184a0 --- /dev/null +++ b/temporal-maf-agents-poc/Dockerfile @@ -0,0 +1,32 @@ +# Single image, shared by all five worker deployments. Each Kubernetes +# Deployment / docker-compose service selects which worker to run by setting +# the WORKER_MODULE env var (e.g. orchestrator_worker.worker). +FROM python:3.12-slim AS base + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONPATH=/app/src \ + HEALTH_PORT=8080 \ + AGENT_MODE=mock + +WORKDIR /app + +# Install deps first for layer caching. +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +# App source. +COPY src ./src +COPY sample-input.json ./sample-input.json + +# Run as non-root. +RUN useradd --create-home --uid 10001 appuser \ + && chown -R appuser:appuser /app +USER appuser + +EXPOSE 8080 + +# WORKER_MODULE is required at runtime; default to the orchestrator so the +# image is runnable as-is. Override per deployment. +ENV WORKER_MODULE=orchestrator_worker.worker +ENTRYPOINT ["sh", "-c", "exec python -m \"$WORKER_MODULE\""] diff --git a/temporal-maf-agents-poc/Makefile b/temporal-maf-agents-poc/Makefile new file mode 100644 index 0000000..5fbb7ab --- /dev/null +++ b/temporal-maf-agents-poc/Makefile @@ -0,0 +1,44 @@ +# Convenience targets for the Temporal + MAF POC. +# Local-without-docker workflow uses a venv; docker workflow uses compose. + +IMAGE ?= temporal-maf-agents-poc:dev +export PYTHONPATH := src + +.PHONY: help install up down logs run test build workers \ + worker-orchestrator worker-planner worker-github worker-aks worker-approval + +help: + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-22s\033[0m %s\n", $$1, $$2}' + +install: ## Install Python deps into the active environment + pip install -r requirements.txt -e . + +up: ## Start Temporal + all 5 workers via docker-compose + docker compose up --build + +down: ## Stop the docker-compose stack and remove volumes + docker compose down -v + +logs: ## Tail worker logs + docker compose logs -f orchestrator-worker planner-agent-worker github-agent-worker aks-agent-worker approval-agent-worker + +run: ## Start one orchestration from sample-input.json + python -m starter + +test: ## Run the unit tests (no Temporal server needed) + pytest -q + +build: ## Build the shared worker image + docker build -t $(IMAGE) . + +# --- Run individual workers locally (needs a reachable Temporal at $TEMPORAL_ADDRESS) --- +worker-orchestrator: ## Run the orchestrator worker locally + python -m orchestrator_worker.worker +worker-planner: ## Run the planner agent worker locally + python -m planner_agent_worker.worker +worker-github: ## Run the github agent worker locally + python -m github_agent_worker.worker +worker-aks: ## Run the aks agent worker locally + python -m aks_agent_worker.worker +worker-approval: ## Run the approval agent worker locally + python -m approval_agent_worker.worker diff --git a/temporal-maf-agents-poc/README.md b/temporal-maf-agents-poc/README.md new file mode 100644 index 0000000..759b5c5 --- /dev/null +++ b/temporal-maf-agents-poc/README.md @@ -0,0 +1,280 @@ +# Temporal + Microsoft Agent Framework on AKS + +A production-style POC where: + +- **Temporal** is the durable orchestration layer (the single orchestration authority). +- **Microsoft Agent Framework (MAF)** is the agent runtime — used **only inside Temporal activities**. +- **AKS** hosts one worker Deployment per task queue. +- **KEDA** scales each worker from its Temporal task-queue backlog (including scale-to-zero). + +> **Phase 1 (default, `AGENT_MODE=mock`)** proves the whole topology — parent +> workflow, child workflows, task queues, retries, approval gate, KEDA scaling, +> MAF integration seam — with **no cloud credentials**. **Phase 2 +> (`AGENT_MODE=live`)** swaps the mocks for real Azure OpenAI / GitHub / +> Kubernetes / MCP calls *inside the activities only* (TODO stubs are in place). + +--- + +## Architecture + +``` +User Request + │ + ▼ +AgentOrchestratorWorkflow (parent, task queue: orchestrator-tq) + │ executes child workflows in sequence, passing each stage's output forward + ├─▶ PlannerAgentWorkflow (planner-agent-tq) ─▶ RunPlannerAgentActivity ─▶ PlannerAgent (MAF) + ├─▶ GitHubAgentWorkflow (github-agent-tq) ─▶ RunGitHubAgentActivity ─▶ GitHubAgent (MAF) + ├─▶ AKSAgentWorkflow (aks-agent-tq) ─▶ RunAKSAgentActivity ─▶ AKSAgent (MAF) + └─▶ ApprovalAgentWorkflow (approval-agent-tq) ─▶ RunApprovalAgentActivity ─▶ ApprovalAgent (MAF) + │ durable human-in-the-loop wait (signal or auto-approve) + ▼ + Final OrchestrationResult +``` + +### The determinism rule (enforced by the Temporal sandbox) + +Workflow code (`*/workflows.py`, `shared/contracts.py`, `shared/config.py`, +`shared/child.py`) is **deterministic** and never calls: + +- LLMs / Azure OpenAI +- GitHub APIs +- Kubernetes / AKS APIs +- Microsoft Agent Framework tools + +All of that happens in **activities** (`*/activities.py` → `shared/maf.py`). +The agent runtime is reached only through the activity boundary, so Temporal +stays the single orchestration authority. We deliberately do **not** use Agent +Framework's own Durable Workflows as the orchestration layer. + +--- + +## Repository layout + +``` +temporal-maf-agents-poc/ +├── docker-compose.yaml # Temporal + UI + namespace bootstrap + 5 workers +├── Dockerfile # one shared image; WORKER_MODULE selects the worker +├── sample-input.json # example orchestration request +├── sample-output.json # example orchestration result (mock run) +├── pyproject.toml / requirements.txt +├── Makefile +├── src/ +│ ├── shared/ # contracts, config, logging, runtime, child helper, MAF seam +│ ├── orchestrator_worker/ # parent workflow + worker (orchestrator-tq) +│ ├── planner_agent_worker/ # agent + activity + child workflow + worker +│ ├── github_agent_worker/ +│ ├── aks_agent_worker/ +│ ├── approval_agent_worker/ +│ └── starter.py # kicks off an orchestration +├── tests/ # unit tests + end-to-end Temporal time-skipping test +└── k8s/ + ├── namespace.yaml # agent-platform namespace + shared ConfigMap + ├── deployments/ # one Deployment per task queue (5) + └── keda/ # one ScaledObject per agent task queue (4) +``` + +--- + +## Contracts + +### Orchestration input (`sample-input.json`) + +```json +{ + "request_id": "req-001", + "goal": "Add a /healthz endpoint to the payments service and deploy it", + "repo_url": "https://github.com/example-org/payments-service", + "environment": "dev", + "approval_required": true +} +``` + +### Agent output contract + +Every agent returns this structure (`shared/contracts.py:AgentOutput`): + +```json +{ + "agent_name": "planner", + "stage": "planning", + "status": "success", + "retryable": false, + "summary": "deployment plan created", + "details": {}, + "next_action": "continue" +} +``` + +- `status` ∈ `success | failed | needs_approval` +- `next_action` ∈ `continue | retry | fail | rollback | ask_human` + +### Retry & control-flow policy + +Two layers, both implemented (see `shared/child.py`): + +| Layer | Trigger | Policy | +|-------|---------|--------| +| 1. Temporal activity retries | activity **raises** (transient infra error) | `initial_interval=10s`, `backoff_coefficient=2`, `maximum_interval=120s`, `maximum_attempts=3` | +| 2. Workflow business policy | activity **returns** a structured result | `failed + retryable=true` → re-run · `failed + retryable=false` → fail workflow · `needs_approval` → durable wait for approval | + +The pure decision function is `shared.contracts.decide()` (unit-tested). + +--- + +## Run it locally (docker-compose) + +Prereqs: Docker + Docker Compose. + +```bash +cd temporal-maf-agents-poc + +# 1. Start Temporal (with Postgres), create the `agent-platform` namespace, +# and launch all five workers. Temporal UI: http://localhost:8233 +docker compose up --build + +# 2. In another shell, install the client deps and start one orchestration. +python3 -m venv .venv && . .venv/bin/activate +pip install -r requirements.txt +PYTHONPATH=src python -m starter # uses sample-input.json +``` + +You'll see the final `OrchestrationResult` printed (compare with +`sample-output.json`). Open the **Temporal UI** to watch the parent workflow, +the four child workflows on their task queues, the activities, and the approval +timer. + +### Run workers without Docker + +Point the workers at any reachable Temporal (e.g. `temporal server start-dev` +on `localhost:7233`, then create the namespace: +`temporal operator namespace create --namespace agent-platform`): + +```bash +. .venv/bin/activate && pip install -r requirements.txt -e . +export TEMPORAL_ADDRESS=localhost:7233 TEMPORAL_NAMESPACE=agent-platform +make worker-orchestrator # in 5 terminals: -orchestrator -planner -github -aks -approval +make run # start an orchestration +``` + +### Demonstrate the retry policy + +```bash +# Make the planner activity fail once; Temporal's RetryPolicy re-runs it. +FORCE_TRANSIENT_ERROR=1 python -m planner_agent_worker.worker +``` + +### Drive the approval gate manually + +Set `TEMPORAL_APPROVAL_AUTO=false` so the approval workflow blocks on a human +decision, then signal it (workflow id is `-approval`): + +```bash +temporal workflow signal \ + --workflow-id req-001-approval \ + --name submit_decision \ + --input 'true' --input '"LGTM"' +``` + +With the default `TEMPORAL_APPROVAL_AUTO=true`, the gate auto-approves after +`TEMPORAL_APPROVAL_TIMEOUT_SECONDS` so the POC completes end-to-end unattended. + +--- + +## Tests + +```bash +. .venv/bin/activate +pip install -r requirements.txt -e ".[dev]" +PYTHONPATH=src pytest -q +``` + +- `test_contracts.py`, `test_agents.py` — pure, no server. +- `test_workflow_integration.py` — runs the **full parent → 4 children** + pipeline on Temporal's in-memory time-skipping test server (auto-downloads a + test binary on first run; skips if unavailable). Covers the happy path + (auto-approve) and an approval **rejection** that fails the gate. + +--- + +## Deploy to AKS + +Prereqs: an AKS cluster, a Temporal deployment reachable in-cluster +(Temporal Helm chart or Temporal Cloud), and KEDA ≥ 2.17 installed +(`helm install keda kedacore/keda -n keda --create-namespace`). + +```bash +# 1. Build and push the shared worker image to your registry (e.g. ACR). +az acr build -r -t temporal-maf-agents-poc:latest . +# then set that image ref in k8s/deployments/*.yaml (replace temporal-maf-agents-poc:latest) + +# 2. Namespace + shared config. Edit k8s/namespace.yaml first so TEMPORAL_ADDRESS +# points at your Temporal frontend Service (default assumes the Temporal Helm +# chart at temporal-frontend.temporal.svc.cluster.local:7233). +kubectl apply -f k8s/namespace.yaml + +# 3. Worker Deployments (one per task queue). +kubectl apply -f k8s/deployments/ + +# 4. KEDA ScaledObjects (one per agent task queue) — scale 0..10 from backlog. +kubectl apply -f k8s/keda/ + +# 5. Create the Temporal namespace if it doesn't exist yet. +# (from a temporal admin-tools pod / your machine) +temporal operator namespace create --namespace agent-platform +``` + +Notes: + +- The **orchestrator** Deployment is fixed at 1 replica (it must always be + available to host the parent workflow). The four **agent** Deployments are + owned by KEDA — they scale to zero when idle and wake when their task queue + has backlog (`endpoint`, `namespace`, `taskQueue`, `targetQueueSize` in each + `k8s/keda/*-scaledobject.yaml`). +- **Temporal Cloud**: set `TEMPORAL_ADDRESS` to `..tmprl.cloud:7233` + and add mTLS / API-key auth to both the workers and the KEDA ScaledObjects + (via a `TriggerAuthentication` — see the TODO in `k8s/keda/*.yaml`). + +--- + +## Observability + +Workers emit structured JSON logs (`shared/logging.py`) carrying the spec's +fields where available: `workflow_id`, `run_id`, `request_id`, `agent_name`, +`stage`, `status`, `duration_ms`, `error_type`. Each worker also serves a +health endpoint on `:8080` (`/healthz`, `/readyz`) used by the k8s probes. + +Suggested metrics to scrape next (Temporal SDK + KEDA both export Prometheus): +`workflow_duration`, `activity_duration`, `retry_count`, `task_queue_backlog`, +`worker_replicas`, `approval_wait_time`, `failed_workflows`. + +--- + +## Phase 2 — going live + +Replace the mocks with real integrations **inside activities only**: + +1. Implement `shared/maf.run_live_agent()` — build a real MAF agent + (`AzureOpenAIChatClient().as_agent(...)`, `await agent.run(prompt)`), parse + its output into the `AgentOutput` contract, and register the per-stage tools + (GitHub API / Kubernetes API / Azure) as MAF tools or MCP servers. +2. Install the `live` extras: `pip install -e ".[live]"`. +3. Set `AGENT_MODE=live` and the relevant Azure/GitHub/Kubernetes env vars. + +Workflow code does **not** change — Temporal keeps orchestrating; only the +activity bodies gain real side effects. + +--- + +## Acceptance criteria — status + +- [x] Local Temporal starts (docker-compose) +- [x] Parent workflow executes +- [x] Child workflows execute (one per task queue) +- [x] Activities invoke mocked Microsoft Agent Framework agents +- [x] Retry logic works (both layers; `FORCE_TRANSIENT_ERROR` demo + soft-retry) +- [x] Structured outputs work (validated `AgentOutput` contract) +- [x] AKS manifests exist (`k8s/deployments/`) +- [x] KEDA manifests exist (`k8s/keda/`) +- [x] README with local and AKS deployment instructions +- [ ] Phase 2 real integrations (TODO stubs in place) diff --git a/temporal-maf-agents-poc/docker-compose.yaml b/temporal-maf-agents-poc/docker-compose.yaml new file mode 100644 index 0000000..7d6bae3 --- /dev/null +++ b/temporal-maf-agents-poc/docker-compose.yaml @@ -0,0 +1,123 @@ +# Local development stack: +# * Postgres — Temporal persistence +# * Temporal server — durable orchestration engine (frontend on :7233) +# * Temporal UI — http://localhost:8233 +# * temporal-setup — one-shot: creates the `agent-platform` namespace +# * 5 worker services — orchestrator + 4 agent workers, one per task queue +# +# Usage: +# docker compose up --build +# python -m starter # (from another shell, with deps installed) +# +# All workers run AGENT_MODE=mock (Phase 1) — no cloud credentials required. +name: temporal-maf-agents-poc + +x-worker-env: &worker-env + TEMPORAL_ADDRESS: temporal:7233 + TEMPORAL_NAMESPACE: agent-platform + AGENT_MODE: mock + TEMPORAL_APPROVAL_AUTO: "true" + TEMPORAL_APPROVAL_TIMEOUT_SECONDS: "20" + +x-worker-common: &worker-common + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + depends_on: + temporal-setup: + condition: service_completed_successfully + +services: + postgresql: + image: postgres:16-alpine + environment: + POSTGRES_USER: temporal + POSTGRES_PASSWORD: temporal + POSTGRES_DB: temporal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U temporal"] + interval: 5s + timeout: 5s + retries: 10 + volumes: + - temporal-pg:/var/lib/postgresql/data + + temporal: + image: temporalio/auto-setup:1.25.2 + depends_on: + postgresql: + condition: service_healthy + environment: + DB: postgres12 + DB_PORT: 5432 + POSTGRES_USER: temporal + POSTGRES_PWD: temporal + POSTGRES_SEEDS: postgresql + ports: + - "7233:7233" + healthcheck: + test: ["CMD", "temporal", "workflow", "list", "--address", "temporal:7233"] + interval: 5s + timeout: 5s + retries: 30 + + # Creates the `agent-platform` namespace (auto-setup only creates `default`). + temporal-setup: + image: temporalio/admin-tools:1.25.2 + depends_on: + temporal: + condition: service_healthy + environment: + TEMPORAL_ADDRESS: temporal:7233 + entrypoint: ["sh", "-c"] + command: + - > + temporal operator namespace describe --namespace agent-platform >/dev/null 2>&1 + || temporal operator namespace create --namespace agent-platform --retention 72h; + echo "namespace agent-platform ready" + restart: "no" + + temporal-ui: + image: temporalio/ui:2.34.0 + depends_on: + temporal: + condition: service_healthy + environment: + TEMPORAL_ADDRESS: temporal:7233 + TEMPORAL_CORS_ORIGINS: http://localhost:3000 + ports: + - "8233:8080" + + orchestrator-worker: + <<: *worker-common + environment: + <<: *worker-env + WORKER_MODULE: orchestrator_worker.worker + + planner-agent-worker: + <<: *worker-common + environment: + <<: *worker-env + WORKER_MODULE: planner_agent_worker.worker + + github-agent-worker: + <<: *worker-common + environment: + <<: *worker-env + WORKER_MODULE: github_agent_worker.worker + + aks-agent-worker: + <<: *worker-common + environment: + <<: *worker-env + WORKER_MODULE: aks_agent_worker.worker + + approval-agent-worker: + <<: *worker-common + environment: + <<: *worker-env + WORKER_MODULE: approval_agent_worker.worker + +volumes: + temporal-pg: diff --git a/temporal-maf-agents-poc/k8s/deployments/aks-agent-worker.yaml b/temporal-maf-agents-poc/k8s/deployments/aks-agent-worker.yaml new file mode 100644 index 0000000..365eaa2 --- /dev/null +++ b/temporal-maf-agents-poc/k8s/deployments/aks-agent-worker.yaml @@ -0,0 +1,54 @@ +# AKS agent worker — `aks-agent-tq`. Replica count is owned by KEDA +# (see k8s/keda/aks-agent-scaledobject.yaml); it scales 0..10 from the +# task-queue backlog. Do not set `replicas` here. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: aks-agent-worker + namespace: agent-platform + labels: + app: aks-agent-worker + task-queue: aks-agent-tq +spec: + selector: + matchLabels: + app: aks-agent-worker + template: + metadata: + labels: + app: aks-agent-worker + task-queue: aks-agent-tq + spec: + containers: + - name: worker + # TODO: replace with your registry image. + image: temporal-maf-agents-poc:latest + imagePullPolicy: IfNotPresent + envFrom: + - configMapRef: + name: temporal-config + env: + - name: WORKER_MODULE + value: aks_agent_worker.worker + ports: + - name: health + containerPort: 8080 + livenessProbe: + httpGet: + path: /healthz + port: health + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /readyz + port: health + initialDelaySeconds: 3 + periodSeconds: 10 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: "1" + memory: 512Mi diff --git a/temporal-maf-agents-poc/k8s/deployments/approval-agent-worker.yaml b/temporal-maf-agents-poc/k8s/deployments/approval-agent-worker.yaml new file mode 100644 index 0000000..009ffea --- /dev/null +++ b/temporal-maf-agents-poc/k8s/deployments/approval-agent-worker.yaml @@ -0,0 +1,54 @@ +# Approval agent worker — `approval-agent-tq`. Replica count is owned by KEDA +# (see k8s/keda/approval-agent-scaledobject.yaml); it scales 0..10 from the +# task-queue backlog. Do not set `replicas` here. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: approval-agent-worker + namespace: agent-platform + labels: + app: approval-agent-worker + task-queue: approval-agent-tq +spec: + selector: + matchLabels: + app: approval-agent-worker + template: + metadata: + labels: + app: approval-agent-worker + task-queue: approval-agent-tq + spec: + containers: + - name: worker + # TODO: replace with your registry image. + image: temporal-maf-agents-poc:latest + imagePullPolicy: IfNotPresent + envFrom: + - configMapRef: + name: temporal-config + env: + - name: WORKER_MODULE + value: approval_agent_worker.worker + ports: + - name: health + containerPort: 8080 + livenessProbe: + httpGet: + path: /healthz + port: health + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /readyz + port: health + initialDelaySeconds: 3 + periodSeconds: 10 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: "1" + memory: 512Mi diff --git a/temporal-maf-agents-poc/k8s/deployments/github-agent-worker.yaml b/temporal-maf-agents-poc/k8s/deployments/github-agent-worker.yaml new file mode 100644 index 0000000..9971492 --- /dev/null +++ b/temporal-maf-agents-poc/k8s/deployments/github-agent-worker.yaml @@ -0,0 +1,54 @@ +# GitHub agent worker — `github-agent-tq`. Replica count is owned by KEDA +# (see k8s/keda/github-agent-scaledobject.yaml); it scales 0..10 from the +# task-queue backlog. Do not set `replicas` here. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: github-agent-worker + namespace: agent-platform + labels: + app: github-agent-worker + task-queue: github-agent-tq +spec: + selector: + matchLabels: + app: github-agent-worker + template: + metadata: + labels: + app: github-agent-worker + task-queue: github-agent-tq + spec: + containers: + - name: worker + # TODO: replace with your registry image. + image: temporal-maf-agents-poc:latest + imagePullPolicy: IfNotPresent + envFrom: + - configMapRef: + name: temporal-config + env: + - name: WORKER_MODULE + value: github_agent_worker.worker + ports: + - name: health + containerPort: 8080 + livenessProbe: + httpGet: + path: /healthz + port: health + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /readyz + port: health + initialDelaySeconds: 3 + periodSeconds: 10 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: "1" + memory: 512Mi diff --git a/temporal-maf-agents-poc/k8s/deployments/orchestrator-worker.yaml b/temporal-maf-agents-poc/k8s/deployments/orchestrator-worker.yaml new file mode 100644 index 0000000..6f30393 --- /dev/null +++ b/temporal-maf-agents-poc/k8s/deployments/orchestrator-worker.yaml @@ -0,0 +1,55 @@ +# Orchestrator worker — hosts the parent workflow on `orchestrator-tq`. +# NOT autoscaled by KEDA: it must always be available to accept new +# orchestrations and to run parent-workflow tasks. Keep >=1 replica. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: orchestrator-worker + namespace: agent-platform + labels: + app: orchestrator-worker + task-queue: orchestrator-tq +spec: + replicas: 1 + selector: + matchLabels: + app: orchestrator-worker + template: + metadata: + labels: + app: orchestrator-worker + task-queue: orchestrator-tq + spec: + containers: + - name: worker + # TODO: replace with your registry, e.g. youracr.azurecr.io/temporal-maf-agents-poc:latest + image: temporal-maf-agents-poc:latest + imagePullPolicy: IfNotPresent + envFrom: + - configMapRef: + name: temporal-config + env: + - name: WORKER_MODULE + value: orchestrator_worker.worker + ports: + - name: health + containerPort: 8080 + livenessProbe: + httpGet: + path: /healthz + port: health + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /readyz + port: health + initialDelaySeconds: 3 + periodSeconds: 10 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: "1" + memory: 512Mi diff --git a/temporal-maf-agents-poc/k8s/deployments/planner-agent-worker.yaml b/temporal-maf-agents-poc/k8s/deployments/planner-agent-worker.yaml new file mode 100644 index 0000000..1ed461f --- /dev/null +++ b/temporal-maf-agents-poc/k8s/deployments/planner-agent-worker.yaml @@ -0,0 +1,54 @@ +# Planner agent worker — `planner-agent-tq`. Replica count is owned by KEDA +# (see k8s/keda/planner-agent-scaledobject.yaml); it scales 0..10 from the +# task-queue backlog. Do not set `replicas` here. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: planner-agent-worker + namespace: agent-platform + labels: + app: planner-agent-worker + task-queue: planner-agent-tq +spec: + selector: + matchLabels: + app: planner-agent-worker + template: + metadata: + labels: + app: planner-agent-worker + task-queue: planner-agent-tq + spec: + containers: + - name: worker + # TODO: replace with your registry image. + image: temporal-maf-agents-poc:latest + imagePullPolicy: IfNotPresent + envFrom: + - configMapRef: + name: temporal-config + env: + - name: WORKER_MODULE + value: planner_agent_worker.worker + ports: + - name: health + containerPort: 8080 + livenessProbe: + httpGet: + path: /healthz + port: health + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /readyz + port: health + initialDelaySeconds: 3 + periodSeconds: 10 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: "1" + memory: 512Mi diff --git a/temporal-maf-agents-poc/k8s/keda/aks-agent-scaledobject.yaml b/temporal-maf-agents-poc/k8s/keda/aks-agent-scaledobject.yaml new file mode 100644 index 0000000..eb36b91 --- /dev/null +++ b/temporal-maf-agents-poc/k8s/keda/aks-agent-scaledobject.yaml @@ -0,0 +1,34 @@ +# KEDA ScaledObject for the AKS agent worker. +# Scales the Deployment from the `aks-agent-tq` Temporal task-queue backlog. +# +# Requires KEDA >= 2.17 (the Temporal scaler shipped in 2.17). Docs: +# https://keda.sh/docs/2.19/scalers/temporal/ +# +# Behaviour: +# * minReplicaCount 0 -> scale to zero when the queue is idle (cost saving) +# * targetQueueSize 5 -> KEDA adds replicas to keep backlog/replica ~5 +# * activationTargetQueueSize 0 -> any backlog (>0) wakes a worker from zero +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: + name: aks-agent-scaledobject + namespace: agent-platform +spec: + scaleTargetRef: + name: aks-agent-worker + minReplicaCount: 0 + maxReplicaCount: 10 + pollingInterval: 10 + cooldownPeriod: 60 + triggers: + - type: temporal + metadata: + # Temporal frontend gRPC endpoint (host:port). Match temporal-config. + endpoint: temporal-frontend.temporal.svc.cluster.local:7233 + namespace: agent-platform + taskQueue: aks-agent-tq + targetQueueSize: "5" + activationTargetQueueSize: "0" + # TODO (Temporal Cloud): add API-key / mTLS auth via a TriggerAuthentication: + # authenticationRef: + # name: temporal-cloud-auth diff --git a/temporal-maf-agents-poc/k8s/keda/approval-agent-scaledobject.yaml b/temporal-maf-agents-poc/k8s/keda/approval-agent-scaledobject.yaml new file mode 100644 index 0000000..cc08b52 --- /dev/null +++ b/temporal-maf-agents-poc/k8s/keda/approval-agent-scaledobject.yaml @@ -0,0 +1,34 @@ +# KEDA ScaledObject for the Approval agent worker. +# Scales the Deployment from the `approval-agent-tq` Temporal task-queue backlog. +# +# Requires KEDA >= 2.17 (the Temporal scaler shipped in 2.17). Docs: +# https://keda.sh/docs/2.19/scalers/temporal/ +# +# Behaviour: +# * minReplicaCount 0 -> scale to zero when the queue is idle (cost saving) +# * targetQueueSize 5 -> KEDA adds replicas to keep backlog/replica ~5 +# * activationTargetQueueSize 0 -> any backlog (>0) wakes a worker from zero +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: + name: approval-agent-scaledobject + namespace: agent-platform +spec: + scaleTargetRef: + name: approval-agent-worker + minReplicaCount: 0 + maxReplicaCount: 10 + pollingInterval: 10 + cooldownPeriod: 60 + triggers: + - type: temporal + metadata: + # Temporal frontend gRPC endpoint (host:port). Match temporal-config. + endpoint: temporal-frontend.temporal.svc.cluster.local:7233 + namespace: agent-platform + taskQueue: approval-agent-tq + targetQueueSize: "5" + activationTargetQueueSize: "0" + # TODO (Temporal Cloud): add API-key / mTLS auth via a TriggerAuthentication: + # authenticationRef: + # name: temporal-cloud-auth diff --git a/temporal-maf-agents-poc/k8s/keda/github-agent-scaledobject.yaml b/temporal-maf-agents-poc/k8s/keda/github-agent-scaledobject.yaml new file mode 100644 index 0000000..66852f8 --- /dev/null +++ b/temporal-maf-agents-poc/k8s/keda/github-agent-scaledobject.yaml @@ -0,0 +1,34 @@ +# KEDA ScaledObject for the GitHub agent worker. +# Scales the Deployment from the `github-agent-tq` Temporal task-queue backlog. +# +# Requires KEDA >= 2.17 (the Temporal scaler shipped in 2.17). Docs: +# https://keda.sh/docs/2.19/scalers/temporal/ +# +# Behaviour: +# * minReplicaCount 0 -> scale to zero when the queue is idle (cost saving) +# * targetQueueSize 5 -> KEDA adds replicas to keep backlog/replica ~5 +# * activationTargetQueueSize 0 -> any backlog (>0) wakes a worker from zero +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: + name: github-agent-scaledobject + namespace: agent-platform +spec: + scaleTargetRef: + name: github-agent-worker + minReplicaCount: 0 + maxReplicaCount: 10 + pollingInterval: 10 + cooldownPeriod: 60 + triggers: + - type: temporal + metadata: + # Temporal frontend gRPC endpoint (host:port). Match temporal-config. + endpoint: temporal-frontend.temporal.svc.cluster.local:7233 + namespace: agent-platform + taskQueue: github-agent-tq + targetQueueSize: "5" + activationTargetQueueSize: "0" + # TODO (Temporal Cloud): add API-key / mTLS auth via a TriggerAuthentication: + # authenticationRef: + # name: temporal-cloud-auth diff --git a/temporal-maf-agents-poc/k8s/keda/planner-agent-scaledobject.yaml b/temporal-maf-agents-poc/k8s/keda/planner-agent-scaledobject.yaml new file mode 100644 index 0000000..00b0e09 --- /dev/null +++ b/temporal-maf-agents-poc/k8s/keda/planner-agent-scaledobject.yaml @@ -0,0 +1,34 @@ +# KEDA ScaledObject for the planner agent worker. +# Scales the Deployment from the `planner-agent-tq` Temporal task-queue backlog. +# +# Requires KEDA >= 2.17 (the Temporal scaler shipped in 2.17). Docs: +# https://keda.sh/docs/2.19/scalers/temporal/ +# +# Behaviour: +# * minReplicaCount 0 -> scale to zero when the queue is idle (cost saving) +# * targetQueueSize 5 -> KEDA adds replicas to keep backlog/replica ~5 +# * activationTargetQueueSize 0 -> any backlog (>0) wakes a worker from zero +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: + name: planner-agent-scaledobject + namespace: agent-platform +spec: + scaleTargetRef: + name: planner-agent-worker + minReplicaCount: 0 + maxReplicaCount: 10 + pollingInterval: 10 + cooldownPeriod: 60 + triggers: + - type: temporal + metadata: + # Temporal frontend gRPC endpoint (host:port). Match temporal-config. + endpoint: temporal-frontend.temporal.svc.cluster.local:7233 + namespace: agent-platform + taskQueue: planner-agent-tq + targetQueueSize: "5" + activationTargetQueueSize: "0" + # TODO (Temporal Cloud): add API-key / mTLS auth via a TriggerAuthentication: + # authenticationRef: + # name: temporal-cloud-auth diff --git a/temporal-maf-agents-poc/k8s/namespace.yaml b/temporal-maf-agents-poc/k8s/namespace.yaml new file mode 100644 index 0000000..0ccbb76 --- /dev/null +++ b/temporal-maf-agents-poc/k8s/namespace.yaml @@ -0,0 +1,24 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: agent-platform + labels: + app.kubernetes.io/part-of: temporal-maf-agents-poc +--- +# Shared Temporal connection settings for every worker deployment. +# Point `TEMPORAL_ADDRESS` at your Temporal frontend Service. For the Temporal +# Helm chart the default is "-frontend.:7233"; for Temporal +# Cloud use "..tmprl.cloud:7233" and add mTLS/API-key +# auth to the workers + KEDA ScaledObjects (see TODOs in keda/*.yaml). +apiVersion: v1 +kind: ConfigMap +metadata: + name: temporal-config + namespace: agent-platform +data: + TEMPORAL_ADDRESS: "temporal-frontend.temporal.svc.cluster.local:7233" + TEMPORAL_NAMESPACE: "agent-platform" + AGENT_MODE: "mock" + TEMPORAL_APPROVAL_AUTO: "true" + TEMPORAL_APPROVAL_TIMEOUT_SECONDS: "30" + HEALTH_PORT: "8080" diff --git a/temporal-maf-agents-poc/sample-input.json b/temporal-maf-agents-poc/sample-input.json new file mode 100644 index 0000000..422f127 --- /dev/null +++ b/temporal-maf-agents-poc/sample-input.json @@ -0,0 +1,7 @@ +{ + "request_id": "req-001", + "goal": "Add a /healthz endpoint to the payments service and deploy it", + "repo_url": "https://github.com/example-org/payments-service", + "environment": "dev", + "approval_required": true +} diff --git a/temporal-maf-agents-poc/sample-output.json b/temporal-maf-agents-poc/sample-output.json new file mode 100644 index 0000000..0122270 --- /dev/null +++ b/temporal-maf-agents-poc/sample-output.json @@ -0,0 +1,70 @@ +{ + "request_id": "req-001", + "status": "success", + "summary": "orchestration complete for goal: Add a /healthz endpoint to the payments service and deploy it", + "stages": [ + { + "agent_name": "planner", + "stage": "planning", + "status": "success", + "retryable": false, + "summary": "deployment plan created", + "next_action": "continue", + "details": { + "goal": "Add a /healthz endpoint to the payments service and deploy it", + "repo_url": "https://github.com/example-org/payments-service", + "environment": "dev", + "steps": [ + "Create feature branch for: Add a /healthz endpoint to the payments service and deploy it", + "Open a pull request against the target repository", + "Apply Kubernetes manifests to the 'dev' environment", + "Request human approval before promotion" + ] + } + }, + { + "agent_name": "github", + "stage": "github", + "status": "success", + "retryable": false, + "summary": "opened pull request #1 on branch feat/req-001", + "next_action": "continue", + "details": { + "branch": "feat/req-001", + "pr_number": 1, + "pr_url": "https://github.com/example-org/payments-service/pull/1", + "based_on_plan": true + } + }, + { + "agent_name": "aks", + "stage": "aks", + "status": "needs_approval", + "retryable": false, + "summary": "manifests staged; awaiting approval before promotion", + "next_action": "ask_human", + "details": { + "environment": "dev", + "applied": ["namespace/dev", "deployment/agent-app", "service/agent-app"], + "from_pr": 1, + "promotion_pending": true + } + }, + { + "agent_name": "approval", + "stage": "approval", + "status": "success", + "retryable": false, + "summary": "approved — promotion authorised", + "next_action": "continue", + "details": { + "auto_approve": true, + "timeout_seconds": 20, + "environment": "dev", + "resolved": true, + "approved": true, + "reason": "auto-approved after timeout (non-prod POC policy)" + } + } + ] +} diff --git a/temporal-maf-agents-poc/src/aks_agent_worker/__init__.py b/temporal-maf-agents-poc/src/aks_agent_worker/__init__.py new file mode 100644 index 0000000..ea941d7 --- /dev/null +++ b/temporal-maf-agents-poc/src/aks_agent_worker/__init__.py @@ -0,0 +1 @@ +"""AKS agent worker — applies Kubernetes manifests (``aks-agent-tq``).""" diff --git a/temporal-maf-agents-poc/src/aks_agent_worker/activities.py b/temporal-maf-agents-poc/src/aks_agent_worker/activities.py new file mode 100644 index 0000000..4cef169 --- /dev/null +++ b/temporal-maf-agents-poc/src/aks_agent_worker/activities.py @@ -0,0 +1,37 @@ +"""Temporal activity for the AKS stage (real Kubernetes API lives here in Phase 2).""" + +from __future__ import annotations + +import time + +from temporalio import activity + +from shared.contracts import AgentOutput, AgentRequest +from shared.logging import get_logger +from shared.maf import run_agent +from aks_agent_worker import agent + +log = get_logger("aks.activity") + + +@activity.defn(name="RunAKSAgentActivity") +async def run_aks_agent(request: AgentRequest) -> AgentOutput: + started = time.monotonic() + output = await run_agent( + agent_name=agent.AGENT_NAME, + stage=request.stage, + instructions=agent.INSTRUCTIONS, + request=request, + mock=agent.mock, + ) + log.info( + "aks agent finished", + extra={ + "request_id": request.request_id, + "agent_name": agent.AGENT_NAME, + "stage": request.stage, + "status": output.status, + "duration_ms": int((time.monotonic() - started) * 1000), + }, + ) + return output diff --git a/temporal-maf-agents-poc/src/aks_agent_worker/agent.py b/temporal-maf-agents-poc/src/aks_agent_worker/agent.py new file mode 100644 index 0000000..a41ec39 --- /dev/null +++ b/temporal-maf-agents-poc/src/aks_agent_worker/agent.py @@ -0,0 +1,63 @@ +"""AKSAgent — applies the Kubernetes/AKS part of the plan. + +Phase 2 wires the real Kubernetes API (or an AKS MCP server) as Agent +Framework tools inside the activity. ``approval_required`` flows through to a +``needs_approval`` signal so the Approval stage can gate promotion. +""" + +from __future__ import annotations + +from shared.contracts import ( + ACTION_ASK_HUMAN, + ACTION_CONTINUE, + STAGE_AKS, + STAGE_GITHUB, + STATUS_NEEDS_APPROVAL, + STATUS_SUCCESS, + AgentOutput, + AgentRequest, +) + +AGENT_NAME = "aks" + +INSTRUCTIONS = """\ +You are AKSAgent. Apply the Kubernetes manifests for this change to the target +AKS cluster/namespace for the requested environment. Use the provided +Kubernetes tools. If the change targets a protected environment and approval is +required, do NOT promote — return needs_approval and let the Approval agent +gate it. Report the resources applied in your structured output. +""" + + +def mock(request: AgentRequest) -> AgentOutput: + """Deterministic Phase-1 AKS output. + + Stages the rollout and, when approval is required, hands off to the + approval gate via ``needs_approval`` / ``ask_human``. + """ + github = request.upstream.get(STAGE_GITHUB) + applied = [ + f"namespace/{request.environment}", + "deployment/agent-app", + "service/agent-app", + ] + needs_approval = request.approval_required + + return AgentOutput( + agent_name=AGENT_NAME, + stage=STAGE_AKS, + status=STATUS_NEEDS_APPROVAL if needs_approval else STATUS_SUCCESS, + retryable=False, + summary=( + "manifests staged; awaiting approval before promotion" + if needs_approval + else "manifests applied" + ), + next_action=ACTION_ASK_HUMAN if needs_approval else ACTION_CONTINUE, + details={ + "environment": request.environment, + "applied": applied, + "from_pr": github.details.get("pr_number") if github else None, + "promotion_pending": needs_approval, + }, + ) diff --git a/temporal-maf-agents-poc/src/aks_agent_worker/worker.py b/temporal-maf-agents-poc/src/aks_agent_worker/worker.py new file mode 100644 index 0000000..851b704 --- /dev/null +++ b/temporal-maf-agents-poc/src/aks_agent_worker/worker.py @@ -0,0 +1,15 @@ +"""Entrypoint: ``python -m aks_agent_worker.worker`` (``aks-agent-tq``).""" + +from __future__ import annotations + +from shared import config +from shared.runtime import main +from aks_agent_worker.activities import run_aks_agent +from aks_agent_worker.workflows import AKSAgentWorkflow + +if __name__ == "__main__": + main( + task_queue=config.AKS_TASK_QUEUE, + workflows=[AKSAgentWorkflow], + activities=[run_aks_agent], + ) diff --git a/temporal-maf-agents-poc/src/aks_agent_worker/workflows.py b/temporal-maf-agents-poc/src/aks_agent_worker/workflows.py new file mode 100644 index 0000000..cebc10a --- /dev/null +++ b/temporal-maf-agents-poc/src/aks_agent_worker/workflows.py @@ -0,0 +1,19 @@ +"""AKSAgentWorkflow — child workflow on ``aks-agent-tq``.""" + +from __future__ import annotations + +from temporalio import workflow + +from shared import config +from shared.child import run_agent_stage +from shared.contracts import AgentOutput + + +@workflow.defn(name=config.AKS_WORKFLOW) +class AKSAgentWorkflow: + @workflow.run + async def run(self, payload: dict) -> AgentOutput: + return await run_agent_stage( + activity_name="RunAKSAgentActivity", + payload=payload, + ) diff --git a/temporal-maf-agents-poc/src/approval_agent_worker/__init__.py b/temporal-maf-agents-poc/src/approval_agent_worker/__init__.py new file mode 100644 index 0000000..08d1135 --- /dev/null +++ b/temporal-maf-agents-poc/src/approval_agent_worker/__init__.py @@ -0,0 +1 @@ +"""Approval agent worker — human-in-the-loop gate (``approval-agent-tq``).""" diff --git a/temporal-maf-agents-poc/src/approval_agent_worker/activities.py b/temporal-maf-agents-poc/src/approval_agent_worker/activities.py new file mode 100644 index 0000000..0e26ddc --- /dev/null +++ b/temporal-maf-agents-poc/src/approval_agent_worker/activities.py @@ -0,0 +1,37 @@ +"""Temporal activity for the approval classification stage.""" + +from __future__ import annotations + +import time + +from temporalio import activity + +from shared.contracts import AgentOutput, AgentRequest +from shared.logging import get_logger +from shared.maf import run_agent +from approval_agent_worker import agent + +log = get_logger("approval.activity") + + +@activity.defn(name="RunApprovalAgentActivity") +async def run_approval_agent(request: AgentRequest) -> AgentOutput: + started = time.monotonic() + output = await run_agent( + agent_name=agent.AGENT_NAME, + stage=request.stage, + instructions=agent.INSTRUCTIONS, + request=request, + mock=agent.mock, + ) + log.info( + "approval agent classified", + extra={ + "request_id": request.request_id, + "agent_name": agent.AGENT_NAME, + "stage": request.stage, + "status": output.status, + "duration_ms": int((time.monotonic() - started) * 1000), + }, + ) + return output diff --git a/temporal-maf-agents-poc/src/approval_agent_worker/agent.py b/temporal-maf-agents-poc/src/approval_agent_worker/agent.py new file mode 100644 index 0000000..e83e066 --- /dev/null +++ b/temporal-maf-agents-poc/src/approval_agent_worker/agent.py @@ -0,0 +1,71 @@ +"""ApprovalAgent — decides whether the rollout needs a human sign-off. + +The agent itself does not block; it only classifies. The *workflow* +(``ApprovalAgentWorkflow``) owns the durable wait for a human decision signal. +The activity injects the (env-derived) approval behaviour into ``details`` so +the deterministic workflow can read it without touching the environment. +""" + +from __future__ import annotations + +from shared.config import get_settings +from shared.contracts import ( + ACTION_ASK_HUMAN, + ACTION_CONTINUE, + STAGE_AKS, + STAGE_APPROVAL, + STATUS_NEEDS_APPROVAL, + STATUS_SUCCESS, + AgentOutput, + AgentRequest, +) + +AGENT_NAME = "approval" + +INSTRUCTIONS = """\ +You are ApprovalAgent. Review the staged deployment (plan, PR, and AKS rollout) +and decide whether it can be promoted automatically or requires explicit human +approval. If approval is required, emit needs_approval/ask_human and stop — a +human (or an auto-approve policy in non-prod) resolves the gate. +""" + + +def mock(request: AgentRequest) -> AgentOutput: + """Deterministic Phase-1 approval classification. + + Requires approval when the run asked for it or when the AKS stage staged a + rollout pending promotion. + """ + settings = get_settings() + aks = request.upstream.get(STAGE_AKS) + aks_pending = bool(aks and aks.details.get("promotion_pending")) + needs_approval = request.approval_required or aks_pending + + # Behaviour the workflow uses to drive its durable wait. Carried in details + # so the deterministic workflow never reads os.environ itself. + behaviour = { + "auto_approve": settings.approval_auto, + "timeout_seconds": settings.approval_timeout_seconds, + "environment": request.environment, + } + + if not needs_approval: + return AgentOutput( + agent_name=AGENT_NAME, + stage=STAGE_APPROVAL, + status=STATUS_SUCCESS, + retryable=False, + summary="no approval required; auto-promoted", + next_action=ACTION_CONTINUE, + details=behaviour, + ) + + return AgentOutput( + agent_name=AGENT_NAME, + stage=STAGE_APPROVAL, + status=STATUS_NEEDS_APPROVAL, + retryable=False, + summary=f"human approval required to promote to {request.environment}", + next_action=ACTION_ASK_HUMAN, + details=behaviour, + ) diff --git a/temporal-maf-agents-poc/src/approval_agent_worker/worker.py b/temporal-maf-agents-poc/src/approval_agent_worker/worker.py new file mode 100644 index 0000000..9c1a5dd --- /dev/null +++ b/temporal-maf-agents-poc/src/approval_agent_worker/worker.py @@ -0,0 +1,15 @@ +"""Entrypoint: ``python -m approval_agent_worker.worker`` (``approval-agent-tq``).""" + +from __future__ import annotations + +from shared import config +from shared.runtime import main +from approval_agent_worker.activities import run_approval_agent +from approval_agent_worker.workflows import ApprovalAgentWorkflow + +if __name__ == "__main__": + main( + task_queue=config.APPROVAL_TASK_QUEUE, + workflows=[ApprovalAgentWorkflow], + activities=[run_approval_agent], + ) diff --git a/temporal-maf-agents-poc/src/approval_agent_worker/workflows.py b/temporal-maf-agents-poc/src/approval_agent_worker/workflows.py new file mode 100644 index 0000000..4f555be --- /dev/null +++ b/temporal-maf-agents-poc/src/approval_agent_worker/workflows.py @@ -0,0 +1,128 @@ +"""ApprovalAgentWorkflow — durable human-in-the-loop gate (``approval-agent-tq``). + +Unlike the other agent workflows, this one does more than schedule an activity: +after the agent classifies the rollout, the workflow **durably waits** for a +human decision signal (or auto-resolves after a timeout in non-prod). This is +the canonical Temporal pattern for ``needs_approval`` — the wait survives worker +restarts because it is part of workflow state, not an in-memory timer. + +Resolve the gate from the Temporal CLI:: + + temporal workflow signal \\ + --workflow-id -approval \\ + --name submit_decision \\ + --input '{"approved": true, "reason": "LGTM"}' + +Still deterministic: the wait, the timeout, and the decision logic are all +Temporal-managed. The only environment-derived inputs (auto-approve, timeout) +arrive via the activity's structured ``details``. +""" + +from __future__ import annotations + +import asyncio +from datetime import timedelta + +from temporalio import workflow + +from shared import config +from shared.child import ACTIVITY_RETRY_POLICY, ACTIVITY_START_TO_CLOSE +from shared.contracts import ( + ACTION_CONTINUE, + ACTION_FAIL, + ACTION_ASK_HUMAN, + STATUS_FAILED, + STATUS_NEEDS_APPROVAL, + STATUS_SUCCESS, + AgentOutput, + AgentRequest, +) + + +@workflow.defn(name=config.APPROVAL_WORKFLOW) +class ApprovalAgentWorkflow: + def __init__(self) -> None: + self._decision: tuple[bool, str] | None = None + + @workflow.signal + def submit_decision(self, approved: bool, reason: str = "") -> None: + """Human (or external system) approves/rejects the rollout.""" + self._decision = (bool(approved), str(reason)) + + @workflow.query + def is_pending(self) -> bool: + """True while the workflow is still waiting on a human decision.""" + return self._decision is None + + @workflow.run + async def run(self, payload: dict) -> AgentOutput: + request = AgentRequest.from_payload(payload) + + classification: AgentOutput = await workflow.execute_activity( + "RunApprovalAgentActivity", + request, + start_to_close_timeout=ACTIVITY_START_TO_CLOSE, + retry_policy=ACTIVITY_RETRY_POLICY, + result_type=AgentOutput, + ) + + # Nothing to gate — auto-promoted. + if classification.status != STATUS_NEEDS_APPROVAL: + return classification + + auto_approve = bool(classification.details.get("auto_approve", True)) + timeout_seconds = int(classification.details.get("timeout_seconds", 30)) + + workflow.logger.info( + "awaiting human approval", + extra={"request_id": request.request_id, "stage": request.stage, + "status": STATUS_NEEDS_APPROVAL}, + ) + + # Durable wait for the signal, bounded by a timer. + try: + await workflow.wait_condition( + lambda: self._decision is not None, + timeout=timedelta(seconds=timeout_seconds), + ) + except asyncio.TimeoutError: + pass + + if self._decision is None: + if auto_approve: + self._decision = (True, "auto-approved after timeout (non-prod POC policy)") + else: + # Still pending: surface needs_approval back to the orchestrator. + return AgentOutput( + agent_name=classification.agent_name, + stage=request.stage, + status=STATUS_NEEDS_APPROVAL, + retryable=False, + summary="approval timed out; rollout still pending human decision", + next_action=ACTION_ASK_HUMAN, + details={**classification.details, "resolved": False}, + ) + + approved, reason = self._decision + if approved: + return AgentOutput( + agent_name=classification.agent_name, + stage=request.stage, + status=STATUS_SUCCESS, + retryable=False, + summary="approved — promotion authorised", + next_action=ACTION_CONTINUE, + details={**classification.details, "resolved": True, "approved": True, + "reason": reason}, + ) + + return AgentOutput( + agent_name=classification.agent_name, + stage=request.stage, + status=STATUS_FAILED, + retryable=False, + summary="rejected — promotion denied by human", + next_action=ACTION_FAIL, + details={**classification.details, "resolved": True, "approved": False, + "reason": reason}, + ) diff --git a/temporal-maf-agents-poc/src/github_agent_worker/__init__.py b/temporal-maf-agents-poc/src/github_agent_worker/__init__.py new file mode 100644 index 0000000..68736b6 --- /dev/null +++ b/temporal-maf-agents-poc/src/github_agent_worker/__init__.py @@ -0,0 +1 @@ +"""GitHub agent worker — branch + PR against the target repo (``github-agent-tq``).""" diff --git a/temporal-maf-agents-poc/src/github_agent_worker/activities.py b/temporal-maf-agents-poc/src/github_agent_worker/activities.py new file mode 100644 index 0000000..af8121d --- /dev/null +++ b/temporal-maf-agents-poc/src/github_agent_worker/activities.py @@ -0,0 +1,37 @@ +"""Temporal activity for the GitHub stage (real GitHub API lives here in Phase 2).""" + +from __future__ import annotations + +import time + +from temporalio import activity + +from shared.contracts import AgentOutput, AgentRequest +from shared.logging import get_logger +from shared.maf import run_agent +from github_agent_worker import agent + +log = get_logger("github.activity") + + +@activity.defn(name="RunGitHubAgentActivity") +async def run_github_agent(request: AgentRequest) -> AgentOutput: + started = time.monotonic() + output = await run_agent( + agent_name=agent.AGENT_NAME, + stage=request.stage, + instructions=agent.INSTRUCTIONS, + request=request, + mock=agent.mock, + ) + log.info( + "github agent finished", + extra={ + "request_id": request.request_id, + "agent_name": agent.AGENT_NAME, + "stage": request.stage, + "status": output.status, + "duration_ms": int((time.monotonic() - started) * 1000), + }, + ) + return output diff --git a/temporal-maf-agents-poc/src/github_agent_worker/agent.py b/temporal-maf-agents-poc/src/github_agent_worker/agent.py new file mode 100644 index 0000000..a19db99 --- /dev/null +++ b/temporal-maf-agents-poc/src/github_agent_worker/agent.py @@ -0,0 +1,46 @@ +"""GitHubAgent — executes the source-control part of the plan. + +Phase 2 wires the real GitHub API (or a GitHub MCP server) as Agent Framework +tools *inside the activity*. Phase 1 returns a deterministic mock. +""" + +from __future__ import annotations + +from shared.contracts import ( + ACTION_CONTINUE, + STAGE_GITHUB, + STAGE_PLANNING, + STATUS_SUCCESS, + AgentOutput, + AgentRequest, +) + +AGENT_NAME = "github" + +INSTRUCTIONS = """\ +You are GitHubAgent. Execute the source-control steps of the deployment plan: +create a feature branch, commit the required changes, and open a pull request +against the target repository. Use the provided GitHub tools. Report the branch +name and PR URL in your structured output. +""" + + +def mock(request: AgentRequest) -> AgentOutput: + """Deterministic Phase-1 GitHub output, derived from the planner's plan.""" + planner = request.upstream.get(STAGE_PLANNING) + branch = f"feat/{request.request_id}" + pr_number = 1 + return AgentOutput( + agent_name=AGENT_NAME, + stage=STAGE_GITHUB, + status=STATUS_SUCCESS, + retryable=False, + summary=f"opened pull request #{pr_number} on branch {branch}", + next_action=ACTION_CONTINUE, + details={ + "branch": branch, + "pr_number": pr_number, + "pr_url": f"{request.repo_url.rstrip('/')}/pull/{pr_number}", + "based_on_plan": bool(planner), + }, + ) diff --git a/temporal-maf-agents-poc/src/github_agent_worker/worker.py b/temporal-maf-agents-poc/src/github_agent_worker/worker.py new file mode 100644 index 0000000..89d4778 --- /dev/null +++ b/temporal-maf-agents-poc/src/github_agent_worker/worker.py @@ -0,0 +1,15 @@ +"""Entrypoint: ``python -m github_agent_worker.worker`` (``github-agent-tq``).""" + +from __future__ import annotations + +from shared import config +from shared.runtime import main +from github_agent_worker.activities import run_github_agent +from github_agent_worker.workflows import GitHubAgentWorkflow + +if __name__ == "__main__": + main( + task_queue=config.GITHUB_TASK_QUEUE, + workflows=[GitHubAgentWorkflow], + activities=[run_github_agent], + ) diff --git a/temporal-maf-agents-poc/src/github_agent_worker/workflows.py b/temporal-maf-agents-poc/src/github_agent_worker/workflows.py new file mode 100644 index 0000000..d7a706d --- /dev/null +++ b/temporal-maf-agents-poc/src/github_agent_worker/workflows.py @@ -0,0 +1,19 @@ +"""GitHubAgentWorkflow — child workflow on ``github-agent-tq``.""" + +from __future__ import annotations + +from temporalio import workflow + +from shared import config +from shared.child import run_agent_stage +from shared.contracts import AgentOutput + + +@workflow.defn(name=config.GITHUB_WORKFLOW) +class GitHubAgentWorkflow: + @workflow.run + async def run(self, payload: dict) -> AgentOutput: + return await run_agent_stage( + activity_name="RunGitHubAgentActivity", + payload=payload, + ) diff --git a/temporal-maf-agents-poc/src/orchestrator_worker/__init__.py b/temporal-maf-agents-poc/src/orchestrator_worker/__init__.py new file mode 100644 index 0000000..dec12c4 --- /dev/null +++ b/temporal-maf-agents-poc/src/orchestrator_worker/__init__.py @@ -0,0 +1 @@ +"""Orchestrator worker: hosts the parent workflow on ``orchestrator-tq``.""" diff --git a/temporal-maf-agents-poc/src/orchestrator_worker/worker.py b/temporal-maf-agents-poc/src/orchestrator_worker/worker.py new file mode 100644 index 0000000..c8efc20 --- /dev/null +++ b/temporal-maf-agents-poc/src/orchestrator_worker/worker.py @@ -0,0 +1,20 @@ +"""Entrypoint for the orchestrator worker. + + python -m orchestrator_worker.worker + +Hosts only the parent workflow on ``orchestrator-tq``. It has no activities of +its own — all real work happens in the agent workers' activities. +""" + +from __future__ import annotations + +from shared import config +from shared.runtime import main +from orchestrator_worker.workflows import AgentOrchestratorWorkflow + +if __name__ == "__main__": + main( + task_queue=config.ORCHESTRATOR_TASK_QUEUE, + workflows=[AgentOrchestratorWorkflow], + activities=[], + ) diff --git a/temporal-maf-agents-poc/src/orchestrator_worker/workflows.py b/temporal-maf-agents-poc/src/orchestrator_worker/workflows.py new file mode 100644 index 0000000..026d98a --- /dev/null +++ b/temporal-maf-agents-poc/src/orchestrator_worker/workflows.py @@ -0,0 +1,144 @@ +"""The parent orchestration workflow. + +``AgentOrchestratorWorkflow`` is the single orchestration authority. It runs +the four agent **child workflows** in sequence, each on its own task queue, +passing the structured output of every stage forward to the next. + +Determinism rules (enforced by the Temporal sandbox): + * No LLM / Azure / Kubernetes / GitHub / Agent Framework calls here. + * No wall-clock, no randomness, no network, no file I/O. + * All external work happens inside activities, reached via the child + workflows. This module only orchestrates. + +Only deterministic, sandbox-safe imports are allowed below. +""" + +from __future__ import annotations + +from temporalio import workflow +from temporalio.common import RetryPolicy +from temporalio.exceptions import ApplicationError + +# These imports are pure dataclasses / constants — safe inside the sandbox. +from shared import config +from shared.contracts import ( + STAGE_AKS, + STAGE_APPROVAL, + STAGE_GITHUB, + STAGE_PLANNING, + STATUS_FAILED, + STATUS_SUCCESS, + AgentOutput, + OrchestrationRequest, + OrchestrationResult, +) + +# Child workflows are started by *name* (strings from config) so the +# orchestrator never has to import the agent workflow modules. +_CHILD_RETRY = RetryPolicy(maximum_attempts=3) + + +@workflow.defn(name=config.ORCHESTRATOR_WORKFLOW) +class AgentOrchestratorWorkflow: + """User request -> Planner -> GitHub -> AKS -> Approval -> final result.""" + + def __init__(self) -> None: + self._stage: str = "init" + + @workflow.query + def current_stage(self) -> str: + """Live progress query — handy from the Temporal UI / CLI.""" + return self._stage + + @workflow.run + async def run(self, request: OrchestrationRequest) -> OrchestrationResult: + workflow.logger.info( + "orchestration started", + extra={"request_id": request.request_id, "stage": "orchestrator"}, + ) + + stages: list[AgentOutput] = [] + upstream: dict[str, AgentOutput] = {} + + # The fixed pipeline: (stage name, child workflow name, task queue). + pipeline = [ + (STAGE_PLANNING, config.PLANNER_WORKFLOW, config.PLANNER_TASK_QUEUE), + (STAGE_GITHUB, config.GITHUB_WORKFLOW, config.GITHUB_TASK_QUEUE), + (STAGE_AKS, config.AKS_WORKFLOW, config.AKS_TASK_QUEUE), + (STAGE_APPROVAL, config.APPROVAL_WORKFLOW, config.APPROVAL_TASK_QUEUE), + ] + + for stage, wf_name, task_queue in pipeline: + self._stage = stage + # Build the per-agent payload. We pass a dict for `upstream` so the + # child can rebuild typed AgentOutput objects on its side. + child_input = { + "request_id": request.request_id, + "goal": request.goal, + "repo_url": request.repo_url, + "environment": request.environment, + "approval_required": request.approval_required, + "stage": stage, + "upstream": {k: _as_dict(v) for k, v in upstream.items()}, + } + + workflow.logger.info( + f"executing child workflow for stage={stage}", + extra={"request_id": request.request_id, "stage": stage}, + ) + + output: AgentOutput = await workflow.execute_child_workflow( + wf_name, + child_input, + id=f"{request.request_id}-{stage}", + task_queue=task_queue, + retry_policy=_CHILD_RETRY, + result_type=AgentOutput, + ) + + stages.append(output) + upstream[stage] = output + + # A child workflow only returns a terminal AgentOutput. If a stage + # could not be salvaged it raises (failing this workflow) rather + # than returning. A clean needs_approval reaching the *final* + # result is surfaced, not treated as failure. + if output.status == STATUS_FAILED: + self._stage = "failed" + raise ApplicationError( + f"stage {stage} failed and was not retryable: {output.summary}", + type="StageFailed", + non_retryable=True, + ) + + self._stage = "done" + + # Final status is governed by the *approval* (final) stage: an + # intermediate needs_approval from the AKS stage is resolved by the + # approval workflow, so only its terminal verdict matters here. + final_status = stages[-1].status if stages else STATUS_SUCCESS + + result = OrchestrationResult( + request_id=request.request_id, + status=final_status, + stages=stages, + summary=f"orchestration complete for goal: {request.goal}", + ) + workflow.logger.info( + "orchestration finished", + extra={"request_id": request.request_id, "status": final_status}, + ) + return result + + +def _as_dict(output: AgentOutput) -> dict: + """Deterministic dataclass->dict (no stdlib `asdict`, to stay obvious).""" + return { + "agent_name": output.agent_name, + "stage": output.stage, + "status": output.status, + "retryable": output.retryable, + "summary": output.summary, + "next_action": output.next_action, + "details": output.details, + } diff --git a/temporal-maf-agents-poc/src/planner_agent_worker/__init__.py b/temporal-maf-agents-poc/src/planner_agent_worker/__init__.py new file mode 100644 index 0000000..d8b5a2a --- /dev/null +++ b/temporal-maf-agents-poc/src/planner_agent_worker/__init__.py @@ -0,0 +1 @@ +"""Planner agent worker — turns a goal into a deployment plan (``planner-agent-tq``).""" diff --git a/temporal-maf-agents-poc/src/planner_agent_worker/activities.py b/temporal-maf-agents-poc/src/planner_agent_worker/activities.py new file mode 100644 index 0000000..d0b7918 --- /dev/null +++ b/temporal-maf-agents-poc/src/planner_agent_worker/activities.py @@ -0,0 +1,57 @@ +"""Temporal activity for the planning stage. + +The activity is the boundary where the deterministic Temporal world meets the +non-deterministic agent world. Everything external (LLM calls, tool calls) +happens here, never in workflow code. +""" + +from __future__ import annotations + +import os +import time + +from temporalio import activity + +from shared.contracts import AgentOutput, AgentRequest +from shared.logging import get_logger +from shared.maf import run_agent +from planner_agent_worker import agent + +log = get_logger("planner.activity") + + +@activity.defn(name="RunPlannerAgentActivity") +async def run_planner_agent(request: AgentRequest) -> AgentOutput: + started = time.monotonic() + info = activity.info() + + # --- Demo hook: prove Temporal's activity-level retries (layer 1) -------- + # Set FORCE_TRANSIENT_ERROR=1 to make the activity raise on its first + # attempt; Temporal's RetryPolicy then re-runs it (attempt 2 succeeds). + if os.getenv("FORCE_TRANSIENT_ERROR") == "1" and info.attempt < 2: + log.warning( + "injected transient error to exercise retry policy", + extra={"request_id": request.request_id, "stage": request.stage, + "agent_name": agent.AGENT_NAME, "error_type": "TransientError"}, + ) + raise RuntimeError("injected transient error (will be retried by Temporal)") + + output = await run_agent( + agent_name=agent.AGENT_NAME, + stage=request.stage, + instructions=agent.INSTRUCTIONS, + request=request, + mock=agent.mock, + ) + + log.info( + "planner agent finished", + extra={ + "request_id": request.request_id, + "agent_name": agent.AGENT_NAME, + "stage": request.stage, + "status": output.status, + "duration_ms": int((time.monotonic() - started) * 1000), + }, + ) + return output diff --git a/temporal-maf-agents-poc/src/planner_agent_worker/agent.py b/temporal-maf-agents-poc/src/planner_agent_worker/agent.py new file mode 100644 index 0000000..0a8239b --- /dev/null +++ b/temporal-maf-agents-poc/src/planner_agent_worker/agent.py @@ -0,0 +1,53 @@ +"""PlannerAgent — the Microsoft Agent Framework agent for the planning stage. + +Phase 1 ships a deterministic mock so the Temporal topology can be proven with +no cloud credentials. The ``INSTRUCTIONS`` below are the real system prompt the +live (Phase 2) agent would use. + +Activity-only module — safe to import Agent Framework here (it never touches +workflow code). +""" + +from __future__ import annotations + +from shared.contracts import ( + ACTION_CONTINUE, + STAGE_PLANNING, + STATUS_SUCCESS, + AgentOutput, + AgentRequest, +) + +AGENT_NAME = "planner" + +INSTRUCTIONS = """\ +You are PlannerAgent. Given a high-level engineering goal and a target +repository, produce a concrete, ordered deployment plan: the code changes +required, the GitHub actions (branch, PR), the AKS resources to apply, and the +approval checkpoints. Output a structured plan the downstream GitHub, AKS, and +Approval agents can execute. Be explicit and deterministic. +""" + + +def mock(request: AgentRequest) -> AgentOutput: + """Deterministic Phase-1 planning output.""" + plan = [ + f"Create feature branch for: {request.goal}", + "Open a pull request against the target repository", + f"Apply Kubernetes manifests to the '{request.environment}' environment", + "Request human approval before promotion", + ] + return AgentOutput( + agent_name=AGENT_NAME, + stage=STAGE_PLANNING, + status=STATUS_SUCCESS, + retryable=False, + summary="deployment plan created", + next_action=ACTION_CONTINUE, + details={ + "goal": request.goal, + "repo_url": request.repo_url, + "environment": request.environment, + "steps": plan, + }, + ) diff --git a/temporal-maf-agents-poc/src/planner_agent_worker/worker.py b/temporal-maf-agents-poc/src/planner_agent_worker/worker.py new file mode 100644 index 0000000..738c75b --- /dev/null +++ b/temporal-maf-agents-poc/src/planner_agent_worker/worker.py @@ -0,0 +1,15 @@ +"""Entrypoint: ``python -m planner_agent_worker.worker`` (``planner-agent-tq``).""" + +from __future__ import annotations + +from shared import config +from shared.runtime import main +from planner_agent_worker.activities import run_planner_agent +from planner_agent_worker.workflows import PlannerAgentWorkflow + +if __name__ == "__main__": + main( + task_queue=config.PLANNER_TASK_QUEUE, + workflows=[PlannerAgentWorkflow], + activities=[run_planner_agent], + ) diff --git a/temporal-maf-agents-poc/src/planner_agent_worker/workflows.py b/temporal-maf-agents-poc/src/planner_agent_worker/workflows.py new file mode 100644 index 0000000..97fe8a4 --- /dev/null +++ b/temporal-maf-agents-poc/src/planner_agent_worker/workflows.py @@ -0,0 +1,23 @@ +"""PlannerAgentWorkflow — child workflow on ``planner-agent-tq``. + +Deterministic: it only schedules the planning activity (via the shared child +helper) and returns its structured output. No external calls here. +""" + +from __future__ import annotations + +from temporalio import workflow + +from shared import config +from shared.child import run_agent_stage +from shared.contracts import AgentOutput + + +@workflow.defn(name=config.PLANNER_WORKFLOW) +class PlannerAgentWorkflow: + @workflow.run + async def run(self, payload: dict) -> AgentOutput: + return await run_agent_stage( + activity_name="RunPlannerAgentActivity", + payload=payload, + ) diff --git a/temporal-maf-agents-poc/src/shared/__init__.py b/temporal-maf-agents-poc/src/shared/__init__.py new file mode 100644 index 0000000..946073d --- /dev/null +++ b/temporal-maf-agents-poc/src/shared/__init__.py @@ -0,0 +1,11 @@ +"""Shared, deterministic-safe building blocks for the Temporal + MAF POC. + +Everything in this package is importable from *both* Temporal workflow code +(which runs inside the deterministic sandbox) and from activity code. + +Hard rule: nothing in here may import ``agent_framework``, the Azure SDK, +the Kubernetes client, the GitHub client, or perform any I/O at import time. +Workflow modules import :mod:`shared.contracts` and :mod:`shared.config`; if +either ever pulled in a non-deterministic dependency, the Temporal worker +sandbox would reject the workflow. +""" diff --git a/temporal-maf-agents-poc/src/shared/child.py b/temporal-maf-agents-poc/src/shared/child.py new file mode 100644 index 0000000..351ba98 --- /dev/null +++ b/temporal-maf-agents-poc/src/shared/child.py @@ -0,0 +1,95 @@ +"""Reusable child-workflow body shared by every agent worker. + +Each ``*_agent_worker/workflows.py`` defines its own ``@workflow.defn`` class +(Temporal needs distinct workflow types per task queue) but delegates the +actual control flow to :func:`run_agent_stage` here so the retry / fail / +approval policy lives in exactly one place. + +This module is imported by workflow code, so it stays deterministic: it only +uses ``temporalio.workflow`` APIs plus the pure helpers in +:mod:`shared.contracts`. No SDKs, clocks, randomness, or I/O. +""" + +from __future__ import annotations + +from datetime import timedelta + +from temporalio import workflow +from temporalio.common import RetryPolicy +from temporalio.exceptions import ApplicationError + +from shared.contracts import ( + DECISION_APPROVE, + DECISION_OK, + DECISION_RETRY, + AgentOutput, + AgentRequest, + decide, +) + +# Layer 1: Temporal's own activity retries. Fires when an activity *raises* +# (transient infra errors: network blips, throttling, etc.). Mirrors the +# spec's "Retry Policy > Activities" section exactly. +ACTIVITY_RETRY_POLICY = RetryPolicy( + initial_interval=timedelta(seconds=10), + backoff_coefficient=2.0, + maximum_interval=timedelta(seconds=120), + maximum_attempts=3, +) + +# How long a single agent activity may run before Temporal times it out. +ACTIVITY_START_TO_CLOSE = timedelta(minutes=5) + + +async def run_agent_stage( + *, + activity_name: str, + payload: dict, + soft_retry_limit: int = 2, +) -> AgentOutput: + """Run an agent activity and apply the business-level decision policy. + + Two retry layers: + 1. ``ACTIVITY_RETRY_POLICY`` — Temporal retries the activity if it raises. + 2. Soft retries here — when the activity *returns* a structured + ``failed + retryable`` output (a business failure, not an exception), + we re-invoke up to ``soft_retry_limit`` times before failing the + workflow. ``needs_approval`` is returned as-is for the caller (the + approval workflow) to gate on. + """ + request = AgentRequest.from_payload(payload) + + attempt = 0 + while True: + output: AgentOutput = await workflow.execute_activity( + activity_name, + request, + start_to_close_timeout=ACTIVITY_START_TO_CLOSE, + retry_policy=ACTIVITY_RETRY_POLICY, + result_type=AgentOutput, + ) + + decision = decide(output) + workflow.logger.info( + f"activity {activity_name} -> status={output.status} decision={decision}", + extra={ + "request_id": request.request_id, + "stage": request.stage, + "status": output.status, + }, + ) + + if decision in (DECISION_OK, DECISION_APPROVE): + return output + + if decision == DECISION_RETRY and attempt < soft_retry_limit: + attempt += 1 + continue + + # DECISION_FAIL, or soft retries exhausted -> fail the workflow. + raise ApplicationError( + f"{activity_name} failed: {output.summary}", + output, # surfaced as ApplicationError detail for debugging + type="AgentFailed", + non_retryable=True, + ) diff --git a/temporal-maf-agents-poc/src/shared/contracts.py b/temporal-maf-agents-poc/src/shared/contracts.py new file mode 100644 index 0000000..6a566ec --- /dev/null +++ b/temporal-maf-agents-poc/src/shared/contracts.py @@ -0,0 +1,207 @@ +"""Typed contracts shared across the orchestrator and every agent worker. + +These dataclasses are the *wire format* between Temporal workflows and +activities. Temporal's default (Pydantic-free) data converter serialises +dataclasses to JSON out of the box, so keep every field JSON-native +(str / int / bool / float / list / dict / nested dataclass). + +This module is deterministic and dependency-free on purpose — it is imported +by workflow code that runs inside the Temporal sandbox. Do **not** add I/O, +clocks, randomness, or third-party SDK imports here. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +# --------------------------------------------------------------------------- +# Enumerated string values (kept as plain str constants so they serialise +# cleanly and stay comparable inside deterministic workflow code). +# --------------------------------------------------------------------------- + +# Agent status values +STATUS_SUCCESS = "success" +STATUS_FAILED = "failed" +STATUS_NEEDS_APPROVAL = "needs_approval" +VALID_STATUSES = frozenset({STATUS_SUCCESS, STATUS_FAILED, STATUS_NEEDS_APPROVAL}) + +# Agent next_action values +ACTION_CONTINUE = "continue" +ACTION_RETRY = "retry" +ACTION_FAIL = "fail" +ACTION_ROLLBACK = "rollback" +ACTION_ASK_HUMAN = "ask_human" +VALID_ACTIONS = frozenset( + {ACTION_CONTINUE, ACTION_RETRY, ACTION_FAIL, ACTION_ROLLBACK, ACTION_ASK_HUMAN} +) + +# Pipeline stages (one per child workflow) +STAGE_PLANNING = "planning" +STAGE_GITHUB = "github" +STAGE_AKS = "aks" +STAGE_APPROVAL = "approval" + + +# --------------------------------------------------------------------------- +# Inputs +# --------------------------------------------------------------------------- + + +@dataclass +class OrchestrationRequest: + """Top-level input to :class:`AgentOrchestratorWorkflow`. + + Mirrors ``sample-input.json``. + """ + + request_id: str + goal: str + repo_url: str + environment: str = "dev" + approval_required: bool = True + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "OrchestrationRequest": + return cls( + request_id=str(data["request_id"]), + goal=str(data["goal"]), + repo_url=str(data.get("repo_url", "")), + environment=str(data.get("environment", "dev")), + approval_required=bool(data.get("approval_required", True)), + ) + + +@dataclass +class AgentRequest: + """What a single agent activity receives. + + Carries the original orchestration context plus the structured outputs of + every upstream stage, so e.g. the GitHub agent can read the planner's plan. + """ + + request_id: str + goal: str + repo_url: str + environment: str + stage: str + # Whether a human approval gate is required for this run. + approval_required: bool = True + # Outputs of prior stages, keyed by stage name. Lets each agent build on + # the work of the agents before it without re-deriving anything. + upstream: dict[str, "AgentOutput"] = field(default_factory=dict) + + def __post_init__(self) -> None: + # When Temporal's data converter rebuilds this dataclass on the activity + # side, the nested `upstream` values may arrive as plain dicts. Coerce + # them to AgentOutput so every agent can rely on attribute access. + if self.upstream: + self.upstream = { + key: value if isinstance(value, AgentOutput) else AgentOutput.from_dict(value) + for key, value in self.upstream.items() + } + + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> "AgentRequest": + """Rebuild an AgentRequest from the dict a child workflow receives.""" + upstream_raw = payload.get("upstream", {}) or {} + return cls( + request_id=str(payload["request_id"]), + goal=str(payload.get("goal", "")), + repo_url=str(payload.get("repo_url", "")), + environment=str(payload.get("environment", "dev")), + stage=str(payload["stage"]), + approval_required=bool(payload.get("approval_required", True)), + upstream={k: AgentOutput.from_dict(v) for k, v in upstream_raw.items()}, + ) + + +# --------------------------------------------------------------------------- +# Output contract (the structured result every agent must return) +# --------------------------------------------------------------------------- + + +@dataclass +class AgentOutput: + """Structured output contract returned by every agent. + + Matches the "Agent Output Contract" in the spec:: + + { + "agent_name": "planner", + "stage": "planning", + "status": "success", + "retryable": false, + "summary": "deployment plan created", + "details": {}, + "next_action": "continue" + } + """ + + agent_name: str + stage: str + status: str # one of VALID_STATUSES + retryable: bool + summary: str + next_action: str # one of VALID_ACTIONS + details: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "AgentOutput": + return cls( + agent_name=str(data["agent_name"]), + stage=str(data["stage"]), + status=str(data["status"]), + retryable=bool(data.get("retryable", False)), + summary=str(data.get("summary", "")), + next_action=str(data.get("next_action", ACTION_CONTINUE)), + details=dict(data.get("details", {})), + ) + + def validate(self) -> "AgentOutput": + """Cheap, deterministic sanity check. Raises ValueError on a bad contract.""" + if self.status not in VALID_STATUSES: + raise ValueError(f"invalid status {self.status!r}; expected one of {sorted(VALID_STATUSES)}") + if self.next_action not in VALID_ACTIONS: + raise ValueError( + f"invalid next_action {self.next_action!r}; expected one of {sorted(VALID_ACTIONS)}" + ) + return self + + +@dataclass +class OrchestrationResult: + """Final return value of the parent workflow. Mirrors ``sample-output.json``.""" + + request_id: str + status: str # "success" | "failed" | "needs_approval" + stages: list[AgentOutput] = field(default_factory=list) + summary: str = "" + + +# --------------------------------------------------------------------------- +# Deterministic decision helper used by *workflow* code. +# --------------------------------------------------------------------------- + +# Decisions the orchestration/child workflows act on after each agent runs. +DECISION_OK = "ok" # status success -> move on +DECISION_RETRY = "retry" # failed + retryable -> run the activity again +DECISION_FAIL = "fail" # failed + not retryable -> fail the workflow +DECISION_APPROVE = "approve" # needs_approval -> wait for a human decision + + +def decide(output: AgentOutput) -> str: + """Map an :class:`AgentOutput` to the next control-flow decision. + + Pure function — safe to call from inside deterministic workflow code. + This is the single source of truth for the retry/fail/approval policy + described in the spec's "Workflow Behavior" section. + """ + if output.status == STATUS_NEEDS_APPROVAL or output.next_action == ACTION_ASK_HUMAN: + return DECISION_APPROVE + if output.status == STATUS_SUCCESS: + return DECISION_OK + # status == failed (or anything unexpected) -> honour the retryable flag + if output.retryable and output.next_action == ACTION_RETRY: + return DECISION_RETRY + return DECISION_FAIL diff --git a/temporal-maf-agents-poc/src/shared/logging.py b/temporal-maf-agents-poc/src/shared/logging.py new file mode 100644 index 0000000..41f6578 --- /dev/null +++ b/temporal-maf-agents-poc/src/shared/logging.py @@ -0,0 +1,103 @@ +"""Structured (JSON-line) logging plus a tiny health endpoint. + +Every log line carries the observability fields the spec asks for +(``workflow_id``, ``run_id``, ``request_id``, ``agent_name``, ``stage``, +``status``, ``duration_ms``, ``error_type``) when they are available. + +This module is imported by *activity* and *worker* code, not by workflow +code, so it is free to do I/O. (Workflows should use ``temporalio.workflow``'s +own logger, which already injects ``workflow_id`` / ``run_id``.) +""" + +from __future__ import annotations + +import json +import logging +import sys +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any + +# Canonical observability field order (also documented in the README). +LOG_FIELDS = ( + "workflow_id", + "run_id", + "request_id", + "agent_name", + "stage", + "status", + "duration_ms", + "error_type", +) + + +class JsonFormatter(logging.Formatter): + """Render each record as a single JSON object on one line.""" + + def format(self, record: logging.LogRecord) -> str: + payload: dict[str, Any] = { + "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + # Promote any of the canonical fields that were passed via `extra=`. + for key in LOG_FIELDS: + value = getattr(record, key, None) + if value is not None: + payload[key] = value + if record.exc_info: + payload["error_type"] = record.exc_info[0].__name__ if record.exc_info[0] else None + payload["exc"] = self.formatException(record.exc_info) + return json.dumps(payload, default=str) + + +_configured = False + + +def configure_logging(level: int = logging.INFO) -> None: + """Install the JSON formatter on the root logger (idempotent).""" + global _configured + if _configured: + return + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(JsonFormatter()) + root = logging.getLogger() + root.handlers[:] = [handler] + root.setLevel(level) + _configured = True + + +def get_logger(name: str) -> logging.Logger: + configure_logging() + return logging.getLogger(name) + + +# --------------------------------------------------------------------------- +# Health endpoint — used by the Kubernetes liveness/readiness probes. +# --------------------------------------------------------------------------- + + +class _HealthHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 (http.server API) + if self.path in ("/healthz", "/readyz", "/health", "/"): + body = b'{"status":"ok"}' + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, *_args: Any) -> None: # silence default stderr spam + return + + +def start_health_server(port: int) -> ThreadingHTTPServer: + """Start a background health server and return it (call .shutdown() to stop).""" + server = ThreadingHTTPServer(("0.0.0.0", port), _HealthHandler) + thread = threading.Thread(target=server.serve_forever, name="health", daemon=True) + thread.start() + return server diff --git a/temporal-maf-agents-poc/src/shared/maf.py b/temporal-maf-agents-poc/src/shared/maf.py new file mode 100644 index 0000000..0ea00d6 --- /dev/null +++ b/temporal-maf-agents-poc/src/shared/maf.py @@ -0,0 +1,107 @@ +"""Microsoft Agent Framework integration seam. + +**This is the only place the POC touches Microsoft Agent Framework**, and it is +only ever called from inside Temporal *activities* — never from workflow code. +Temporal stays the single orchestration authority; Agent Framework is just the +agent runtime that an activity drives. + +Phase 1 (default, ``AGENT_MODE=mock``): no network. Each agent returns a +deterministic, structured ``AgentOutput`` so the whole Temporal topology +(parent workflow, child workflows, task queues, retries, KEDA scaling) can be +exercised end-to-end with zero cloud credentials. + +Phase 2 (``AGENT_MODE=live``): replace the TODO stub in :func:`run_live_agent` +with a real Agent Framework agent backed by Azure OpenAI + MCP tools. The +construction pattern mirrors the sibling ``code_forge`` project:: + + from agent_framework.azure import AzureOpenAIChatClient + client = AzureOpenAIChatClient() # reads AZURE_OPENAI_* env + agent = client.as_agent(name=name, instructions=instructions) + result = await agent.run(prompt) # result.text holds output + +Tool calls (GitHub API, Kubernetes API, Azure) are wired as MAF tools / MCP +servers *here*, so the workflow never sees them. +""" + +from __future__ import annotations + +from typing import Any, Callable + +from shared.config import get_settings +from shared.contracts import AgentOutput, AgentRequest + +# A "mock factory" produces the canned AgentOutput for an agent in Phase 1. +MockFactory = Callable[[AgentRequest], AgentOutput] + + +async def run_agent( + *, + agent_name: str, + stage: str, + instructions: str, + request: AgentRequest, + mock: MockFactory, + build_prompt: Callable[[AgentRequest], str] | None = None, +) -> AgentOutput: + """Run one agent and return its structured output. + + Dispatches to the deterministic mock (Phase 1) or the live MAF agent + (Phase 2) based on ``AGENT_MODE``. + """ + settings = get_settings() + if settings.agent_mode == "live": + prompt = (build_prompt or _default_prompt)(request) + return await run_live_agent( + agent_name=agent_name, + stage=stage, + instructions=instructions, + prompt=prompt, + request=request, + ) + return mock(request).validate() + + +def _default_prompt(request: AgentRequest) -> str: + return ( + f"Goal: {request.goal}\n" + f"Repository: {request.repo_url}\n" + f"Environment: {request.environment}\n" + f"Stage: {request.stage}\n" + f"Upstream results: {request.upstream}" + ) + + +async def run_live_agent( + *, + agent_name: str, + stage: str, + instructions: str, + prompt: str, + request: AgentRequest, +) -> AgentOutput: + """Phase 2: real Microsoft Agent Framework agent. TODO — wire this up. + + Replace the body below with the real implementation. Keep it inside this + function so workflow code stays clean. Suggested skeleton:: + + from agent_framework.azure import AzureOpenAIChatClient + client = AzureOpenAIChatClient() + agent = client.as_agent(name=agent_name, instructions=instructions) + # TODO: register tools / MCP servers for this agent's stage + result = await agent.run(prompt) + parsed = _parse_structured_output(result.text) # enforce the contract + return AgentOutput(agent_name=agent_name, stage=stage, **parsed).validate() + """ + raise NotImplementedError( + "AGENT_MODE=live is a Phase 2 TODO. Wire Microsoft Agent Framework here " + "(Azure OpenAI + MCP tools), inside this activity-only seam. " + "Phase 1 uses AGENT_MODE=mock." + ) + + +# Convenience used by Phase 2 once wired (left here so the seam is obvious). +def build_chat_client() -> Any: # pragma: no cover - Phase 2 + """Construct an Azure OpenAI chat client for Agent Framework. TODO Phase 2.""" + from agent_framework.azure import AzureOpenAIChatClient # type: ignore + + return AzureOpenAIChatClient() diff --git a/temporal-maf-agents-poc/src/shared/runtime.py b/temporal-maf-agents-poc/src/shared/runtime.py new file mode 100644 index 0000000..389ada4 --- /dev/null +++ b/temporal-maf-agents-poc/src/shared/runtime.py @@ -0,0 +1,70 @@ +"""Worker bootstrap helpers shared by every ``*_worker/worker.py``. + +Activity/worker-side only (does real network I/O) — never imported by +workflow code. +""" + +from __future__ import annotations + +import asyncio +from typing import Any, Sequence + +from temporalio.client import Client +from temporalio.worker import Worker + +from shared.config import Settings, get_settings +from shared.logging import get_logger, start_health_server + +log = get_logger("worker") + + +async def connect(settings: Settings | None = None) -> Client: + """Connect a Temporal client to the configured frontend + namespace.""" + settings = settings or get_settings() + log.info( + "connecting to Temporal", + extra={"status": "connecting"}, + ) + client = await Client.connect( + settings.temporal_address, + namespace=settings.temporal_namespace, + ) + return client + + +async def run_worker( + task_queue: str, + workflows: Sequence[type], + activities: Sequence[Any] = (), +) -> None: + """Connect, start the health server, and run a Worker until cancelled. + + One call per worker process. ``activities`` is empty for the orchestrator + worker (it only hosts the parent workflow). + """ + settings = get_settings() + health = start_health_server(settings.health_port) + client = await connect(settings) + + worker = Worker( + client, + task_queue=task_queue, + workflows=list(workflows), + activities=list(activities), + ) + log.info( + "worker started", + extra={"status": "ready", "stage": task_queue}, + ) + try: + await worker.run() + finally: + health.shutdown() + + +def main(task_queue: str, workflows: Sequence[type], activities: Sequence[Any] = ()) -> None: + """Synchronous entrypoint used by ``python -m .worker``.""" + try: + asyncio.run(run_worker(task_queue, workflows, activities)) + except KeyboardInterrupt: + log.info("worker stopped", extra={"status": "stopped", "stage": task_queue}) diff --git a/temporal-maf-agents-poc/src/starter.py b/temporal-maf-agents-poc/src/starter.py new file mode 100644 index 0000000..6a1f1cc --- /dev/null +++ b/temporal-maf-agents-poc/src/starter.py @@ -0,0 +1,62 @@ +"""Kick off an ``AgentOrchestratorWorkflow`` and print the result. + +Usage: + python -m starter # uses sample-input.json + python -m starter path/to/input.json + python -m starter '{"request_id": "...", "goal": "..."}' + +Connects to the Temporal frontend (``TEMPORAL_ADDRESS``) in the +``agent-platform`` namespace and starts the parent workflow on +``orchestrator-tq``. +""" + +from __future__ import annotations + +import asyncio +import dataclasses +import json +import sys +from pathlib import Path + +from temporalio.client import Client + +from shared import config +from shared.contracts import OrchestrationRequest, OrchestrationResult +from orchestrator_worker.workflows import AgentOrchestratorWorkflow + +_DEFAULT_INPUT = Path(__file__).resolve().parents[1] / "sample-input.json" + + +def _load_request(arg: str | None) -> OrchestrationRequest: + if arg is None: + data = json.loads(_DEFAULT_INPUT.read_text()) + elif arg.strip().startswith("{"): + data = json.loads(arg) + else: + data = json.loads(Path(arg).read_text()) + return OrchestrationRequest.from_dict(data) + + +async def main(arg: str | None) -> None: + settings = config.get_settings() + request = _load_request(arg) + + client = await Client.connect( + settings.temporal_address, namespace=settings.temporal_namespace + ) + + workflow_id = f"orchestration-{request.request_id}" + print(f"starting {config.ORCHESTRATOR_WORKFLOW} id={workflow_id} on {config.ORCHESTRATOR_TASK_QUEUE}") + + result: OrchestrationResult = await client.execute_workflow( + AgentOrchestratorWorkflow.run, + request, + id=workflow_id, + task_queue=config.ORCHESTRATOR_TASK_QUEUE, + ) + + print(json.dumps(dataclasses.asdict(result), indent=2, default=str)) + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else None)) diff --git a/temporal-maf-agents-poc/tests/test_agents.py b/temporal-maf-agents-poc/tests/test_agents.py new file mode 100644 index 0000000..d66147f --- /dev/null +++ b/temporal-maf-agents-poc/tests/test_agents.py @@ -0,0 +1,60 @@ +"""Each agent's Phase-1 mock must emit a valid AgentOutput contract.""" + +from __future__ import annotations + +from shared.contracts import ( + STAGE_AKS, + STAGE_GITHUB, + STAGE_PLANNING, + STATUS_NEEDS_APPROVAL, + STATUS_SUCCESS, + AgentRequest, +) + +from planner_agent_worker import agent as planner +from github_agent_worker import agent as github +from aks_agent_worker import agent as aks +from approval_agent_worker import agent as approval + + +def _req(stage: str, approval_required: bool = True, upstream=None) -> AgentRequest: + return AgentRequest( + request_id="r1", + goal="add healthcheck", + repo_url="https://github.com/example-org/svc", + environment="dev", + stage=stage, + approval_required=approval_required, + upstream=upstream or {}, + ) + + +def test_planner_mock_is_valid(): + out = planner.mock(_req(STAGE_PLANNING)).validate() + assert out.agent_name == "planner" + assert out.status == STATUS_SUCCESS + assert out.details["steps"] + + +def test_github_mock_uses_plan(): + plan = planner.mock(_req(STAGE_PLANNING)) + out = github.mock(_req(STAGE_GITHUB, upstream={STAGE_PLANNING: plan})).validate() + assert out.details["based_on_plan"] is True + assert out.details["pr_url"].endswith("/pull/1") + + +def test_aks_mock_requires_approval_when_asked(): + out = aks.mock(_req(STAGE_AKS, approval_required=True)).validate() + assert out.status == STATUS_NEEDS_APPROVAL + assert out.details["promotion_pending"] is True + + +def test_aks_mock_autopromotes_when_not_required(): + out = aks.mock(_req(STAGE_AKS, approval_required=False)).validate() + assert out.status == STATUS_SUCCESS + + +def test_approval_mock_flags_when_required(): + out = approval.mock(_req("approval", approval_required=True)).validate() + assert out.status == STATUS_NEEDS_APPROVAL + assert "auto_approve" in out.details diff --git a/temporal-maf-agents-poc/tests/test_contracts.py b/temporal-maf-agents-poc/tests/test_contracts.py new file mode 100644 index 0000000..3251e9f --- /dev/null +++ b/temporal-maf-agents-poc/tests/test_contracts.py @@ -0,0 +1,89 @@ +"""Unit tests for the deterministic decision policy and the agent contract. + +No Temporal server required — these exercise the pure helpers. +""" + +from __future__ import annotations + +import pytest + +from shared.contracts import ( + ACTION_ASK_HUMAN, + ACTION_CONTINUE, + ACTION_FAIL, + ACTION_RETRY, + DECISION_APPROVE, + DECISION_FAIL, + DECISION_OK, + DECISION_RETRY, + STATUS_FAILED, + STATUS_NEEDS_APPROVAL, + STATUS_SUCCESS, + AgentOutput, + AgentRequest, + decide, +) + + +def _out(status: str, retryable: bool, action: str) -> AgentOutput: + return AgentOutput( + agent_name="t", + stage="planning", + status=status, + retryable=retryable, + summary="x", + next_action=action, + ) + + +@pytest.mark.parametrize( + "status,retryable,action,expected", + [ + (STATUS_SUCCESS, False, ACTION_CONTINUE, DECISION_OK), + (STATUS_FAILED, True, ACTION_RETRY, DECISION_RETRY), + (STATUS_FAILED, False, ACTION_FAIL, DECISION_FAIL), + (STATUS_FAILED, True, ACTION_FAIL, DECISION_FAIL), # retryable but action=fail + (STATUS_NEEDS_APPROVAL, False, ACTION_ASK_HUMAN, DECISION_APPROVE), + (STATUS_SUCCESS, False, ACTION_ASK_HUMAN, DECISION_APPROVE), # ask_human wins + ], +) +def test_decide(status, retryable, action, expected): + assert decide(_out(status, retryable, action)) == expected + + +def test_validate_rejects_bad_status(): + with pytest.raises(ValueError): + _out("bogus", False, ACTION_CONTINUE).validate() + + +def test_validate_rejects_bad_action(): + with pytest.raises(ValueError): + _out(STATUS_SUCCESS, False, "teleport").validate() + + +def test_agent_request_roundtrip_through_payload(): + upstream = {"planning": _out(STATUS_SUCCESS, False, ACTION_CONTINUE)} + payload = { + "request_id": "r1", + "goal": "g", + "repo_url": "https://example.com/repo", + "environment": "dev", + "stage": "github", + "approval_required": True, + "upstream": { + k: { + "agent_name": v.agent_name, + "stage": v.stage, + "status": v.status, + "retryable": v.retryable, + "summary": v.summary, + "next_action": v.next_action, + "details": v.details, + } + for k, v in upstream.items() + }, + } + req = AgentRequest.from_payload(payload) + assert req.request_id == "r1" + assert req.stage == "github" + assert req.upstream["planning"].status == STATUS_SUCCESS diff --git a/temporal-maf-agents-poc/tests/test_workflow_integration.py b/temporal-maf-agents-poc/tests/test_workflow_integration.py new file mode 100644 index 0000000..4b7227c --- /dev/null +++ b/temporal-maf-agents-poc/tests/test_workflow_integration.py @@ -0,0 +1,160 @@ +"""End-to-end Temporal test: parent workflow drives all four child workflows. + +Uses Temporal's in-memory time-skipping test environment, so the approval +timer fires instantly and no external Temporal server is needed. The very +first run downloads the test-server binary; if that download is unavailable +the test skips rather than failing. +""" + +from __future__ import annotations + +import uuid + +import pytest + +pytest.importorskip("temporalio") + +from temporalio.worker import Worker + +from shared import config +from shared.contracts import ( + STATUS_FAILED, + STATUS_SUCCESS, + OrchestrationRequest, + OrchestrationResult, +) + +from orchestrator_worker.workflows import AgentOrchestratorWorkflow +from planner_agent_worker.workflows import PlannerAgentWorkflow +from planner_agent_worker.activities import run_planner_agent +from github_agent_worker.workflows import GitHubAgentWorkflow +from github_agent_worker.activities import run_github_agent +from aks_agent_worker.workflows import AKSAgentWorkflow +from aks_agent_worker.activities import run_aks_agent +from approval_agent_worker.workflows import ApprovalAgentWorkflow +from approval_agent_worker.activities import run_approval_agent + + +def _workers(client): + """One Worker per task queue, sharing the test client.""" + return [ + Worker( + client, + task_queue=config.ORCHESTRATOR_TASK_QUEUE, + workflows=[AgentOrchestratorWorkflow], + ), + Worker( + client, + task_queue=config.PLANNER_TASK_QUEUE, + workflows=[PlannerAgentWorkflow], + activities=[run_planner_agent], + ), + Worker( + client, + task_queue=config.GITHUB_TASK_QUEUE, + workflows=[GitHubAgentWorkflow], + activities=[run_github_agent], + ), + Worker( + client, + task_queue=config.AKS_TASK_QUEUE, + workflows=[AKSAgentWorkflow], + activities=[run_aks_agent], + ), + Worker( + client, + task_queue=config.APPROVAL_TASK_QUEUE, + workflows=[ApprovalAgentWorkflow], + activities=[run_approval_agent], + ), + ] + + +async def _run(client, request: OrchestrationRequest) -> OrchestrationResult: + import contextlib + + workers = _workers(client) + async with contextlib.AsyncExitStack() as stack: + for w in workers: + await stack.enter_async_context(w) + return await client.execute_workflow( + AgentOrchestratorWorkflow.run, + request, + id=f"test-{uuid.uuid4()}", + task_queue=config.ORCHESTRATOR_TASK_QUEUE, + ) + + +@pytest.fixture() +async def env(): + from temporalio.testing import WorkflowEnvironment + + try: + environment = await WorkflowEnvironment.start_time_skipping() + except Exception as exc: # pragma: no cover - offline / no binary + pytest.skip(f"time-skipping test server unavailable: {exc}") + async with environment: + yield environment + + +async def test_full_pipeline_auto_approves(env, monkeypatch): + # Auto-approve so the approval gate resolves under time-skipping. + monkeypatch.setenv("TEMPORAL_APPROVAL_AUTO", "true") + monkeypatch.setenv("AGENT_MODE", "mock") + + result = await _run( + env.client, + OrchestrationRequest( + request_id="itest-1", + goal="add a healthcheck endpoint", + repo_url="https://github.com/example-org/svc", + environment="dev", + approval_required=True, + ), + ) + + assert result.status == STATUS_SUCCESS + stages = {s.stage: s for s in result.stages} + assert set(stages) == {"planning", "github", "aks", "approval"} + assert stages["aks"].status == "needs_approval" # gated mid-pipeline + assert stages["approval"].status == STATUS_SUCCESS # then auto-approved + + +async def test_approval_rejection_returns_failed(env, monkeypatch): + # Require a real human signal (no auto-approve), then reject it. Driving the + # ApprovalAgentWorkflow directly with a start-signal makes this fully + # deterministic under time-skipping (no signal/timer race). + monkeypatch.setenv("TEMPORAL_APPROVAL_AUTO", "false") + monkeypatch.setenv("AGENT_MODE", "mock") + + import contextlib + + client = env.client + payload = { + "request_id": "itest-2", + "goal": "risky prod change", + "repo_url": "https://github.com/example-org/svc", + "environment": "prod", + "approval_required": True, + "stage": "approval", + "upstream": {}, + } + + async with contextlib.AsyncExitStack() as stack: + for w in _workers(client): + await stack.enter_async_context(w) + + # The reject signal is buffered and applied before the durable wait, so + # the gate resolves immediately as a rejection. + result = await client.execute_workflow( + ApprovalAgentWorkflow.run, + payload, + id=f"test-reject-{uuid.uuid4()}", + task_queue=config.APPROVAL_TASK_QUEUE, + start_signal="submit_decision", + start_signal_args=[False, "no go"], + ) + + assert result.status == STATUS_FAILED + assert result.details["approved"] is False + assert result.next_action == "fail" From 38283239a90358cce4479898309c5f85eafb508f Mon Sep 17 00:00:00 2001 From: Roey Zalta Date: Thu, 25 Jun 2026 10:25:15 +0300 Subject: [PATCH 05/17] fix(phase2): drop agent-framework from base deps; harden config test Remove agent-framework from [project].dependencies so mock-mode installs and runs without it. It remains in the [project.optional-dependencies] live extra. Also adds AZURE_OPENAI_API_VERSION to the monkeypatch.delenv loop in test_phase2_defaults to guard against a pre-set empty env var breaking the non-empty default assertion. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01U8d6DuACgKfMFiFbaQ2AFQ --- temporal-maf-agents-poc/pyproject.toml | 4 ---- temporal-maf-agents-poc/tests/test_config_phase2.py | 1 + 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/temporal-maf-agents-poc/pyproject.toml b/temporal-maf-agents-poc/pyproject.toml index eb8209c..63e8df9 100644 --- a/temporal-maf-agents-poc/pyproject.toml +++ b/temporal-maf-agents-poc/pyproject.toml @@ -6,10 +6,6 @@ requires-python = ">=3.10" dependencies = [ # Durable orchestration layer. "temporalio>=1.7,<2", - # Microsoft Agent Framework — used ONLY inside Temporal activities. - # Phase 1 (AGENT_MODE=mock) does not import it at runtime; it is declared - # so Phase 2 (AGENT_MODE=live) works without changing dependencies. - "agent-framework>=0.0.0a1", "python-dotenv>=1.0", "pydantic>=2.7", ] diff --git a/temporal-maf-agents-poc/tests/test_config_phase2.py b/temporal-maf-agents-poc/tests/test_config_phase2.py index 42fd10e..39e1093 100644 --- a/temporal-maf-agents-poc/tests/test_config_phase2.py +++ b/temporal-maf-agents-poc/tests/test_config_phase2.py @@ -7,6 +7,7 @@ def test_phase2_defaults(monkeypatch): for var in ( "AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_CHAT_DEPLOYMENT", "AZURE_OPENAI_API_KEY", "GITHUB_TOKEN", "GITHUB_ALLOWED_OWNER", + "AZURE_OPENAI_API_VERSION", ): monkeypatch.delenv(var, raising=False) s = get_settings() From e94e34a969e43796c4487d70e4630ce781e398b8 Mon Sep 17 00:00:00 2001 From: Roey Zalta Date: Thu, 25 Jun 2026 10:28:14 +0300 Subject: [PATCH 06/17] feat(phase2): implement live MAF seam (Azure OpenAI + structured output) --- temporal-maf-agents-poc/src/shared/maf.py | 139 ++++++++++++------ .../tests/test_maf_seam.py | 87 +++++++++++ 2 files changed, 180 insertions(+), 46 deletions(-) create mode 100644 temporal-maf-agents-poc/tests/test_maf_seam.py diff --git a/temporal-maf-agents-poc/src/shared/maf.py b/temporal-maf-agents-poc/src/shared/maf.py index 0ea00d6..859e3cb 100644 --- a/temporal-maf-agents-poc/src/shared/maf.py +++ b/temporal-maf-agents-poc/src/shared/maf.py @@ -10,14 +10,10 @@ (parent workflow, child workflows, task queues, retries, KEDA scaling) can be exercised end-to-end with zero cloud credentials. -Phase 2 (``AGENT_MODE=live``): replace the TODO stub in :func:`run_live_agent` -with a real Agent Framework agent backed by Azure OpenAI + MCP tools. The -construction pattern mirrors the sibling ``code_forge`` project:: - - from agent_framework.azure import AzureOpenAIChatClient - client = AzureOpenAIChatClient() # reads AZURE_OPENAI_* env - agent = client.as_agent(name=name, instructions=instructions) - result = await agent.run(prompt) # result.text holds output +Phase 2 (``AGENT_MODE=live``): agents that supply both a ``response_model`` and +a ``to_output`` mapper are driven by a real Azure OpenAI MAF agent. Agents +without live wiring (e.g. AKS) stay on the deterministic mock even when +``AGENT_MODE=live``. Tool calls (GitHub API, Kubernetes API, Azure) are wired as MAF tools / MCP servers *here*, so the workflow never sees them. @@ -25,39 +21,47 @@ from __future__ import annotations -from typing import Any, Callable +from typing import Any, Awaitable, Callable + +from temporalio.exceptions import ApplicationError -from shared.config import get_settings +from shared.config import Settings, get_settings from shared.contracts import AgentOutput, AgentRequest -# A "mock factory" produces the canned AgentOutput for an agent in Phase 1. MockFactory = Callable[[AgentRequest], AgentOutput] +BuildPrompt = Callable[[AgentRequest], str] +ToOutput = Callable[[AgentRequest, Any], Awaitable[AgentOutput]] async def run_agent( *, agent_name: str, stage: str, - instructions: str, request: AgentRequest, mock: MockFactory, - build_prompt: Callable[[AgentRequest], str] | None = None, + instructions: str | None = None, + build_prompt: BuildPrompt | None = None, + response_model: type | None = None, + to_output: ToOutput | None = None, ) -> AgentOutput: """Run one agent and return its structured output. - Dispatches to the deterministic mock (Phase 1) or the live MAF agent - (Phase 2) based on ``AGENT_MODE``. + Goes live only when AGENT_MODE=live AND the agent supplied both a + response_model and a to_output mapper. Agents without live wiring (e.g. + AKS) stay on the deterministic mock even in live mode. """ settings = get_settings() - if settings.agent_mode == "live": + live_supported = response_model is not None and to_output is not None + if settings.agent_mode == "live" and live_supported: prompt = (build_prompt or _default_prompt)(request) - return await run_live_agent( + parsed = await run_live_agent( agent_name=agent_name, - stage=stage, - instructions=instructions, + instructions=instructions or "", prompt=prompt, - request=request, + response_model=response_model, ) + out = await to_output(request, parsed) + return out.validate() return mock(request).validate() @@ -71,37 +75,80 @@ def _default_prompt(request: AgentRequest) -> str: ) +def build_chat_client(settings: Settings) -> Any: + """Construct an Azure-OpenAI-backed MAF chat client. + + Uses the API key when present, otherwise DefaultAzureCredential (Entra ID / + AKS workload identity). Imports the live packages lazily so mock mode runs + without them installed. + """ + from agent_framework.openai import OpenAIChatClient # type: ignore + + if not settings.azure_openai_endpoint or not settings.azure_openai_deployment: + raise ApplicationError( + "AGENT_MODE=live requires AZURE_OPENAI_ENDPOINT and " + "AZURE_OPENAI_CHAT_DEPLOYMENT", + type="ConfigError", + non_retryable=True, + ) + + kwargs: dict[str, Any] = { + "model": settings.azure_openai_deployment, + "azure_endpoint": settings.azure_openai_endpoint, + "api_version": settings.azure_openai_api_version, + } + if settings.azure_openai_api_key: + kwargs["api_key"] = settings.azure_openai_api_key + else: + from azure.identity.aio import DefaultAzureCredential # type: ignore + + kwargs["credential"] = DefaultAzureCredential() + return OpenAIChatClient(**kwargs) + + async def run_live_agent( *, agent_name: str, - stage: str, instructions: str, prompt: str, - request: AgentRequest, -) -> AgentOutput: - """Phase 2: real Microsoft Agent Framework agent. TODO — wire this up. - - Replace the body below with the real implementation. Keep it inside this - function so workflow code stays clean. Suggested skeleton:: - - from agent_framework.azure import AzureOpenAIChatClient - client = AzureOpenAIChatClient() - agent = client.as_agent(name=agent_name, instructions=instructions) - # TODO: register tools / MCP servers for this agent's stage - result = await agent.run(prompt) - parsed = _parse_structured_output(result.text) # enforce the contract - return AgentOutput(agent_name=agent_name, stage=stage, **parsed).validate() - """ - raise NotImplementedError( - "AGENT_MODE=live is a Phase 2 TODO. Wire Microsoft Agent Framework here " - "(Azure OpenAI + MCP tools), inside this activity-only seam. " - "Phase 1 uses AGENT_MODE=mock." - ) + response_model: type, +) -> Any: + """Drive a real MAF agent and return the parsed structured output. + Raises on transient failures (so Temporal's retry policy handles them) and + raises a non-retryable ApplicationError on permanent failures (auth/bad + request) so Temporal fails fast instead of retrying pointlessly. + """ + settings = get_settings() + client = build_chat_client(settings) + agent = client.as_agent(name=agent_name, instructions=instructions) + try: + result = await agent.run(prompt, options={"response_format": response_model}) + except Exception as exc: # noqa: BLE001 - classify then re-raise + if _is_permanent_azure_error(exc): + raise ApplicationError( + f"permanent Azure OpenAI error for {agent_name}: {exc}", + type=type(exc).__name__, + non_retryable=True, + ) from exc + raise # transient -> Temporal layer-1 retry + + parsed = getattr(result, "value", None) + if parsed is None: + # Model returned output that didn't match the schema. Treat as transient + # (a re-generation often succeeds); Temporal retries, then fails. + raise RuntimeError( + f"{agent_name} returned no schema-valid output: " + f"{getattr(result, 'text', '')[:300]}" + ) + return parsed -# Convenience used by Phase 2 once wired (left here so the seam is obvious). -def build_chat_client() -> Any: # pragma: no cover - Phase 2 - """Construct an Azure OpenAI chat client for Agent Framework. TODO Phase 2.""" - from agent_framework.azure import AzureOpenAIChatClient # type: ignore - return AzureOpenAIChatClient() +def _is_permanent_azure_error(exc: Exception) -> bool: + """Auth / bad-request style errors should not be retried.""" + name = type(exc).__name__ + if name in {"AuthenticationError", "PermissionDeniedError", "BadRequestError", + "NotFoundError", "UnprocessableEntityError"}: + return True + status = getattr(exc, "status_code", None) + return status in {400, 401, 403, 404, 422} diff --git a/temporal-maf-agents-poc/tests/test_maf_seam.py b/temporal-maf-agents-poc/tests/test_maf_seam.py new file mode 100644 index 0000000..80610d5 --- /dev/null +++ b/temporal-maf-agents-poc/tests/test_maf_seam.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import pytest + +from shared.contracts import ( + ACTION_CONTINUE, STATUS_SUCCESS, AgentOutput, AgentRequest, +) +from shared import maf + + +def _req(stage="planning"): + return AgentRequest( + request_id="r1", goal="g", repo_url="https://github.com/o/r", + environment="dev", stage=stage, + ) + + +def _mock_output(req): + return AgentOutput( + agent_name="x", stage=req.stage, status=STATUS_SUCCESS, + retryable=False, summary="mock", next_action=ACTION_CONTINUE, + ) + + +async def test_run_agent_uses_mock_when_mode_mock(monkeypatch): + monkeypatch.setenv("AGENT_MODE", "mock") + out = await maf.run_agent( + agent_name="x", stage="planning", request=_req(), mock=_mock_output, + ) + assert out.summary == "mock" + + +async def test_run_agent_falls_back_to_mock_when_live_wiring_absent(monkeypatch): + # AKS-style: live mode but no response_model/to_output -> stays mock. + monkeypatch.setenv("AGENT_MODE", "live") + out = await maf.run_agent( + agent_name="aks", stage="aks", request=_req("aks"), mock=_mock_output, + ) + assert out.summary == "mock" + + +async def test_run_agent_live_path_calls_to_output(monkeypatch): + monkeypatch.setenv("AGENT_MODE", "live") + + class Parsed: + value = 42 + + async def fake_live(*, agent_name, instructions, prompt, response_model): + return Parsed() + + captured = {} + + async def to_output(req, parsed): + captured["parsed"] = parsed + return AgentOutput( + agent_name="x", stage=req.stage, status=STATUS_SUCCESS, + retryable=False, summary="live", next_action=ACTION_CONTINUE, + ) + + monkeypatch.setattr(maf, "run_live_agent", fake_live) + out = await maf.run_agent( + agent_name="x", stage="planning", request=_req(), mock=_mock_output, + instructions="i", build_prompt=lambda r: "p", + response_model=Parsed, to_output=to_output, + ) + assert out.summary == "live" + assert captured["parsed"].value == 42 + + +async def test_run_live_agent_raises_on_none_value(monkeypatch): + class FakeResult: + value = None + text = "garbage" + + class FakeAgent: + async def run(self, prompt, options=None): + return FakeResult() + + class FakeClient: + def as_agent(self, **kwargs): + return FakeAgent() + + monkeypatch.setattr(maf, "build_chat_client", lambda settings: FakeClient()) + with pytest.raises(Exception): + await maf.run_live_agent( + agent_name="x", instructions="i", prompt="p", response_model=object, + ) From 271b406eda06180cb56e521074fff22a51f9954d Mon Sep 17 00:00:00 2001 From: Roey Zalta Date: Thu, 25 Jun 2026 10:30:25 +0300 Subject: [PATCH 07/17] test(phase2): pin RuntimeError in maf none-value test --- temporal-maf-agents-poc/tests/test_maf_seam.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temporal-maf-agents-poc/tests/test_maf_seam.py b/temporal-maf-agents-poc/tests/test_maf_seam.py index 80610d5..4b71c28 100644 --- a/temporal-maf-agents-poc/tests/test_maf_seam.py +++ b/temporal-maf-agents-poc/tests/test_maf_seam.py @@ -81,7 +81,7 @@ def as_agent(self, **kwargs): return FakeAgent() monkeypatch.setattr(maf, "build_chat_client", lambda settings: FakeClient()) - with pytest.raises(Exception): + with pytest.raises(RuntimeError, match="no schema-valid output"): await maf.run_live_agent( agent_name="x", instructions="i", prompt="p", response_model=object, ) From 159672fe3fb15325e0e1276b0223cddee617b42f Mon Sep 17 00:00:00 2001 From: Roey Zalta Date: Thu, 25 Jun 2026 10:32:41 +0300 Subject: [PATCH 08/17] feat(phase2): idempotent guarded GitHub write client --- temporal-maf-agents-poc/src/shared/github.py | 130 ++++++++++++++++++ .../tests/test_github_client.py | 116 ++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 temporal-maf-agents-poc/src/shared/github.py create mode 100644 temporal-maf-agents-poc/tests/test_github_client.py diff --git a/temporal-maf-agents-poc/src/shared/github.py b/temporal-maf-agents-poc/src/shared/github.py new file mode 100644 index 0000000..7adb520 --- /dev/null +++ b/temporal-maf-agents-poc/src/shared/github.py @@ -0,0 +1,130 @@ +"""Idempotent, guarded GitHub write client (activity-side only). + +No LLM here. PyGithub is synchronous, so callers invoke create_pr_with_plan via +asyncio.to_thread. PyGithub is imported lazily so mock mode runs without it. +""" + +from __future__ import annotations + +import re +from typing import Any, Callable + +PLAN_PATH_TEMPLATE = "docs/agent-plan-{request_id}.md" +BRANCH_TEMPLATE = "feat/{request_id}" + +# Statuses we treat as permanent (no point retrying). +_PERMANENT_STATUS = {401, 403, 404, 422} + + +class GitHubWriteNotAllowed(Exception): + """The target repo is not permitted by the owner/allowlist guard.""" + + +class PermanentGitHubError(Exception): + """A non-retryable GitHub failure (auth, missing repo, validation).""" + + +def parse_owner_repo(repo_url: str) -> tuple[str, str]: + """Extract (owner, repo) from an https or ssh GitHub URL.""" + cleaned = repo_url.strip() + cleaned = re.sub(r"\.git$", "", cleaned) + m = re.search(r"github\.com[:/]+([^/]+)/([^/]+)$", cleaned) + if not m: + raise PermanentGitHubError(f"cannot parse owner/repo from {repo_url!r}") + return m.group(1), m.group(2) + + +def assert_write_allowed(owner: str, allowed_owner: str | None) -> None: + """Fail-closed guard: refuse unless the owner matches the allowlist.""" + if not allowed_owner: + raise GitHubWriteNotAllowed( + "GITHUB_ALLOWED_OWNER is not set; refusing to write (fail-closed)" + ) + allowed = {o.strip() for o in allowed_owner.split(",") if o.strip()} + if owner not in allowed: + raise GitHubWriteNotAllowed( + f"owner {owner!r} not in allowed owners {sorted(allowed)}" + ) + + +def _default_client_factory(token: str | None) -> Any: + from github import Auth, Github # type: ignore + + if not token: + raise PermanentGitHubError("GITHUB_TOKEN is required for live GitHub writes") + return Github(auth=Auth.Token(token)) + + +def create_pr_with_plan( + *, + repo_url: str, + request_id: str, + token: str | None, + allowed_owner: str | None, + pr_title: str, + pr_body: str, + plan_markdown: str, + commit_message: str, + client_factory: Callable[[str | None], Any] | None = None, +) -> dict: + """Ensure branch -> upsert plan file -> ensure PR. Idempotent. + + Raises GitHubWriteNotAllowed / PermanentGitHubError for permanent failures; + lets transient GithubException (5xx / rate limit) propagate for retry. + """ + from github import GithubException # type: ignore + + owner, repo_name = parse_owner_repo(repo_url) + assert_write_allowed(owner, allowed_owner) + + factory = client_factory or _default_client_factory + gh_client = factory(token) + + branch = BRANCH_TEMPLATE.format(request_id=request_id) + path = PLAN_PATH_TEMPLATE.format(request_id=request_id) + + try: + repo = gh_client.get_repo(f"{owner}/{repo_name}") + base = repo.default_branch + + # 1. ensure branch + try: + repo.get_git_ref(f"heads/{branch}") + except GithubException as exc: + if exc.status == 404: + base_sha = repo.get_git_ref(f"heads/{base}").object.sha + repo.create_git_ref(ref=f"refs/heads/{branch}", sha=base_sha) + else: + raise + + # 2. upsert plan file on the branch + try: + existing = repo.get_contents(path, ref=branch) + repo.update_file(path, commit_message, plan_markdown, existing.sha, branch=branch) + except GithubException as exc: + if exc.status == 404: + repo.create_file(path, commit_message, plan_markdown, branch=branch) + else: + raise + + # 3. ensure PR + open_pulls = list(repo.get_pulls(state="open", head=f"{owner}:{branch}")) + if open_pulls: + pr = open_pulls[0] + created_or_existed = "existed" + else: + pr = repo.create_pull(title=pr_title, body=pr_body, head=branch, base=base) + created_or_existed = "created" + + return { + "branch": branch, + "pr_number": pr.number, + "pr_url": pr.html_url, + "plan_file": path, + "created_or_existed": created_or_existed, + } + + except GithubException as exc: + if getattr(exc, "status", None) in _PERMANENT_STATUS: + raise PermanentGitHubError(f"GitHub {exc.status}: {exc.data}") from exc + raise # transient (5xx, secondary rate limit) -> Temporal retry diff --git a/temporal-maf-agents-poc/tests/test_github_client.py b/temporal-maf-agents-poc/tests/test_github_client.py new file mode 100644 index 0000000..c938088 --- /dev/null +++ b/temporal-maf-agents-poc/tests/test_github_client.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from shared import github as gh + + +def test_parse_owner_repo(): + assert gh.parse_owner_repo("https://github.com/example-org/svc") == ("example-org", "svc") + assert gh.parse_owner_repo("https://github.com/example-org/svc.git") == ("example-org", "svc") + assert gh.parse_owner_repo("git@github.com:example-org/svc.git") == ("example-org", "svc") + + +def test_assert_write_allowed_fail_closed_when_unset(): + with pytest.raises(gh.GitHubWriteNotAllowed): + gh.assert_write_allowed("example-org", None) + + +def test_assert_write_allowed_rejects_mismatch(): + with pytest.raises(gh.GitHubWriteNotAllowed): + gh.assert_write_allowed("someone-else", "example-org") + + +def test_assert_write_allowed_accepts_match(): + gh.assert_write_allowed("example-org", "example-org") # no raise + + +def _fake_repo(*, branch_exists, file_exists, pr_exists): + repo = MagicMock() + repo.default_branch = "main" + base_ref = SimpleNamespace(object=SimpleNamespace(sha="basesha")) + + def get_git_ref(ref): + if ref == "heads/main": + return base_ref + if ref == f"heads/feat/req-1" and branch_exists: + return SimpleNamespace(object=SimpleNamespace(sha="branchsha")) + from github import GithubException + raise GithubException(404, {"message": "Not Found"}, {}) + + repo.get_git_ref.side_effect = get_git_ref + + if file_exists: + repo.get_contents.return_value = SimpleNamespace(sha="filesha") + else: + from github import GithubException + repo.get_contents.side_effect = GithubException(404, {"message": "nf"}, {}) + + if pr_exists: + existing = SimpleNamespace(number=7, html_url="https://github.com/example-org/svc/pull/7") + repo.get_pulls.return_value = [existing] + else: + repo.get_pulls.return_value = [] + repo.create_pull.return_value = SimpleNamespace( + number=8, html_url="https://github.com/example-org/svc/pull/8" + ) + return repo + + +def _factory_for(repo): + gh_client = MagicMock() + gh_client.get_repo.return_value = repo + return lambda token: gh_client + + +def test_create_pr_fresh(monkeypatch): + repo = _fake_repo(branch_exists=False, file_exists=False, pr_exists=False) + out = gh.create_pr_with_plan( + repo_url="https://github.com/example-org/svc", request_id="req-1", + token="t", allowed_owner="example-org", pr_title="T", pr_body="B", + plan_markdown="# plan", commit_message="add plan", + client_factory=_factory_for(repo), + ) + assert out["branch"] == "feat/req-1" + assert out["pr_number"] == 8 + assert out["created_or_existed"] == "created" + repo.create_git_ref.assert_called_once() # branch created from base + + +def test_create_pr_idempotent_when_everything_exists(monkeypatch): + repo = _fake_repo(branch_exists=True, file_exists=True, pr_exists=True) + out = gh.create_pr_with_plan( + repo_url="https://github.com/example-org/svc", request_id="req-1", + token="t", allowed_owner="example-org", pr_title="T", pr_body="B", + plan_markdown="# plan", commit_message="add plan", + client_factory=_factory_for(repo), + ) + assert out["pr_number"] == 7 + assert out["created_or_existed"] == "existed" + repo.create_git_ref.assert_not_called() # branch reused + repo.update_file.assert_called_once() # file updated, not created + repo.create_pull.assert_not_called() # PR reused + + +def test_create_pr_guard_blocks_disallowed_owner(): + with pytest.raises(gh.GitHubWriteNotAllowed): + gh.create_pr_with_plan( + repo_url="https://github.com/someone-else/svc", request_id="req-1", + token="t", allowed_owner="example-org", pr_title="T", pr_body="B", + plan_markdown="x", commit_message="m", client_factory=lambda token: MagicMock(), + ) + + +def test_permanent_error_on_404_repo(): + from github import GithubException + gh_client = MagicMock() + gh_client.get_repo.side_effect = GithubException(404, {"message": "nf"}, {}) + with pytest.raises(gh.PermanentGitHubError): + gh.create_pr_with_plan( + repo_url="https://github.com/example-org/svc", request_id="req-1", + token="t", allowed_owner="example-org", pr_title="T", pr_body="B", + plan_markdown="x", commit_message="m", client_factory=lambda token: gh_client, + ) From 3a845ec718dec593e4f0185cf439416eed2334eb Mon Sep 17 00:00:00 2001 From: Roey Zalta Date: Thu, 25 Jun 2026 10:35:27 +0300 Subject: [PATCH 09/17] test(phase2): cover github create-path and partial-retry idempotency Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01U8d6DuACgKfMFiFbaQ2AFQ --- .../tests/test_github_client.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/temporal-maf-agents-poc/tests/test_github_client.py b/temporal-maf-agents-poc/tests/test_github_client.py index c938088..894e7c0 100644 --- a/temporal-maf-agents-poc/tests/test_github_client.py +++ b/temporal-maf-agents-poc/tests/test_github_client.py @@ -78,6 +78,8 @@ def test_create_pr_fresh(monkeypatch): assert out["pr_number"] == 8 assert out["created_or_existed"] == "created" repo.create_git_ref.assert_called_once() # branch created from base + repo.create_file.assert_called_once() # plan file created (not updated) + repo.create_pull.assert_called_once() # PR opened def test_create_pr_idempotent_when_everything_exists(monkeypatch): @@ -114,3 +116,20 @@ def test_permanent_error_on_404_repo(): token="t", allowed_owner="example-org", pr_title="T", pr_body="B", plan_markdown="x", commit_message="m", client_factory=lambda token: gh_client, ) + + +def test_create_pr_partial_retry_branch_exists_file_absent(): + # Simulates a Temporal retry after the branch was created but the commit/PR + # did not happen: must NOT recreate the branch, must create the file + PR. + repo = _fake_repo(branch_exists=True, file_exists=False, pr_exists=False) + out = gh.create_pr_with_plan( + repo_url="https://github.com/example-org/svc", request_id="req-1", + token="t", allowed_owner="example-org", pr_title="T", pr_body="B", + plan_markdown="# plan", commit_message="add plan", + client_factory=_factory_for(repo), + ) + assert out["created_or_existed"] == "created" + assert out["pr_number"] == 8 + repo.create_git_ref.assert_not_called() # branch reused, not recreated + repo.create_file.assert_called_once() # file created this attempt + repo.create_pull.assert_called_once() # PR opened this attempt From 20dddb52029bc894c86afed14956439a07c62460 Mon Sep 17 00:00:00 2001 From: Roey Zalta Date: Thu, 25 Jun 2026 10:37:33 +0300 Subject: [PATCH 10/17] feat(phase2): planner agent live wiring (Azure OpenAI) --- .../src/planner_agent_worker/activities.py | 3 ++ .../src/planner_agent_worker/agent.py | 44 +++++++++++++++++++ .../tests/test_planner_live.py | 30 +++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 temporal-maf-agents-poc/tests/test_planner_live.py diff --git a/temporal-maf-agents-poc/src/planner_agent_worker/activities.py b/temporal-maf-agents-poc/src/planner_agent_worker/activities.py index d0b7918..a346187 100644 --- a/temporal-maf-agents-poc/src/planner_agent_worker/activities.py +++ b/temporal-maf-agents-poc/src/planner_agent_worker/activities.py @@ -42,6 +42,9 @@ async def run_planner_agent(request: AgentRequest) -> AgentOutput: instructions=agent.INSTRUCTIONS, request=request, mock=agent.mock, + build_prompt=agent.build_prompt, + response_model=agent.RESPONSE_MODEL, + to_output=agent.to_output, ) log.info( diff --git a/temporal-maf-agents-poc/src/planner_agent_worker/agent.py b/temporal-maf-agents-poc/src/planner_agent_worker/agent.py index 0a8239b..925feb9 100644 --- a/temporal-maf-agents-poc/src/planner_agent_worker/agent.py +++ b/temporal-maf-agents-poc/src/planner_agent_worker/agent.py @@ -10,6 +10,10 @@ from __future__ import annotations +from typing import Literal + +from pydantic import BaseModel, Field + from shared.contracts import ( ACTION_CONTINUE, STAGE_PLANNING, @@ -51,3 +55,43 @@ def mock(request: AgentRequest) -> AgentOutput: "steps": plan, }, ) + + +class PlannerResult(BaseModel): + """Structured planning output enforced via Azure OpenAI response_format.""" + + summary: str = Field(description="One-line summary of the plan") + steps: list[str] = Field(description="Ordered, concrete deployment steps") + risk_level: Literal["low", "medium", "high"] + rationale: str = Field(description="Why this plan and risk level") + + +RESPONSE_MODEL = PlannerResult + + +def build_prompt(request: AgentRequest) -> str: + return ( + f"Engineering goal: {request.goal}\n" + f"Target repository: {request.repo_url}\n" + f"Environment: {request.environment}\n\n" + "Produce a concrete, ordered deployment plan and assess its risk." + ) + + +async def to_output(request: AgentRequest, parsed: PlannerResult) -> AgentOutput: + return AgentOutput( + agent_name=AGENT_NAME, + stage=STAGE_PLANNING, + status=STATUS_SUCCESS, + retryable=False, + summary=parsed.summary, + next_action=ACTION_CONTINUE, + details={ + "goal": request.goal, + "repo_url": request.repo_url, + "environment": request.environment, + "steps": parsed.steps, + "risk_level": parsed.risk_level, + "rationale": parsed.rationale, + }, + ) diff --git a/temporal-maf-agents-poc/tests/test_planner_live.py b/temporal-maf-agents-poc/tests/test_planner_live.py new file mode 100644 index 0000000..673f131 --- /dev/null +++ b/temporal-maf-agents-poc/tests/test_planner_live.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from shared.contracts import STATUS_SUCCESS, ACTION_CONTINUE, AgentRequest +from planner_agent_worker import agent + + +def _req(): + return AgentRequest( + request_id="r1", goal="add healthz", repo_url="https://github.com/o/r", + environment="dev", stage="planning", + ) + + +async def test_planner_to_output_maps_to_contract(): + parsed = agent.PlannerResult( + summary="plan ready", steps=["a", "b"], risk_level="low", rationale="because", + ) + out = (await agent.to_output(_req(), parsed)).validate() + assert out.agent_name == "planner" + assert out.status == STATUS_SUCCESS + assert out.next_action == ACTION_CONTINUE + assert out.summary == "plan ready" + assert out.details["steps"] == ["a", "b"] + assert out.details["risk_level"] == "low" + + +def test_planner_build_prompt_includes_goal_and_repo(): + p = agent.build_prompt(_req()) + assert "add healthz" in p + assert "https://github.com/o/r" in p From 0b32fafe2c305dd4ea40a89a799aeffaf7656f1a Mon Sep 17 00:00:00 2001 From: Roey Zalta Date: Thu, 25 Jun 2026 10:40:36 +0300 Subject: [PATCH 11/17] feat(phase2): github agent live wiring (Azure OpenAI + real PR writes) --- .../src/github_agent_worker/activities.py | 3 + .../src/github_agent_worker/agent.py | 70 +++++++++++++++++ .../tests/test_github_live.py | 75 +++++++++++++++++++ 3 files changed, 148 insertions(+) create mode 100644 temporal-maf-agents-poc/tests/test_github_live.py diff --git a/temporal-maf-agents-poc/src/github_agent_worker/activities.py b/temporal-maf-agents-poc/src/github_agent_worker/activities.py index af8121d..a25f353 100644 --- a/temporal-maf-agents-poc/src/github_agent_worker/activities.py +++ b/temporal-maf-agents-poc/src/github_agent_worker/activities.py @@ -23,6 +23,9 @@ async def run_github_agent(request: AgentRequest) -> AgentOutput: instructions=agent.INSTRUCTIONS, request=request, mock=agent.mock, + build_prompt=agent.build_prompt, + response_model=agent.RESPONSE_MODEL, + to_output=agent.to_output, ) log.info( "github agent finished", diff --git a/temporal-maf-agents-poc/src/github_agent_worker/agent.py b/temporal-maf-agents-poc/src/github_agent_worker/agent.py index a19db99..41c16bc 100644 --- a/temporal-maf-agents-poc/src/github_agent_worker/agent.py +++ b/temporal-maf-agents-poc/src/github_agent_worker/agent.py @@ -6,10 +6,18 @@ from __future__ import annotations +import asyncio + +from pydantic import BaseModel, Field + +from shared import github as gh +from shared.config import get_settings from shared.contracts import ( ACTION_CONTINUE, + ACTION_FAIL, STAGE_GITHUB, STAGE_PLANNING, + STATUS_FAILED, STATUS_SUCCESS, AgentOutput, AgentRequest, @@ -44,3 +52,65 @@ def mock(request: AgentRequest) -> AgentOutput: "based_on_plan": bool(planner), }, ) + + +class GitHubChange(BaseModel): + """LLM-authored PR content (the git mechanics are owned by the activity).""" + + pr_title: str = Field(description="Concise PR title") + pr_body_markdown: str = Field(description="PR description in markdown") + plan_file_markdown: str = Field(description="Full content for the committed plan file") + commit_message: str = Field(description="Commit message for the plan file") + + +RESPONSE_MODEL = GitHubChange + + +def build_prompt(request: AgentRequest) -> str: + planner = request.upstream.get(STAGE_PLANNING) + steps = planner.details.get("steps", []) if planner else [] + steps_text = "\n".join(f"- {s}" for s in steps) or "- (no upstream plan)" + return ( + f"Engineering goal: {request.goal}\n" + f"Target repository: {request.repo_url}\n" + f"Environment: {request.environment}\n" + f"Planner steps:\n{steps_text}\n\n" + "Write the pull request title, a markdown PR body, the markdown content " + "for a committed plan file documenting this change, and a commit message." + ) + + +async def to_output(request: AgentRequest, parsed: GitHubChange) -> AgentOutput: + settings = get_settings() + try: + details = await asyncio.to_thread( + gh.create_pr_with_plan, + repo_url=request.repo_url, + request_id=request.request_id, + token=settings.github_token, + allowed_owner=settings.github_allowed_owner, + pr_title=parsed.pr_title, + pr_body=parsed.pr_body_markdown, + plan_markdown=parsed.plan_file_markdown, + commit_message=parsed.commit_message, + ) + except (gh.GitHubWriteNotAllowed, gh.PermanentGitHubError) as exc: + return AgentOutput( + agent_name=AGENT_NAME, + stage=STAGE_GITHUB, + status=STATUS_FAILED, + retryable=False, + summary=f"github write failed: {exc}", + next_action=ACTION_FAIL, + details={"error": str(exc), "error_type": type(exc).__name__}, + ) + + return AgentOutput( + agent_name=AGENT_NAME, + stage=STAGE_GITHUB, + status=STATUS_SUCCESS, + retryable=False, + summary=f"opened pull request #{details['pr_number']} on branch {details['branch']}", + next_action=ACTION_CONTINUE, + details=details, + ) diff --git a/temporal-maf-agents-poc/tests/test_github_live.py b/temporal-maf-agents-poc/tests/test_github_live.py new file mode 100644 index 0000000..bf517fc --- /dev/null +++ b/temporal-maf-agents-poc/tests/test_github_live.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import pytest + +from shared.contracts import ( + ACTION_CONTINUE, ACTION_FAIL, STATUS_FAILED, STATUS_SUCCESS, + STAGE_PLANNING, AgentOutput, AgentRequest, +) +from shared import github as gh +from github_agent_worker import agent + + +def _req(): + return AgentRequest( + request_id="req-1", goal="add healthz", + repo_url="https://github.com/example-org/svc", environment="dev", + stage="github", + upstream={STAGE_PLANNING: AgentOutput( + agent_name="planner", stage="planning", status=STATUS_SUCCESS, + retryable=False, summary="s", next_action=ACTION_CONTINUE, + details={"steps": ["x"]}, + )}, + ) + + +def _change(): + return agent.GitHubChange( + pr_title="Add healthz", pr_body_markdown="body", + plan_file_markdown="# plan", commit_message="add plan", + ) + + +async def test_github_to_output_success(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "t") + monkeypatch.setenv("GITHUB_ALLOWED_OWNER", "example-org") + + def fake_create(**kwargs): + assert kwargs["request_id"] == "req-1" + return {"branch": "feat/req-1", "pr_number": 5, + "pr_url": "https://github.com/example-org/svc/pull/5", + "plan_file": "docs/agent-plan-req-1.md", "created_or_existed": "created"} + + monkeypatch.setattr(gh, "create_pr_with_plan", fake_create) + out = (await agent.to_output(_req(), _change())).validate() + assert out.status == STATUS_SUCCESS + assert out.next_action == ACTION_CONTINUE + assert out.details["pr_number"] == 5 + assert "#5" in out.summary + + +async def test_github_to_output_guard_violation_fails(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "t") + monkeypatch.delenv("GITHUB_ALLOWED_OWNER", raising=False) + + def fake_create(**kwargs): + raise gh.GitHubWriteNotAllowed("fail-closed") + + monkeypatch.setattr(gh, "create_pr_with_plan", fake_create) + out = (await agent.to_output(_req(), _change())).validate() + assert out.status == STATUS_FAILED + assert out.retryable is False + assert out.next_action == ACTION_FAIL + + +async def test_github_to_output_permanent_error_fails(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "t") + monkeypatch.setenv("GITHUB_ALLOWED_OWNER", "example-org") + + def fake_create(**kwargs): + raise gh.PermanentGitHubError("404") + + monkeypatch.setattr(gh, "create_pr_with_plan", fake_create) + out = (await agent.to_output(_req(), _change())).validate() + assert out.status == STATUS_FAILED + assert out.next_action == ACTION_FAIL From 94f400caf58aab0ba107d49876da01b08f6d4c3b Mon Sep 17 00:00:00 2001 From: Roey Zalta Date: Thu, 25 Jun 2026 10:43:15 +0300 Subject: [PATCH 12/17] feat(phase2): approval agent live wiring with risk escalation --- .../src/approval_agent_worker/activities.py | 3 + .../src/approval_agent_worker/agent.py | 70 +++++++++++++++++++ .../tests/test_approval_live.py | 46 ++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 temporal-maf-agents-poc/tests/test_approval_live.py diff --git a/temporal-maf-agents-poc/src/approval_agent_worker/activities.py b/temporal-maf-agents-poc/src/approval_agent_worker/activities.py index 0e26ddc..c7a7861 100644 --- a/temporal-maf-agents-poc/src/approval_agent_worker/activities.py +++ b/temporal-maf-agents-poc/src/approval_agent_worker/activities.py @@ -23,6 +23,9 @@ async def run_approval_agent(request: AgentRequest) -> AgentOutput: instructions=agent.INSTRUCTIONS, request=request, mock=agent.mock, + build_prompt=agent.build_prompt, + response_model=agent.RESPONSE_MODEL, + to_output=agent.to_output, ) log.info( "approval agent classified", diff --git a/temporal-maf-agents-poc/src/approval_agent_worker/agent.py b/temporal-maf-agents-poc/src/approval_agent_worker/agent.py index e83e066..cd4adf2 100644 --- a/temporal-maf-agents-poc/src/approval_agent_worker/agent.py +++ b/temporal-maf-agents-poc/src/approval_agent_worker/agent.py @@ -8,6 +8,10 @@ from __future__ import annotations +from typing import Literal + +from pydantic import BaseModel, Field + from shared.config import get_settings from shared.contracts import ( ACTION_ASK_HUMAN, @@ -69,3 +73,69 @@ def mock(request: AgentRequest) -> AgentOutput: next_action=ACTION_ASK_HUMAN, details=behaviour, ) + + +class ApprovalAssessment(BaseModel): + """LLM risk classification. The workflow still owns the durable human gate.""" + + recommendation: Literal["approve", "reject", "needs_human"] + risk_level: Literal["low", "medium", "high"] + reasons: list[str] = Field(description="Short bullet reasons for the recommendation") + + +RESPONSE_MODEL = ApprovalAssessment + + +def build_prompt(request: AgentRequest) -> str: + aks = request.upstream.get(STAGE_AKS) + aks_pending = bool(aks and aks.details.get("promotion_pending")) + return ( + f"Engineering goal: {request.goal}\n" + f"Environment: {request.environment}\n" + f"Approval required by policy: {request.approval_required}\n" + f"AKS staged a pending promotion: {aks_pending}\n\n" + "Assess deployment risk and recommend approve, reject, or needs_human." + ) + + +async def to_output(request: AgentRequest, parsed: ApprovalAssessment) -> AgentOutput: + settings = get_settings() + aks = request.upstream.get(STAGE_AKS) + aks_pending = bool(aks and aks.details.get("promotion_pending")) + + needs_approval = ( + request.approval_required + or aks_pending + or parsed.recommendation == "needs_human" + or parsed.risk_level == "high" + ) + + details = { + "auto_approve": settings.approval_auto, + "timeout_seconds": settings.approval_timeout_seconds, + "environment": request.environment, + "recommendation": parsed.recommendation, + "risk_level": parsed.risk_level, + "reasons": parsed.reasons, + } + + if not needs_approval: + return AgentOutput( + agent_name=AGENT_NAME, + stage=STAGE_APPROVAL, + status=STATUS_SUCCESS, + retryable=False, + summary="low risk; auto-promoted without human gate", + next_action=ACTION_CONTINUE, + details=details, + ) + + return AgentOutput( + agent_name=AGENT_NAME, + stage=STAGE_APPROVAL, + status=STATUS_NEEDS_APPROVAL, + retryable=False, + summary=f"human approval required to promote to {request.environment}", + next_action=ACTION_ASK_HUMAN, + details=details, + ) diff --git a/temporal-maf-agents-poc/tests/test_approval_live.py b/temporal-maf-agents-poc/tests/test_approval_live.py new file mode 100644 index 0000000..cd394af --- /dev/null +++ b/temporal-maf-agents-poc/tests/test_approval_live.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import pytest + +from shared.contracts import ( + STATUS_NEEDS_APPROVAL, STATUS_SUCCESS, STAGE_AKS, AgentOutput, AgentRequest, +) +from approval_agent_worker import agent + + +def _req(approval_required, aks_pending=False): + upstream = {} + if aks_pending: + upstream[STAGE_AKS] = AgentOutput( + agent_name="aks", stage="aks", status=STATUS_NEEDS_APPROVAL, + retryable=False, summary="staged", next_action="ask_human", + details={"promotion_pending": True}, + ) + return AgentRequest( + request_id="r1", goal="g", repo_url="https://github.com/o/r", + environment="prod" if approval_required else "dev", stage="approval", + approval_required=approval_required, upstream=upstream, + ) + + +def _assess(recommendation, risk): + return agent.ApprovalAssessment( + recommendation=recommendation, risk_level=risk, reasons=["r"], + ) + + +@pytest.mark.parametrize("required,aks,reco,risk,expected", [ + (True, False, "approve", "low", STATUS_NEEDS_APPROVAL), # required by config + (False, True, "approve", "low", STATUS_NEEDS_APPROVAL), # AKS staged a promotion + (False, False, "needs_human", "low", STATUS_NEEDS_APPROVAL), # LLM asks for a human + (False, False, "approve", "high", STATUS_NEEDS_APPROVAL), # LLM escalates on risk + (False, False, "approve", "low", STATUS_SUCCESS), # low-risk, not required +]) +async def test_approval_escalation_matrix(monkeypatch, required, aks, reco, risk, expected): + monkeypatch.setenv("TEMPORAL_APPROVAL_AUTO", "true") + out = (await agent.to_output(_req(required, aks), _assess(reco, risk))).validate() + assert out.status == expected + # The workflow reads these regardless of branch: + assert "auto_approve" in out.details + assert "timeout_seconds" in out.details + assert out.details["recommendation"] == reco From 1e0af48ece9d1bade6708ada550a662164d44752 Mon Sep 17 00:00:00 2001 From: Roey Zalta Date: Thu, 25 Jun 2026 10:45:43 +0300 Subject: [PATCH 13/17] test(phase2): lock AKS agent to mock even under AGENT_MODE=live --- .../tests/test_aks_stays_mock.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 temporal-maf-agents-poc/tests/test_aks_stays_mock.py diff --git a/temporal-maf-agents-poc/tests/test_aks_stays_mock.py b/temporal-maf-agents-poc/tests/test_aks_stays_mock.py new file mode 100644 index 0000000..9404aa2 --- /dev/null +++ b/temporal-maf-agents-poc/tests/test_aks_stays_mock.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from shared.contracts import STATUS_NEEDS_APPROVAL, STATUS_SUCCESS, AgentRequest +from aks_agent_worker import agent +from aks_agent_worker.activities import run_aks_agent + + +def _req(approval_required=True): + return AgentRequest( + request_id="r1", goal="g", repo_url="https://github.com/o/r", + environment="dev", stage="aks", approval_required=approval_required, + ) + + +def test_aks_module_has_no_live_wiring(): + # AKS must NOT expose a response model / to_output -> run_agent stays mock. + assert not hasattr(agent, "RESPONSE_MODEL") + assert not hasattr(agent, "to_output") + + +async def test_aks_activity_stays_mock_even_in_live_mode(monkeypatch): + monkeypatch.setenv("AGENT_MODE", "live") + out = await run_aks_agent(_req(approval_required=True)) + # Deterministic mock behaviour (needs_approval when approval_required). + assert out.agent_name == "aks" + assert out.status == STATUS_NEEDS_APPROVAL From 8ea8865bae91052f13cedcf9a271669889060f80 Mon Sep 17 00:00:00 2001 From: Roey Zalta Date: Thu, 25 Jun 2026 10:48:23 +0300 Subject: [PATCH 14/17] feat(phase2): live Docker build arg + k8s secret wiring --- temporal-maf-agents-poc/Dockerfile | 9 +++++++-- .../k8s/deployments/approval-agent-worker.yaml | 4 ++++ .../k8s/deployments/github-agent-worker.yaml | 4 ++++ .../k8s/deployments/planner-agent-worker.yaml | 4 ++++ .../k8s/secrets/live-agents-secret.example.yaml | 16 ++++++++++++++++ 5 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 temporal-maf-agents-poc/k8s/secrets/live-agents-secret.example.yaml diff --git a/temporal-maf-agents-poc/Dockerfile b/temporal-maf-agents-poc/Dockerfile index 8d184a0..70a8e2c 100644 --- a/temporal-maf-agents-poc/Dockerfile +++ b/temporal-maf-agents-poc/Dockerfile @@ -12,8 +12,13 @@ ENV PYTHONUNBUFFERED=1 \ WORKDIR /app # Install deps first for layer caching. -COPY requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt +ARG INSTALL_LIVE=false +COPY requirements.txt requirements-live.txt ./ +RUN if [ "$INSTALL_LIVE" = "true" ]; then \ + pip install --no-cache-dir -r requirements-live.txt; \ + else \ + pip install --no-cache-dir -r requirements.txt; \ + fi # App source. COPY src ./src diff --git a/temporal-maf-agents-poc/k8s/deployments/approval-agent-worker.yaml b/temporal-maf-agents-poc/k8s/deployments/approval-agent-worker.yaml index 009ffea..035ebad 100644 --- a/temporal-maf-agents-poc/k8s/deployments/approval-agent-worker.yaml +++ b/temporal-maf-agents-poc/k8s/deployments/approval-agent-worker.yaml @@ -27,9 +27,13 @@ spec: envFrom: - configMapRef: name: temporal-config + - secretRef: + name: live-agents-secret env: - name: WORKER_MODULE value: approval_agent_worker.worker + - name: AGENT_MODE + value: live ports: - name: health containerPort: 8080 diff --git a/temporal-maf-agents-poc/k8s/deployments/github-agent-worker.yaml b/temporal-maf-agents-poc/k8s/deployments/github-agent-worker.yaml index 9971492..34ab0bb 100644 --- a/temporal-maf-agents-poc/k8s/deployments/github-agent-worker.yaml +++ b/temporal-maf-agents-poc/k8s/deployments/github-agent-worker.yaml @@ -27,9 +27,13 @@ spec: envFrom: - configMapRef: name: temporal-config + - secretRef: + name: live-agents-secret env: - name: WORKER_MODULE value: github_agent_worker.worker + - name: AGENT_MODE + value: live ports: - name: health containerPort: 8080 diff --git a/temporal-maf-agents-poc/k8s/deployments/planner-agent-worker.yaml b/temporal-maf-agents-poc/k8s/deployments/planner-agent-worker.yaml index 1ed461f..36a6141 100644 --- a/temporal-maf-agents-poc/k8s/deployments/planner-agent-worker.yaml +++ b/temporal-maf-agents-poc/k8s/deployments/planner-agent-worker.yaml @@ -27,9 +27,13 @@ spec: envFrom: - configMapRef: name: temporal-config + - secretRef: + name: live-agents-secret env: - name: WORKER_MODULE value: planner_agent_worker.worker + - name: AGENT_MODE + value: live ports: - name: health containerPort: 8080 diff --git a/temporal-maf-agents-poc/k8s/secrets/live-agents-secret.example.yaml b/temporal-maf-agents-poc/k8s/secrets/live-agents-secret.example.yaml new file mode 100644 index 0000000..cb952ef --- /dev/null +++ b/temporal-maf-agents-poc/k8s/secrets/live-agents-secret.example.yaml @@ -0,0 +1,16 @@ +# Copy to live-agents-secret.yaml, fill in real values, and `kubectl apply -f` it. +# Keyless Azure (AKS workload identity) is preferred: omit AZURE_OPENAI_API_KEY and +# annotate the worker ServiceAccount for workload identity instead. +apiVersion: v1 +kind: Secret +metadata: + name: live-agents-secret + namespace: agent-platform +type: Opaque +stringData: + AZURE_OPENAI_ENDPOINT: "https://.openai.azure.com" + AZURE_OPENAI_CHAT_DEPLOYMENT: "gpt-4o" + AZURE_OPENAI_API_VERSION: "2024-10-21" + # AZURE_OPENAI_API_KEY: "" # omit to use workload identity + GITHUB_TOKEN: "" + GITHUB_ALLOWED_OWNER: "example-org" From 09e2b8cca76282816a23b053df189602d7fb0694 Mon Sep 17 00:00:00 2001 From: Roey Zalta Date: Thu, 25 Jun 2026 10:50:33 +0300 Subject: [PATCH 15/17] docs(phase2): note live-agents-secret prerequisite in deployments --- .../k8s/deployments/approval-agent-worker.yaml | 1 + temporal-maf-agents-poc/k8s/deployments/github-agent-worker.yaml | 1 + .../k8s/deployments/planner-agent-worker.yaml | 1 + 3 files changed, 3 insertions(+) diff --git a/temporal-maf-agents-poc/k8s/deployments/approval-agent-worker.yaml b/temporal-maf-agents-poc/k8s/deployments/approval-agent-worker.yaml index 035ebad..589eee9 100644 --- a/temporal-maf-agents-poc/k8s/deployments/approval-agent-worker.yaml +++ b/temporal-maf-agents-poc/k8s/deployments/approval-agent-worker.yaml @@ -27,6 +27,7 @@ spec: envFrom: - configMapRef: name: temporal-config + # Requires the live-agents-secret — create it first from k8s/secrets/live-agents-secret.example.yaml - secretRef: name: live-agents-secret env: diff --git a/temporal-maf-agents-poc/k8s/deployments/github-agent-worker.yaml b/temporal-maf-agents-poc/k8s/deployments/github-agent-worker.yaml index 34ab0bb..5f43b66 100644 --- a/temporal-maf-agents-poc/k8s/deployments/github-agent-worker.yaml +++ b/temporal-maf-agents-poc/k8s/deployments/github-agent-worker.yaml @@ -27,6 +27,7 @@ spec: envFrom: - configMapRef: name: temporal-config + # Requires the live-agents-secret — create it first from k8s/secrets/live-agents-secret.example.yaml - secretRef: name: live-agents-secret env: diff --git a/temporal-maf-agents-poc/k8s/deployments/planner-agent-worker.yaml b/temporal-maf-agents-poc/k8s/deployments/planner-agent-worker.yaml index 36a6141..7db4ee5 100644 --- a/temporal-maf-agents-poc/k8s/deployments/planner-agent-worker.yaml +++ b/temporal-maf-agents-poc/k8s/deployments/planner-agent-worker.yaml @@ -27,6 +27,7 @@ spec: envFrom: - configMapRef: name: temporal-config + # Requires the live-agents-secret — create it first from k8s/secrets/live-agents-secret.example.yaml - secretRef: name: live-agents-secret env: From dcb316e7221d44ff5e0c0764c20f30b36fca96e8 Mon Sep 17 00:00:00 2001 From: Roey Zalta Date: Thu, 25 Jun 2026 10:52:30 +0300 Subject: [PATCH 16/17] docs(phase2): live-mode README, .env.example, opt-in live smoke test --- temporal-maf-agents-poc/.env.example | 17 ++++--- temporal-maf-agents-poc/README.md | 46 +++++++++++++------ .../tests/test_live_smoke.py | 42 +++++++++++++++++ 3 files changed, 85 insertions(+), 20 deletions(-) create mode 100644 temporal-maf-agents-poc/tests/test_live_smoke.py diff --git a/temporal-maf-agents-poc/.env.example b/temporal-maf-agents-poc/.env.example index 7fcefc9..a346423 100644 --- a/temporal-maf-agents-poc/.env.example +++ b/temporal-maf-agents-poc/.env.example @@ -27,9 +27,14 @@ HEALTH_PORT=8080 # FORCE_TRANSIENT_ERROR=1 # --------------------------------------------------------------------------- -# Phase 2 only — real integrations (TODO). All consumed inside activities. -# --------------------------------------------------------------------------- -# AZURE_OPENAI_ENDPOINT=https://.openai.azure.com/ -# AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=gpt-4o -# GITHUB_TOKEN= -# KUBECONFIG= +# Phase 2 — live integrations (consumed inside activities only) +# Set AGENT_MODE=live above to enable. +# --------------------------------------------------------------------------- +AZURE_OPENAI_ENDPOINT=https://.openai.azure.com +AZURE_OPENAI_CHAT_DEPLOYMENT=gpt-4o +AZURE_OPENAI_API_VERSION=2024-10-21 +# Leave AZURE_OPENAI_API_KEY unset to use DefaultAzureCredential (az login / workload identity) +# AZURE_OPENAI_API_KEY= +GITHUB_TOKEN= +# Required in live mode (fail-closed): the owner the GitHub agent may write to +GITHUB_ALLOWED_OWNER= diff --git a/temporal-maf-agents-poc/README.md b/temporal-maf-agents-poc/README.md index 759b5c5..a59e4ef 100644 --- a/temporal-maf-agents-poc/README.md +++ b/temporal-maf-agents-poc/README.md @@ -250,19 +250,37 @@ Suggested metrics to scrape next (Temporal SDK + KEDA both export Prometheus): --- -## Phase 2 — going live - -Replace the mocks with real integrations **inside activities only**: - -1. Implement `shared/maf.run_live_agent()` — build a real MAF agent - (`AzureOpenAIChatClient().as_agent(...)`, `await agent.run(prompt)`), parse - its output into the `AgentOutput` contract, and register the per-stage tools - (GitHub API / Kubernetes API / Azure) as MAF tools or MCP servers. -2. Install the `live` extras: `pip install -e ".[live]"`. -3. Set `AGENT_MODE=live` and the relevant Azure/GitHub/Kubernetes env vars. - -Workflow code does **not** change — Temporal keeps orchestrating; only the -activity bodies gain real side effects. +## Phase 2 — going live (Azure OpenAI + GitHub) + +The planner, github, and approval agents run real Azure OpenAI reasoning (via +Microsoft Agent Framework) and the github agent makes real GitHub writes. The +AKS agent stays mock. + +1. Install live deps: `pip install -r requirements-live.txt` (or `pip install -e ".[live]"`). +2. Set env (see `.env.example`): + - `AGENT_MODE=live` + - `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_CHAT_DEPLOYMENT`, `AZURE_OPENAI_API_VERSION` + - Azure auth: set `AZURE_OPENAI_API_KEY`, **or** leave it unset and use + `DefaultAzureCredential` (`az login` locally / workload identity on AKS). + - `GITHUB_TOKEN` (fine-grained PAT) and `GITHUB_ALLOWED_OWNER` (the github + agent refuses to write unless the target repo's owner matches — fail-closed). +3. Run workers + `python -m starter` as in Phase 1. + +What the github agent does: creates branch `feat/`, commits +`docs/agent-plan-.md`, and opens a PR. All writes are idempotent, so +Temporal activity retries converge instead of duplicating. + +Error handling: transient Azure/GitHub errors (5xx, rate limit, timeout, or a +schema-invalid model response) are raised and retried by Temporal's activity +retry policy; permanent errors (auth, missing repo, validation, guard violation) +fail the workflow without pointless retries. + +Live on AKS: build the live image with `--build-arg INSTALL_LIVE=true`, apply +`k8s/secrets/live-agents-secret.yaml`, and use the updated planner/github/approval +deployments (which set `AGENT_MODE=live` and mount the secret). + +Tests: `pytest` runs everything with mocked clients (no creds). The opt-in live +smoke test runs only with `RUN_LIVE_SMOKE=1` + real Azure env. --- @@ -277,4 +295,4 @@ activity bodies gain real side effects. - [x] AKS manifests exist (`k8s/deployments/`) - [x] KEDA manifests exist (`k8s/keda/`) - [x] README with local and AKS deployment instructions -- [ ] Phase 2 real integrations (TODO stubs in place) +- [x] Phase 2 real integrations — Azure OpenAI + GitHub live (AKS still mock) diff --git a/temporal-maf-agents-poc/tests/test_live_smoke.py b/temporal-maf-agents-poc/tests/test_live_smoke.py new file mode 100644 index 0000000..a8f901d --- /dev/null +++ b/temporal-maf-agents-poc/tests/test_live_smoke.py @@ -0,0 +1,42 @@ +"""Opt-in live smoke test. Skipped unless real creds + RUN_LIVE_SMOKE=1. + +Run with: + RUN_LIVE_SMOKE=1 AGENT_MODE=live \ + AZURE_OPENAI_ENDPOINT=... AZURE_OPENAI_CHAT_DEPLOYMENT=... \ + GITHUB_TOKEN=... GITHUB_ALLOWED_OWNER= \ + PYTHONPATH=src pytest tests/test_live_smoke.py -v +""" + +from __future__ import annotations + +import os + +import pytest + +from shared.contracts import STATUS_SUCCESS, AgentRequest +from planner_agent_worker import agent as planner + +pytestmark = pytest.mark.skipif( + os.getenv("RUN_LIVE_SMOKE") != "1" + or not os.getenv("AZURE_OPENAI_ENDPOINT") + or not os.getenv("AZURE_OPENAI_CHAT_DEPLOYMENT"), + reason="live smoke disabled (set RUN_LIVE_SMOKE=1 + Azure env to enable)", +) + + +async def test_planner_round_trip_against_azure_openai(monkeypatch): + monkeypatch.setenv("AGENT_MODE", "live") + from shared.maf import run_agent + + req = AgentRequest( + request_id="smoke-1", goal="add a /healthz endpoint", + repo_url=f"https://github.com/{os.getenv('GITHUB_ALLOWED_OWNER','example-org')}/svc", + environment="dev", stage="planning", + ) + out = await run_agent( + agent_name=planner.AGENT_NAME, stage="planning", instructions=planner.INSTRUCTIONS, + request=req, mock=planner.mock, build_prompt=planner.build_prompt, + response_model=planner.RESPONSE_MODEL, to_output=planner.to_output, + ) + assert out.status == STATUS_SUCCESS + assert out.details["steps"] From 11b17aa8f2c09cd8de662acf3c1892137790a3da Mon Sep 17 00:00:00 2001 From: Roey Zalta Date: Thu, 25 Jun 2026 11:02:12 +0300 Subject: [PATCH 17/17] fix(phase2): treat GitHub rate-limit (403/429) as transient; drop unused import RateLimitExceededException (PyGithub's 403/429 rate-limit type) is a subclass of GithubException. Catching it explicitly before the generic GithubException handler ensures rate-limit errors propagate as transient so Temporal retries them, rather than being wrapped as PermanentGitHubError and aborting the workflow. Also removes the unused STATUS_SUCCESS import in test_aks_stays_mock.py. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01U8d6DuACgKfMFiFbaQ2AFQ --- temporal-maf-agents-poc/src/shared/github.py | 6 ++++-- temporal-maf-agents-poc/tests/test_aks_stays_mock.py | 2 +- temporal-maf-agents-poc/tests/test_github_client.py | 12 ++++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/temporal-maf-agents-poc/src/shared/github.py b/temporal-maf-agents-poc/src/shared/github.py index 7adb520..e2a386d 100644 --- a/temporal-maf-agents-poc/src/shared/github.py +++ b/temporal-maf-agents-poc/src/shared/github.py @@ -72,7 +72,7 @@ def create_pr_with_plan( Raises GitHubWriteNotAllowed / PermanentGitHubError for permanent failures; lets transient GithubException (5xx / rate limit) propagate for retry. """ - from github import GithubException # type: ignore + from github import GithubException, RateLimitExceededException # type: ignore owner, repo_name = parse_owner_repo(repo_url) assert_write_allowed(owner, allowed_owner) @@ -124,7 +124,9 @@ def create_pr_with_plan( "created_or_existed": created_or_existed, } + except RateLimitExceededException: + raise # rate limited (403/429) -> transient -> Temporal retry except GithubException as exc: if getattr(exc, "status", None) in _PERMANENT_STATUS: raise PermanentGitHubError(f"GitHub {exc.status}: {exc.data}") from exc - raise # transient (5xx, secondary rate limit) -> Temporal retry + raise # transient (5xx) -> Temporal retry diff --git a/temporal-maf-agents-poc/tests/test_aks_stays_mock.py b/temporal-maf-agents-poc/tests/test_aks_stays_mock.py index 9404aa2..4ff1253 100644 --- a/temporal-maf-agents-poc/tests/test_aks_stays_mock.py +++ b/temporal-maf-agents-poc/tests/test_aks_stays_mock.py @@ -1,6 +1,6 @@ from __future__ import annotations -from shared.contracts import STATUS_NEEDS_APPROVAL, STATUS_SUCCESS, AgentRequest +from shared.contracts import STATUS_NEEDS_APPROVAL, AgentRequest from aks_agent_worker import agent from aks_agent_worker.activities import run_aks_agent diff --git a/temporal-maf-agents-poc/tests/test_github_client.py b/temporal-maf-agents-poc/tests/test_github_client.py index 894e7c0..e0121e6 100644 --- a/temporal-maf-agents-poc/tests/test_github_client.py +++ b/temporal-maf-agents-poc/tests/test_github_client.py @@ -133,3 +133,15 @@ def test_create_pr_partial_retry_branch_exists_file_absent(): repo.create_git_ref.assert_not_called() # branch reused, not recreated repo.create_file.assert_called_once() # file created this attempt repo.create_pull.assert_called_once() # PR opened this attempt + + +def test_rate_limit_propagates_as_transient(): + from github import RateLimitExceededException + gh_client = MagicMock() + gh_client.get_repo.side_effect = RateLimitExceededException(403, {"message": "rate limited"}, {}) + with pytest.raises(RateLimitExceededException): + gh.create_pr_with_plan( + repo_url="https://github.com/example-org/svc", request_id="req-1", + token="t", allowed_owner="example-org", pr_title="T", pr_body="B", + plan_markdown="x", commit_message="m", client_factory=lambda token: gh_client, + )