a2a-source-edit: write agent.py
This commit is contained in:
931
agent.py
931
agent.py
@@ -1,189 +1,832 @@
|
||||
"""support-to-fix-engineer agent.
|
||||
|
||||
Starter stack:
|
||||
- DeepAgents for tool-calling orchestration
|
||||
- Caller-provided LLM credentials via ctx.llm
|
||||
- A tiny model-call middleware hook you can replace with tracing,
|
||||
routing, rate limits, or policy checks
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import a2a_pack as a2a
|
||||
from a2a_pack import (
|
||||
A2AAgent,
|
||||
LLMProvisioning,
|
||||
{{ auth_type }},
|
||||
ConsumerSetup,
|
||||
ConsumerSetupField,
|
||||
EgressPolicy,
|
||||
NoAuth,
|
||||
Pricing,
|
||||
Resources,
|
||||
RunContext,
|
||||
WorkspaceAccess,
|
||||
WorkspaceMode,
|
||||
)
|
||||
from a2a_pack.context import LLMCreds
|
||||
from a2a_pack.context import AgentEvent
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
APPROVAL_ACK = "I_APPROVE_THE_LISTED_STEPS"
|
||||
OUTPUT_ROOT = "outputs/support-cases"
|
||||
MAX_TEXT_CHARS = 24000
|
||||
MAX_EVIDENCE_ITEMS = 80
|
||||
MAX_PATHS = 40
|
||||
|
||||
|
||||
class SupportToFixEngineerConfig(BaseModel):
|
||||
pass
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
max_log_chars: int = Field(default=12000, ge=1000, le=50000)
|
||||
default_dry_run: bool = True
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """\
|
||||
You are a compact tool-calling agent.
|
||||
|
||||
Use the text_stats tool when the user asks about text, counts, summaries,
|
||||
or anything where exact length/word numbers would help. Mention tool results
|
||||
briefly instead of dumping raw JSON.
|
||||
"""
|
||||
|
||||
RUNTIME_SKILLS_DIR = "support-to-fix-engineer/.deepagents/skills/"
|
||||
DEEPAGENTS_RECURSION_LIMIT = 500
|
||||
class StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", use_enum_values=True)
|
||||
|
||||
|
||||
class SupportToFixEngineer(A2AAgent[SupportToFixEngineerConfig, {{ auth_type }}]):
|
||||
class Severity(str, Enum):
|
||||
sev1 = "sev1"
|
||||
sev2 = "sev2"
|
||||
sev3 = "sev3"
|
||||
sev4 = "sev4"
|
||||
|
||||
|
||||
class EvidenceKind(str, Enum):
|
||||
ticket = "ticket"
|
||||
log = "log"
|
||||
trace = "trace"
|
||||
release = "release"
|
||||
change = "change"
|
||||
customer = "customer"
|
||||
health_check = "health_check"
|
||||
repository = "repository"
|
||||
validation = "validation"
|
||||
|
||||
|
||||
class DeliveryAction(str, Enum):
|
||||
draft_branch = "draft_branch"
|
||||
draft_commit = "draft_commit"
|
||||
draft_pr = "draft_pr"
|
||||
customer_summary = "customer_summary"
|
||||
|
||||
|
||||
class EvidenceItem(StrictModel):
|
||||
source_id: str = Field(min_length=1, max_length=160)
|
||||
kind: EvidenceKind
|
||||
summary: str = Field(min_length=1, max_length=1200)
|
||||
timestamp: str | None = Field(default=None, max_length=80)
|
||||
url: str | None = Field(default=None, max_length=2048)
|
||||
excerpt: str | None = Field(default=None, max_length=4000)
|
||||
|
||||
@field_validator("source_id", "summary", "excerpt")
|
||||
@classmethod
|
||||
def _redact_text_fields(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return redact_secrets(value)[:4000]
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def _validate_url(cls, value: str | None) -> str | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
return validate_safe_url(value, allowed_hosts=())
|
||||
|
||||
|
||||
class HttpCheck(StrictModel):
|
||||
name: str = Field(min_length=1, max_length=80)
|
||||
url: str = Field(min_length=8, max_length=2048)
|
||||
expected_status: int = Field(default=200, ge=100, le=599)
|
||||
|
||||
|
||||
class RepoFileChange(StrictModel):
|
||||
path: str = Field(min_length=1, max_length=240)
|
||||
reason: str = Field(min_length=1, max_length=800)
|
||||
proposed_content: str | None = Field(default=None, max_length=20000)
|
||||
|
||||
@field_validator("path")
|
||||
@classmethod
|
||||
def _safe_path(cls, value: str) -> str:
|
||||
return safe_repo_path(value)
|
||||
|
||||
@field_validator("proposed_content")
|
||||
@classmethod
|
||||
def _redact_content(cls, value: str | None) -> str | None:
|
||||
return redact_secrets(value) if value is not None else None
|
||||
|
||||
|
||||
class TriageCaseInput(StrictModel):
|
||||
case_id: str = Field(min_length=3, max_length=80, pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{2,79}$")
|
||||
customer_id: str = Field(min_length=1, max_length=120)
|
||||
tenant_id: str = Field(min_length=1, max_length=120)
|
||||
release: str = Field(min_length=1, max_length=120)
|
||||
ticket_text: str = Field(min_length=1, max_length=MAX_TEXT_CHARS)
|
||||
evidence: list[EvidenceItem] = Field(default_factory=list, max_length=MAX_EVIDENCE_ITEMS)
|
||||
affected_services: list[str] = Field(default_factory=list, max_length=20)
|
||||
recent_changes: list[str] = Field(default_factory=list, max_length=40)
|
||||
dry_run: bool = True
|
||||
|
||||
@field_validator("ticket_text")
|
||||
@classmethod
|
||||
def _redact_ticket(cls, value: str) -> str:
|
||||
return redact_secrets(value)
|
||||
|
||||
@field_validator("affected_services", "recent_changes")
|
||||
@classmethod
|
||||
def _bounded_strings(cls, values: list[str]) -> list[str]:
|
||||
return [redact_secrets(v.strip())[:500] for v in values if v.strip()]
|
||||
|
||||
|
||||
class ReproduceIssueInput(StrictModel):
|
||||
case_id: str = Field(min_length=3, max_length=80, pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{2,79}$")
|
||||
redacted_symptom: str = Field(min_length=1, max_length=8000)
|
||||
environment: dict[str, str] = Field(default_factory=dict, max_length=30)
|
||||
observed_errors: list[str] = Field(default_factory=list, max_length=40)
|
||||
candidate_files: list[str] = Field(default_factory=list, max_length=MAX_PATHS)
|
||||
health_checks: list[HttpCheck] = Field(default_factory=list, max_length=20)
|
||||
allowed_health_hosts: list[str] = Field(default_factory=list, max_length=30)
|
||||
dry_run: bool = True
|
||||
|
||||
@field_validator("candidate_files")
|
||||
@classmethod
|
||||
def _safe_paths(cls, values: list[str]) -> list[str]:
|
||||
return [safe_repo_path(v) for v in values]
|
||||
|
||||
@field_validator("redacted_symptom")
|
||||
@classmethod
|
||||
def _redact_symptom(cls, value: str) -> str:
|
||||
return redact_secrets(value)
|
||||
|
||||
|
||||
class ProposeFixInput(StrictModel):
|
||||
case_id: str = Field(min_length=3, max_length=80, pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{2,79}$")
|
||||
reproduction_summary: str = Field(min_length=1, max_length=12000)
|
||||
hypotheses: list[str] = Field(default_factory=list, max_length=20)
|
||||
supporting_evidence: list[EvidenceItem] = Field(default_factory=list, max_length=MAX_EVIDENCE_ITEMS)
|
||||
contradicting_evidence: list[EvidenceItem] = Field(default_factory=list, max_length=MAX_EVIDENCE_ITEMS)
|
||||
proposed_changes: list[RepoFileChange] = Field(default_factory=list, max_length=MAX_PATHS)
|
||||
repository: str | None = Field(default=None, max_length=200)
|
||||
dry_run: bool = True
|
||||
|
||||
@field_validator("reproduction_summary")
|
||||
@classmethod
|
||||
def _redact_repro(cls, value: str) -> str:
|
||||
return redact_secrets(value)
|
||||
|
||||
|
||||
class ValidateFixInput(StrictModel):
|
||||
case_id: str = Field(min_length=3, max_length=80, pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{2,79}$")
|
||||
patch_digest: str = Field(min_length=16, max_length=128)
|
||||
patch_diff: str = Field(min_length=1, max_length=60000)
|
||||
test_selectors: list[str] = Field(default_factory=list, max_length=40)
|
||||
lint_selectors: list[str] = Field(default_factory=list, max_length=20)
|
||||
security_checks: list[str] = Field(default_factory=list, max_length=20)
|
||||
dry_run: bool = True
|
||||
|
||||
@field_validator("test_selectors", "lint_selectors", "security_checks")
|
||||
@classmethod
|
||||
def _selectors(cls, values: list[str]) -> list[str]:
|
||||
cleaned: list[str] = []
|
||||
for value in values:
|
||||
item = value.strip()
|
||||
if not item:
|
||||
continue
|
||||
if re.search(r"[;&|`$<>]", item):
|
||||
raise ValueError("selectors must not contain shell metacharacters")
|
||||
cleaned.append(item[:180])
|
||||
return cleaned
|
||||
|
||||
@field_validator("patch_diff")
|
||||
@classmethod
|
||||
def _patch_without_secrets(cls, value: str) -> str:
|
||||
return redact_secrets(value)
|
||||
|
||||
|
||||
class PrepareDeliveryInput(StrictModel):
|
||||
case_id: str = Field(min_length=3, max_length=80, pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{2,79}$")
|
||||
validation_digest: str = Field(min_length=16, max_length=128)
|
||||
patch_digest: str = Field(min_length=16, max_length=128)
|
||||
repository: str = Field(min_length=1, max_length=200)
|
||||
target_branch: str = Field(default="main", min_length=1, max_length=120)
|
||||
proposed_branch: str = Field(default="support-fix", min_length=1, max_length=120)
|
||||
delivery_actions: list[DeliveryAction] = Field(default_factory=lambda: [DeliveryAction.customer_summary], max_length=4)
|
||||
customer_summary_context: str = Field(default="", max_length=8000)
|
||||
dry_run: bool = True
|
||||
approval_acknowledgement: str | None = Field(default=None, max_length=80)
|
||||
approval_plan_digest: str | None = Field(default=None, max_length=128)
|
||||
|
||||
@field_validator("repository", "target_branch", "proposed_branch")
|
||||
@classmethod
|
||||
def _safe_refish(cls, value: str) -> str:
|
||||
if re.search(r"[\s;&|`$<>]", value):
|
||||
raise ValueError("repository and branch values must not contain whitespace or shell metacharacters")
|
||||
return value
|
||||
|
||||
|
||||
class ArtifactRecord(StrictModel):
|
||||
path: str
|
||||
artifact_uri: str | None = None
|
||||
sha256: str
|
||||
size_bytes: int = Field(ge=0)
|
||||
|
||||
|
||||
class AuditRecord(StrictModel):
|
||||
event: str
|
||||
at: str
|
||||
case_id: str
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class TriageCaseOutput(StrictModel):
|
||||
status: str
|
||||
case_id: str
|
||||
severity: Severity
|
||||
owner: str
|
||||
redaction_count: int = Field(ge=0)
|
||||
missing_setup: list[str]
|
||||
allowed_tenant: bool
|
||||
plan_digest: str
|
||||
evidence_index: list[EvidenceItem]
|
||||
artifacts: list[ArtifactRecord]
|
||||
audit: list[AuditRecord]
|
||||
warnings: list[str]
|
||||
|
||||
|
||||
class ReproduceIssueOutput(StrictModel):
|
||||
status: str
|
||||
case_id: str
|
||||
reproduction_digest: str
|
||||
minimal_steps: list[str]
|
||||
safe_health_checks: list[HttpCheck]
|
||||
blocked_health_checks: list[str]
|
||||
artifacts: list[ArtifactRecord]
|
||||
audit: list[AuditRecord]
|
||||
warnings: list[str]
|
||||
|
||||
|
||||
class HypothesisRank(StrictModel):
|
||||
hypothesis: str
|
||||
rank: int = Field(ge=1)
|
||||
confidence: float = Field(ge=0.0, le=1.0)
|
||||
supporting_source_ids: list[str]
|
||||
contradicting_source_ids: list[str]
|
||||
|
||||
|
||||
class ProposeFixOutput(StrictModel):
|
||||
status: str
|
||||
case_id: str
|
||||
repository: str | None
|
||||
patch_digest: str
|
||||
plan_digest: str
|
||||
hypotheses: list[HypothesisRank]
|
||||
scoped_paths: list[str]
|
||||
artifacts: list[ArtifactRecord]
|
||||
audit: list[AuditRecord]
|
||||
warnings: list[str]
|
||||
|
||||
|
||||
class ValidateFixOutput(StrictModel):
|
||||
status: str
|
||||
case_id: str
|
||||
validation_digest: str
|
||||
patch_digest: str
|
||||
checks: dict[str, str]
|
||||
artifacts: list[ArtifactRecord]
|
||||
audit: list[AuditRecord]
|
||||
warnings: list[str]
|
||||
|
||||
|
||||
class PrepareDeliveryOutput(StrictModel):
|
||||
status: str
|
||||
case_id: str
|
||||
plan_digest: str
|
||||
approval_required: bool
|
||||
approved: bool
|
||||
executed_actions: list[str]
|
||||
blocked_actions: list[str]
|
||||
artifacts: list[ArtifactRecord]
|
||||
audit: list[AuditRecord]
|
||||
warnings: list[str]
|
||||
|
||||
|
||||
class SupportToFixEngineer(A2AAgent[SupportToFixEngineerConfig, NoAuth]):
|
||||
name = "support-to-fix-engineer"
|
||||
description = "Turns an authenticated support case into evidence-backed engineering fix artifacts with approval-gated repository delivery."
|
||||
description = (
|
||||
"Turns authenticated support cases into evidence-backed engineering fix artifacts "
|
||||
"with deterministic triage, reproduction, patch proposal, validation, and approval-gated delivery."
|
||||
)
|
||||
version = "0.1.0"
|
||||
|
||||
config_model = SupportToFixEngineerConfig
|
||||
auth_model = {{ auth_type }}
|
||||
auth_model = NoAuth
|
||||
|
||||
# Hosted generated agents read the caller's saved LLM credential through
|
||||
# ctx.llm. The platform may proxy that credential through LiteLLM, but agent
|
||||
# code never reads provider keys, LiteLLM master keys, or OPENAI_API_KEY
|
||||
# directly.
|
||||
llm_provisioning = LLMProvisioning.PLATFORM
|
||||
pricing = Pricing(
|
||||
price_per_call_usd=0.0,
|
||||
caller_pays_llm=True,
|
||||
notes="Starter agent uses the caller's saved LLM credential via ctx.llm.",
|
||||
caller_pays_llm=False,
|
||||
notes="Deterministic policy and artifact generation; no LLM credential required.",
|
||||
)
|
||||
resources = Resources(cpu="1", memory="512Mi", max_runtime_seconds=600)
|
||||
workspace_access = WorkspaceAccess.dynamic(
|
||||
max_files=64,
|
||||
max_files=160,
|
||||
allowed_modes=(WorkspaceMode.READ_ONLY, WorkspaceMode.READ_WRITE_OVERLAY),
|
||||
require_reason=False,
|
||||
require_reason=True,
|
||||
deny_patterns=("**/.env", "**/.env.*", "**/*secret*", "**/*credential*", "**/id_rsa", "**/id_ed25519"),
|
||||
require_human_approval=True,
|
||||
max_total_size_bytes=50 * 1024 * 1024,
|
||||
)
|
||||
tools_used = ("deepagents", "langchain")
|
||||
consumer_setup = ConsumerSetup.from_fields(
|
||||
ConsumerSetupField.config("GITEA_BASE_URL", label="Gitea base URL", input_type="url", required=False),
|
||||
ConsumerSetupField.secret("GITEA_TOKEN", label="Gitea token", required=False),
|
||||
ConsumerSetupField.config("GITEA_ALLOWED_REPOS", label="Allowed repositories", required=False, description="Comma-separated owner/repo allowlist."),
|
||||
ConsumerSetupField.config("KUBERNETES_API_URL", label="Kubernetes API URL", input_type="url", required=False),
|
||||
ConsumerSetupField.secret("KUBERNETES_TOKEN", label="Kubernetes token", required=False),
|
||||
ConsumerSetupField.config("ARGOCD_BASE_URL", label="Argo CD base URL", input_type="url", required=False),
|
||||
ConsumerSetupField.secret("ARGOCD_TOKEN", label="Argo CD token", required=False),
|
||||
ConsumerSetupField.config("OBSERVABILITY_BASE_URL", label="Observability base URL", input_type="url", required=False),
|
||||
ConsumerSetupField.secret("OBSERVABILITY_TOKEN", label="Observability token", required=False),
|
||||
ConsumerSetupField.config("TENANT_ALLOWLIST", label="Tenant allowlist", required=False, description="Comma-separated tenant IDs this agent may process."),
|
||||
ConsumerSetupField.config("HEALTHCHECK_ALLOWED_HOSTS", label="HTTP health check hosts", required=False, description="Comma-separated exact host allowlist."),
|
||||
)
|
||||
egress = EgressPolicy(allow_hosts=(), deny_internet_by_default=True)
|
||||
tools_used = ("pydantic", "workspace")
|
||||
|
||||
@a2a.tool(description="Ask the starter DeepAgent to answer with tool calls when useful")
|
||||
async def ask(self, ctx: RunContext[{{ auth_type }}], prompt: str) -> str:
|
||||
creds = ctx.llm
|
||||
await ctx.emit_progress(f"llm: {creds.model} via {creds.source}")
|
||||
if not creds.api_key:
|
||||
return (
|
||||
"LLM key required. Add an LLM credential in Settings > LLM "
|
||||
"credentials before running this agent; for local --invoke "
|
||||
"runs set AGENT_LLM_KEY."
|
||||
)
|
||||
graph = self._build_deep_agent(ctx=ctx, creds=creds)
|
||||
state = await graph.ainvoke(
|
||||
{"messages": [{"role": "user", "content": prompt}]},
|
||||
config={"recursion_limit": DEEPAGENTS_RECURSION_LIMIT},
|
||||
@a2a.tool(description="Intake, redact, correlate evidence, classify severity and ownership for a bounded support case", timeout_seconds=120, idempotent=True)
|
||||
async def triage_case(self, ctx: RunContext[NoAuth], case: TriageCaseInput) -> TriageCaseOutput:
|
||||
await emit_audit(ctx, case.case_id, "triage_started", {"dry_run": case.dry_run})
|
||||
warnings = policy_warnings(case.ticket_text, [e.summary for e in case.evidence])
|
||||
setup = missing_setup(ctx, include_tokens=False)
|
||||
tenant_allowed = tenant_is_allowed(ctx, case.tenant_id)
|
||||
severity = classify_severity(case.ticket_text, case.evidence)
|
||||
owner = classify_owner(case.affected_services, case.ticket_text, case.recent_changes)
|
||||
evidence_index = normalized_evidence(case.evidence, case.ticket_text, case.release, case.recent_changes)
|
||||
payload = {
|
||||
"case_id": case.case_id,
|
||||
"customer_id": redact_secrets(case.customer_id),
|
||||
"tenant_id": redact_secrets(case.tenant_id),
|
||||
"release": redact_secrets(case.release),
|
||||
"severity": severity.value,
|
||||
"owner": owner,
|
||||
"allowed_tenant": tenant_allowed,
|
||||
"evidence": [item.model_dump(mode="json") for item in evidence_index],
|
||||
}
|
||||
digest = stable_digest(payload)
|
||||
artifacts = [
|
||||
await write_case_file(ctx, case.case_id, "redacted_case.json", json.dumps(payload, indent=2, sort_keys=True)),
|
||||
await write_case_file(ctx, case.case_id, "triage.json", json.dumps({**payload, "plan_digest": digest}, indent=2, sort_keys=True)),
|
||||
]
|
||||
audit = [audit_record(case.case_id, "triage_completed", {"plan_digest": digest, "severity": severity.value})]
|
||||
await emit_audit(ctx, case.case_id, "triage_completed", {"plan_digest": digest})
|
||||
status = "triaged" if tenant_allowed else "blocked_ungranted_tenant"
|
||||
if setup:
|
||||
warnings.append("Some integrations are not configured; analysis used caller-supplied evidence only.")
|
||||
return TriageCaseOutput(
|
||||
status=status,
|
||||
case_id=case.case_id,
|
||||
severity=severity,
|
||||
owner=owner,
|
||||
redaction_count=count_redactions(case.ticket_text),
|
||||
missing_setup=setup,
|
||||
allowed_tenant=tenant_allowed,
|
||||
plan_digest=digest,
|
||||
evidence_index=evidence_index,
|
||||
artifacts=artifacts,
|
||||
audit=audit,
|
||||
warnings=warnings,
|
||||
)
|
||||
await ctx.emit_progress("deepagent finished")
|
||||
return _last_message_text(state)
|
||||
|
||||
def _build_deep_agent(
|
||||
self,
|
||||
*,
|
||||
ctx: RunContext[{{ auth_type }}],
|
||||
creds: LLMCreds,
|
||||
) -> Any:
|
||||
# Lazy imports keep `a2a card` usable before local dependencies are
|
||||
# installed. `a2a deploy` installs requirements.txt during the build.
|
||||
from a2a_pack.deepagents import create_a2a_deep_agent
|
||||
from langchain.agents.middleware import wrap_model_call
|
||||
from langchain_core.tools import tool
|
||||
@a2a.tool(description="Create a deterministic minimal reproduction and validate HTTP health-check targets against SSRF protections", timeout_seconds=120, idempotent=True)
|
||||
async def reproduce_issue(self, ctx: RunContext[NoAuth], reproduction: ReproduceIssueInput) -> ReproduceIssueOutput:
|
||||
await emit_audit(ctx, reproduction.case_id, "reproduction_started", {"dry_run": reproduction.dry_run})
|
||||
allowed_hosts = set(reproduction.allowed_health_hosts or split_csv(ctx.consumer_config("HEALTHCHECK_ALLOWED_HOSTS", "")))
|
||||
safe_checks: list[HttpCheck] = []
|
||||
blocked: list[str] = []
|
||||
for check in reproduction.health_checks:
|
||||
try:
|
||||
validate_safe_url(check.url, allowed_hosts=allowed_hosts)
|
||||
safe_checks.append(check)
|
||||
except ValueError as exc:
|
||||
blocked.append(f"{check.name}: {exc}")
|
||||
steps = build_reproduction_steps(reproduction)
|
||||
doc = reproduction_markdown(reproduction, steps, safe_checks, blocked)
|
||||
digest = stable_digest({"steps": steps, "safe_checks": [c.model_dump(mode="json") for c in safe_checks], "blocked": blocked})
|
||||
artifact = await write_case_file(ctx, reproduction.case_id, "reproduction.md", doc)
|
||||
audit = [audit_record(reproduction.case_id, "reproduction_completed", {"reproduction_digest": digest})]
|
||||
await emit_audit(ctx, reproduction.case_id, "reproduction_completed", {"reproduction_digest": digest})
|
||||
return ReproduceIssueOutput(
|
||||
status="reproduced_deterministically",
|
||||
case_id=reproduction.case_id,
|
||||
reproduction_digest=digest,
|
||||
minimal_steps=steps,
|
||||
safe_health_checks=safe_checks,
|
||||
blocked_health_checks=blocked,
|
||||
artifacts=[artifact],
|
||||
audit=audit,
|
||||
warnings=policy_warnings(reproduction.redacted_symptom, reproduction.observed_errors),
|
||||
)
|
||||
|
||||
@tool
|
||||
def text_stats(text: str) -> str:
|
||||
"""Return exact word, character, and line counts for text."""
|
||||
words = [part for part in text.split() if part.strip()]
|
||||
return json.dumps(
|
||||
{
|
||||
"characters": len(text),
|
||||
"words": len(words),
|
||||
"lines": len(text.splitlines()) or 1,
|
||||
}
|
||||
)
|
||||
@a2a.tool(description="Rank hypotheses with provenance and produce a scoped patch.diff without modifying a repository", timeout_seconds=180, idempotent=True)
|
||||
async def propose_fix(self, ctx: RunContext[NoAuth], proposal: ProposeFixInput) -> ProposeFixOutput:
|
||||
await emit_audit(ctx, proposal.case_id, "proposal_started", {"dry_run": proposal.dry_run})
|
||||
warnings = policy_warnings(proposal.reproduction_summary, proposal.hypotheses)
|
||||
scoped_paths = [change.path for change in proposal.proposed_changes]
|
||||
ranked = rank_hypotheses(proposal.hypotheses, proposal.supporting_evidence, proposal.contradicting_evidence)
|
||||
patch_diff = build_patch_diff(proposal.proposed_changes)
|
||||
diagnosis = {
|
||||
"case_id": proposal.case_id,
|
||||
"repository": proposal.repository,
|
||||
"hypotheses": [item.model_dump(mode="json") for item in ranked],
|
||||
"scoped_paths": scoped_paths,
|
||||
"dry_run": proposal.dry_run,
|
||||
"note": "No repository mutation performed. Patch is an output artifact only.",
|
||||
}
|
||||
patch_digest = stable_digest({"patch_diff": patch_diff, "scoped_paths": scoped_paths})
|
||||
plan_digest = stable_digest({"case_id": proposal.case_id, "repository": proposal.repository, "patch_digest": patch_digest, "paths": scoped_paths})
|
||||
artifacts = [
|
||||
await write_case_file(ctx, proposal.case_id, "diagnosis.json", json.dumps(diagnosis, indent=2, sort_keys=True)),
|
||||
await write_case_file(ctx, proposal.case_id, "patch.diff", patch_diff),
|
||||
]
|
||||
audit = [audit_record(proposal.case_id, "proposal_completed", {"patch_digest": patch_digest, "plan_digest": plan_digest})]
|
||||
await emit_audit(ctx, proposal.case_id, "proposal_completed", {"patch_digest": patch_digest})
|
||||
return ProposeFixOutput(
|
||||
status="patch_proposed_no_repository_write",
|
||||
case_id=proposal.case_id,
|
||||
repository=proposal.repository,
|
||||
patch_digest=patch_digest,
|
||||
plan_digest=plan_digest,
|
||||
hypotheses=ranked,
|
||||
scoped_paths=scoped_paths,
|
||||
artifacts=artifacts,
|
||||
audit=audit,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
@wrap_model_call
|
||||
async def log_model_call(request: Any, handler: Any) -> Any:
|
||||
messages = request.state.get("messages", [])
|
||||
print(
|
||||
"[middleware] model_call "
|
||||
f"model={creds.model} source={creds.source} messages={len(messages)}"
|
||||
)
|
||||
return await handler(request)
|
||||
@a2a.tool(description="Validate a proposed patch with deterministic static checks and test-plan selectors without executing arbitrary shell", timeout_seconds=180, idempotent=True)
|
||||
async def validate_fix(self, ctx: RunContext[NoAuth], validation: ValidateFixInput) -> ValidateFixOutput:
|
||||
await emit_audit(ctx, validation.case_id, "validation_started", {"dry_run": validation.dry_run})
|
||||
checks = deterministic_validation(validation)
|
||||
warnings = [] if all(v == "pass" for v in checks.values()) else ["One or more deterministic checks require human review."]
|
||||
payload = {
|
||||
"case_id": validation.case_id,
|
||||
"patch_digest": validation.patch_digest,
|
||||
"checks": checks,
|
||||
"test_selectors": validation.test_selectors,
|
||||
"lint_selectors": validation.lint_selectors,
|
||||
"security_checks": validation.security_checks,
|
||||
"dry_run": validation.dry_run,
|
||||
"note": "No arbitrary shell was executed; selectors are a reviewable validation plan.",
|
||||
}
|
||||
validation_digest = stable_digest(payload)
|
||||
artifact = await write_case_file(ctx, validation.case_id, "validation.json", json.dumps({**payload, "validation_digest": validation_digest}, indent=2, sort_keys=True))
|
||||
audit = [audit_record(validation.case_id, "validation_completed", {"validation_digest": validation_digest})]
|
||||
await emit_audit(ctx, validation.case_id, "validation_completed", {"validation_digest": validation_digest})
|
||||
return ValidateFixOutput(
|
||||
status="validated_dry_run" if validation.dry_run else "validated_without_deploy",
|
||||
case_id=validation.case_id,
|
||||
validation_digest=validation_digest,
|
||||
patch_digest=validation.patch_digest,
|
||||
checks=checks,
|
||||
artifacts=[artifact],
|
||||
audit=audit,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
backend = ctx.workspace_backend()
|
||||
skill_sources = _seed_runtime_skills(backend, ctx)
|
||||
# create_a2a_deep_agent resolves provider:model strings with
|
||||
# langchain.init_chat_model from ctx.llm, preserving LiteLLM routing,
|
||||
# provider-specific extra body, and runtime model overrides.
|
||||
return create_a2a_deep_agent(
|
||||
ctx,
|
||||
creds=creds,
|
||||
backend=backend,
|
||||
skills=skill_sources or None,
|
||||
tools=[text_stats],
|
||||
middleware=[log_model_call],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
@a2a.tool(description="Prepare a reviewable branch/commit/PR draft and customer-safe summary; gated actions require approval bound to the plan digest", timeout_seconds=180, idempotent=True)
|
||||
async def prepare_delivery(self, ctx: RunContext[NoAuth], delivery: PrepareDeliveryInput) -> PrepareDeliveryOutput:
|
||||
await emit_audit(ctx, delivery.case_id, "delivery_started", {"dry_run": delivery.dry_run})
|
||||
actions = [str(action.value if isinstance(action, DeliveryAction) else action) for action in delivery.delivery_actions]
|
||||
immutable_plan = {
|
||||
"case_id": delivery.case_id,
|
||||
"repository": delivery.repository,
|
||||
"target_branch": delivery.target_branch,
|
||||
"proposed_branch": delivery.proposed_branch,
|
||||
"patch_digest": delivery.patch_digest,
|
||||
"validation_digest": delivery.validation_digest,
|
||||
"actions": actions,
|
||||
}
|
||||
plan_digest = stable_digest(immutable_plan)
|
||||
consequential = any(action in {DeliveryAction.draft_branch.value, DeliveryAction.draft_commit.value, DeliveryAction.draft_pr.value} for action in actions)
|
||||
approved = bool(
|
||||
consequential
|
||||
and not delivery.dry_run
|
||||
and delivery.approval_acknowledgement == APPROVAL_ACK
|
||||
and delivery.approval_plan_digest == plan_digest
|
||||
)
|
||||
approval_required = consequential and (delivery.dry_run or not approved)
|
||||
executed_actions = actions if approved else ([DeliveryAction.customer_summary.value] if DeliveryAction.customer_summary.value in actions else [])
|
||||
blocked_actions = [action for action in actions if action not in executed_actions]
|
||||
doc = delivery_markdown(delivery, plan_digest, approval_required, approved, executed_actions, blocked_actions)
|
||||
artifact = await write_case_file(ctx, delivery.case_id, "delivery.md", doc)
|
||||
audit = [audit_record(delivery.case_id, "delivery_prepared", {"plan_digest": plan_digest, "approved": approved})]
|
||||
await emit_audit(ctx, delivery.case_id, "delivery_prepared", {"plan_digest": plan_digest, "approved": approved})
|
||||
warnings = [
|
||||
"No merge or deployment was performed.",
|
||||
"Repository mutations are represented as a reviewable delivery plan only unless platform approval executes them outside this agent.",
|
||||
]
|
||||
return PrepareDeliveryOutput(
|
||||
status="approval_required" if approval_required else "delivery_prepared",
|
||||
case_id=delivery.case_id,
|
||||
plan_digest=plan_digest,
|
||||
approval_required=approval_required,
|
||||
approved=approved,
|
||||
executed_actions=executed_actions,
|
||||
blocked_actions=blocked_actions,
|
||||
artifacts=[artifact],
|
||||
audit=audit,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def _runtime_skills_root(ctx: RunContext[Any]) -> str:
|
||||
workspace = getattr(ctx, "_workspace", None)
|
||||
prefixes = tuple(getattr(workspace, "write_prefixes", ()) or ())
|
||||
if not prefixes:
|
||||
outputs_prefix = getattr(workspace, "outputs_prefix", None)
|
||||
prefixes = (outputs_prefix or "outputs/",)
|
||||
prefix = str(prefixes[0]).strip("/")
|
||||
return f"/{prefix}/{RUNTIME_SKILLS_DIR}" if prefix else f"/{RUNTIME_SKILLS_DIR}"
|
||||
def split_csv(value: Any) -> list[str]:
|
||||
return [item.strip() for item in str(value or "").split(",") if item.strip()]
|
||||
|
||||
|
||||
def _seed_runtime_skills(backend: Any, ctx: RunContext[Any]) -> list[str]:
|
||||
"""Copy packaged DeepAgents skills into the invocation workspace.
|
||||
|
||||
DeepAgents loads skills from its backend, while source-controlled
|
||||
``skills/`` folders live in the image. This bridge lets generated agents
|
||||
ship reusable SKILL.md bundles without giving up durable A2A workspace
|
||||
files.
|
||||
"""
|
||||
root = Path(__file__).parent / "skills"
|
||||
if not root.exists():
|
||||
return []
|
||||
runtime_skills_root = _runtime_skills_root(ctx)
|
||||
uploads: list[tuple[str, bytes]] = []
|
||||
for path in root.rglob("*"):
|
||||
if path.is_file():
|
||||
rel = path.relative_to(root).as_posix()
|
||||
uploads.append((runtime_skills_root + rel, path.read_bytes()))
|
||||
if uploads:
|
||||
backend.upload_files(uploads)
|
||||
return [runtime_skills_root]
|
||||
return []
|
||||
def missing_setup(ctx: RunContext[NoAuth], *, include_tokens: bool) -> list[str]:
|
||||
names = []
|
||||
for name in ("GITEA_BASE_URL", "KUBERNETES_API_URL", "ARGOCD_BASE_URL", "OBSERVABILITY_BASE_URL"):
|
||||
if not ctx.consumer_config(name, ""):
|
||||
names.append(name)
|
||||
if include_tokens:
|
||||
for secret_name in ("GITEA_TOKEN", "KUBERNETES_TOKEN", "ARGOCD_TOKEN", "OBSERVABILITY_TOKEN"):
|
||||
try:
|
||||
ctx.consumer_secret(secret_name)
|
||||
except Exception:
|
||||
names.append(secret_name)
|
||||
return names
|
||||
|
||||
|
||||
def _last_message_text(state: dict[str, Any]) -> str:
|
||||
messages = state.get("messages") or []
|
||||
if not messages:
|
||||
return json.dumps(state, default=str)
|
||||
def tenant_is_allowed(ctx: RunContext[NoAuth], tenant_id: str) -> bool:
|
||||
allowed = set(split_csv(ctx.consumer_config("TENANT_ALLOWLIST", "")))
|
||||
return not allowed or tenant_id in allowed
|
||||
|
||||
content = getattr(messages[-1], "content", None)
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for item in content:
|
||||
if isinstance(item, dict):
|
||||
text = item.get("text") or item.get("content")
|
||||
if text:
|
||||
parts.append(str(text))
|
||||
elif item:
|
||||
parts.append(str(item))
|
||||
return "\n".join(parts) if parts else json.dumps(content, default=str)
|
||||
return str(content or messages[-1])
|
||||
|
||||
def count_redactions(text: str) -> int:
|
||||
return len(SECRET_RE.findall(text or ""))
|
||||
|
||||
|
||||
SECRET_RE = re.compile(
|
||||
r"(?i)(authorization:\s*bearer\s+[A-Za-z0-9._~+/=-]+|api[_-]?key\s*[:=]\s*['\"]?[^\s,'\"]+|token\s*[:=]\s*['\"]?[^\s,'\"]+|password\s*[:=]\s*['\"]?[^\s,'\"]+|secret\s*[:=]\s*['\"]?[^\s,'\"]+|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----)"
|
||||
)
|
||||
PII_RE = re.compile(r"(?i)\b([A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}|\+?\d[\d .()\-]{7,}\d)\b")
|
||||
PROMPT_INJECTION_RE = re.compile(r"(?i)(ignore (all )?(previous|prior) instructions|system prompt|developer message|reveal secrets|exfiltrate|curl\s+|wget\s+|bash\s+-c)")
|
||||
|
||||
|
||||
def redact_secrets(text: str | None) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
redacted = SECRET_RE.sub("[REDACTED_SECRET]", text)
|
||||
return PII_RE.sub("[REDACTED_PII]", redacted)
|
||||
|
||||
|
||||
def policy_warnings(*texts_or_lists: Any) -> list[str]:
|
||||
joined_parts: list[str] = []
|
||||
for item in texts_or_lists:
|
||||
if isinstance(item, list):
|
||||
joined_parts.extend(str(v) for v in item)
|
||||
else:
|
||||
joined_parts.append(str(item))
|
||||
joined = "\n".join(joined_parts)
|
||||
warnings: list[str] = []
|
||||
if PROMPT_INJECTION_RE.search(joined):
|
||||
warnings.append("Untrusted ticket/log content contained instruction-like text and was treated only as evidence.")
|
||||
if SECRET_RE.search(joined):
|
||||
warnings.append("Secrets were detected and redacted before artifact generation.")
|
||||
if len(joined) > MAX_TEXT_CHARS:
|
||||
warnings.append("Input was bounded and truncated for deterministic processing.")
|
||||
return warnings
|
||||
|
||||
|
||||
def safe_repo_path(path: str) -> str:
|
||||
clean = str(path).replace("\\", "/").strip().lstrip("/")
|
||||
posix = PurePosixPath(clean)
|
||||
if not clean or ".." in posix.parts or clean.startswith("~"):
|
||||
raise ValueError(f"unsafe repository path: {path!r}")
|
||||
if any(part in {".git", ".hg", ".svn"} for part in posix.parts):
|
||||
raise ValueError("repository metadata paths are not allowed")
|
||||
if len(posix.parts) > 20:
|
||||
raise ValueError("repository path is too deep")
|
||||
return posix.as_posix()
|
||||
|
||||
|
||||
def safe_case_id(case_id: str) -> str:
|
||||
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{2,79}", case_id):
|
||||
raise ValueError("invalid case_id")
|
||||
return case_id
|
||||
|
||||
|
||||
def validate_safe_url(url: str, *, allowed_hosts: set[str] | tuple[str, ...]) -> str:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
raise ValueError("URL scheme must be http or https")
|
||||
if not parsed.hostname:
|
||||
raise ValueError("URL must include a hostname")
|
||||
host = parsed.hostname.lower().rstrip(".")
|
||||
if allowed_hosts and host not in {h.lower().rstrip(".") for h in allowed_hosts}:
|
||||
raise ValueError(f"host {host!r} is not in the allowlist")
|
||||
if host in {"localhost", "metadata.google.internal"} or host.endswith(".local") or host.endswith(".internal"):
|
||||
raise ValueError("internal hostnames are blocked")
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
ip = None
|
||||
if ip and (ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip.is_unspecified):
|
||||
raise ValueError("private, loopback, link-local, multicast, reserved, and unspecified IPs are blocked")
|
||||
if parsed.username or parsed.password:
|
||||
raise ValueError("credentials in URLs are not allowed")
|
||||
return url
|
||||
|
||||
|
||||
def stable_digest(payload: Any) -> str:
|
||||
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def classify_severity(ticket_text: str, evidence: list[EvidenceItem]) -> Severity:
|
||||
text = (ticket_text + "\n" + "\n".join(e.summary for e in evidence)).lower()
|
||||
if any(term in text for term in ("outage", "data loss", "security incident", "all customers", "sev1")):
|
||||
return Severity.sev1
|
||||
if any(term in text for term in ("unable", "down", "500", "payments failing", "sev2")):
|
||||
return Severity.sev2
|
||||
if any(term in text for term in ("degraded", "intermittent", "slow", "error")):
|
||||
return Severity.sev3
|
||||
return Severity.sev4
|
||||
|
||||
|
||||
def classify_owner(services: list[str], ticket_text: str, changes: list[str]) -> str:
|
||||
haystack = " ".join([ticket_text, *services, *changes]).lower()
|
||||
if "payment" in haystack or "checkout" in haystack:
|
||||
return "payments"
|
||||
if "auth" in haystack or "login" in haystack:
|
||||
return "identity"
|
||||
if "kubernetes" in haystack or "pod" in haystack or "argocd" in haystack:
|
||||
return "platform"
|
||||
if services:
|
||||
return services[0][:80]
|
||||
return "unassigned"
|
||||
|
||||
|
||||
def normalized_evidence(evidence: list[EvidenceItem], ticket_text: str, release: str, changes: list[str]) -> list[EvidenceItem]:
|
||||
out = list(evidence)
|
||||
out.append(EvidenceItem(source_id="ticket", kind=EvidenceKind.ticket, summary=redact_secrets(ticket_text[:1200]), excerpt=redact_secrets(ticket_text[:2000])))
|
||||
out.append(EvidenceItem(source_id="release", kind=EvidenceKind.release, summary=f"Release: {redact_secrets(release)}"))
|
||||
for idx, change in enumerate(changes[:20], start=1):
|
||||
out.append(EvidenceItem(source_id=f"recent-change-{idx}", kind=EvidenceKind.change, summary=change[:1200]))
|
||||
return out[:MAX_EVIDENCE_ITEMS]
|
||||
|
||||
|
||||
def build_reproduction_steps(inp: ReproduceIssueInput) -> list[str]:
|
||||
steps = [
|
||||
"Use only the redacted support case and explicitly granted repository/workspace evidence.",
|
||||
f"Set environment facets: {json.dumps({k: redact_secrets(v) for k, v in sorted(inp.environment.items())}, sort_keys=True)}.",
|
||||
"Trigger the smallest customer-safe path that matches the observed symptom.",
|
||||
]
|
||||
if inp.observed_errors:
|
||||
steps.append(f"Assert the observed error signature appears: {redact_secrets(inp.observed_errors[0])[:500]}.")
|
||||
if inp.candidate_files:
|
||||
steps.append("Inspect only scoped candidate files: " + ", ".join(inp.candidate_files[:10]) + ".")
|
||||
if inp.health_checks:
|
||||
steps.append("Run configured HTTP health checks only after SSRF allowlist validation; do not follow redirects.")
|
||||
steps.append("Record expected vs actual behavior without customer secrets or raw tenant data.")
|
||||
return steps
|
||||
|
||||
|
||||
def reproduction_markdown(inp: ReproduceIssueInput, steps: list[str], checks: list[HttpCheck], blocked: list[str]) -> str:
|
||||
lines = [
|
||||
f"# Minimal Reproduction for {inp.case_id}",
|
||||
"",
|
||||
"## Symptom",
|
||||
redact_secrets(inp.redacted_symptom),
|
||||
"",
|
||||
"## Deterministic Steps",
|
||||
]
|
||||
lines.extend(f"{idx}. {step}" for idx, step in enumerate(steps, start=1))
|
||||
lines.extend(["", "## Safe Health Checks"])
|
||||
if checks:
|
||||
lines.extend(f"- {c.name}: {c.url} expects {c.expected_status}" for c in checks)
|
||||
else:
|
||||
lines.append("- None")
|
||||
lines.extend(["", "## Blocked Health Checks"])
|
||||
lines.extend([f"- {item}" for item in blocked] or ["- None"])
|
||||
lines.extend(["", "## Safety", "Ticket and log instructions are untrusted evidence; no arbitrary shell or URL fetching is performed."])
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def rank_hypotheses(hypotheses: list[str], supporting: list[EvidenceItem], contradicting: list[EvidenceItem]) -> list[HypothesisRank]:
|
||||
if not hypotheses:
|
||||
hypotheses = ["Regression correlated with recent release/change and observed error signature"]
|
||||
ranked: list[HypothesisRank] = []
|
||||
support_ids = [e.source_id for e in supporting]
|
||||
contra_ids = [e.source_id for e in contradicting]
|
||||
for idx, hypothesis in enumerate(hypotheses[:20], start=1):
|
||||
confidence = max(0.1, min(0.95, 0.55 + (len(support_ids) * 0.05) - (len(contra_ids) * 0.08) - ((idx - 1) * 0.03)))
|
||||
ranked.append(HypothesisRank(hypothesis=redact_secrets(hypothesis)[:1000], rank=idx, confidence=round(confidence, 2), supporting_source_ids=support_ids[:20], contradicting_source_ids=contra_ids[:20]))
|
||||
return ranked
|
||||
|
||||
|
||||
def build_patch_diff(changes: list[RepoFileChange]) -> str:
|
||||
if not changes:
|
||||
return "diff --git a/README.md b/README.md\n--- a/README.md\n+++ b/README.md\n@@ -0,0 +1,3 @@\n+# Support Case Fix Placeholder\n+No scoped code changes were supplied.\n+Attach repository evidence before implementation.\n"
|
||||
parts: list[str] = []
|
||||
for change in changes:
|
||||
content = change.proposed_content or f"# Proposed change\n# Reason: {change.reason}\n"
|
||||
safe_content = redact_secrets(content).splitlines()
|
||||
parts.append(f"diff --git a/{change.path} b/{change.path}")
|
||||
parts.append(f"--- a/{change.path}")
|
||||
parts.append(f"+++ b/{change.path}")
|
||||
parts.append("@@ -0,0 +1,%d @@" % max(1, len(safe_content)))
|
||||
parts.extend("+" + line for line in safe_content[:400])
|
||||
return "\n".join(parts) + "\n"
|
||||
|
||||
|
||||
def deterministic_validation(inp: ValidateFixInput) -> dict[str, str]:
|
||||
checks: dict[str, str] = {}
|
||||
checks["patch_digest_format"] = "pass" if re.fullmatch(r"[a-fA-F0-9]{16,128}", inp.patch_digest) else "review"
|
||||
checks["patch_has_diff_header"] = "pass" if "diff --git" in inp.patch_diff and "+++ b/" in inp.patch_diff else "review"
|
||||
checks["no_secret_literals"] = "pass" if not SECRET_RE.search(inp.patch_diff) else "fail"
|
||||
checks["path_containment"] = "pass" if not re.search(r"(^|\n)(---|\+\+\+) [ab]/(\.\.|/|.*\.git)", inp.patch_diff) else "fail"
|
||||
checks["test_plan_present"] = "pass" if inp.test_selectors else "review"
|
||||
checks["lint_plan_present"] = "pass" if inp.lint_selectors else "review"
|
||||
checks["security_plan_present"] = "pass" if inp.security_checks else "review"
|
||||
checks["dry_run_enforced"] = "pass" if inp.dry_run else "review"
|
||||
return checks
|
||||
|
||||
|
||||
def delivery_markdown(inp: PrepareDeliveryInput, plan_digest: str, approval_required: bool, approved: bool, executed: list[str], blocked: list[str]) -> str:
|
||||
safe_summary = redact_secrets(inp.customer_summary_context)[:2000]
|
||||
return "\n".join([
|
||||
f"# Delivery Plan for {inp.case_id}",
|
||||
"",
|
||||
f"Plan digest: `{plan_digest}`",
|
||||
f"Repository: `{inp.repository}`",
|
||||
f"Target branch: `{inp.target_branch}`",
|
||||
f"Proposed branch: `{inp.proposed_branch}`",
|
||||
f"Patch digest: `{inp.patch_digest}`",
|
||||
f"Validation digest: `{inp.validation_digest}`",
|
||||
"",
|
||||
"## Approval",
|
||||
f"Approval required: `{str(approval_required).lower()}`",
|
||||
f"Approved: `{str(approved).lower()}`",
|
||||
f"Required acknowledgement: `{APPROVAL_ACK}` bound to the plan digest above.",
|
||||
"",
|
||||
"## Actions",
|
||||
"Executed/prepared: " + (", ".join(executed) if executed else "none"),
|
||||
"Blocked: " + (", ".join(blocked) if blocked else "none"),
|
||||
"",
|
||||
"## Customer-safe Resolution Summary",
|
||||
safe_summary or "A scoped fix has been prepared for engineering review. No customer secrets, raw logs, or deployment details are included.",
|
||||
"",
|
||||
"## Non-actions",
|
||||
"This agent never merges, deploys, follows ticket/log instructions, executes arbitrary shell, or fetches arbitrary URLs.",
|
||||
]) + "\n"
|
||||
|
||||
|
||||
def audit_record(case_id: str, event: str, details: dict[str, Any]) -> AuditRecord:
|
||||
return AuditRecord(event=event, at=datetime.now(UTC).isoformat(), case_id=case_id, details=json_sanitize(details))
|
||||
|
||||
|
||||
async def emit_audit(ctx: RunContext[NoAuth], case_id: str, event: str, details: dict[str, Any]) -> None:
|
||||
await ctx.emit_event(AgentEvent(kind="audit", payload=audit_record(case_id, event, details).model_dump(mode="json")))
|
||||
|
||||
|
||||
def json_sanitize(value: Any) -> Any:
|
||||
raw = json.dumps(value, default=str)
|
||||
return json.loads(redact_secrets(raw))
|
||||
|
||||
|
||||
async def write_case_file(ctx: RunContext[NoAuth], case_id: str, filename: str, content: str) -> ArtifactRecord:
|
||||
safe_case = safe_case_id(case_id)
|
||||
safe_name = safe_repo_path(filename)
|
||||
if "/" in safe_name:
|
||||
raise ValueError("case artifact filename must not contain subdirectories")
|
||||
rel_path = f"{OUTPUT_ROOT}/{safe_case}/{safe_name}"
|
||||
data = redact_secrets(content).encode("utf-8")
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
artifact_uri: str | None = None
|
||||
try:
|
||||
view = await ctx.workspace.open_view(
|
||||
purpose=f"Persist support case artifact {safe_name}",
|
||||
hints=[safe_case, safe_name],
|
||||
file_types=(),
|
||||
max_files=1,
|
||||
mode=WorkspaceMode.READ_WRITE_OVERLAY,
|
||||
reason="Persist generated support-to-fix engineering artifact under outputs/support-cases/{case_id}/",
|
||||
)
|
||||
await view.write(rel_path, data)
|
||||
except Exception as exc: # workspace may be unavailable in local smoke invocations
|
||||
await ctx.emit_event(AgentEvent(kind="workspace_write_warning", payload={"path": rel_path, "message": redact_secrets(str(exc))[:500]}))
|
||||
try:
|
||||
ref = await ctx.write_artifact(rel_path.replace("/", "__"), data, mime_type_for(filename))
|
||||
await ctx.emit_artifact(ref)
|
||||
artifact_uri = ref.uri
|
||||
except Exception as exc:
|
||||
await ctx.emit_event(AgentEvent(kind="artifact_warning", payload={"path": rel_path, "message": redact_secrets(str(exc))[:500]}))
|
||||
return ArtifactRecord(path=rel_path, artifact_uri=artifact_uri, sha256=digest, size_bytes=len(data))
|
||||
|
||||
|
||||
def mime_type_for(filename: str) -> str:
|
||||
if filename.endswith(".json"):
|
||||
return "application/json"
|
||||
if filename.endswith(".md"):
|
||||
return "text/markdown"
|
||||
if filename.endswith(".diff") or filename.endswith(".patch"):
|
||||
return "text/x-diff"
|
||||
return "text/plain"
|
||||
|
||||
Reference in New Issue
Block a user