修 #75:蜂群单任务铺不到多 agent。两层根因 + 对应修复(均经本地 k8s 端到端验证):
1) 真实 agent 执行器从未实现 #7「自主分解提案」(autonomous-task-generation.md §4 标为
「后续」;此前只有 stub_agent 演示)。编排器侧 handle_task_proposal 早已就绪,缺 agent 侧出口。
- agent/main.py: 新增 propose_task()(发 WS task_proposal,字段对齐 handle_task_proposal);
execute_task 传入 proposal_callback;control_messages 接受 task_proposal_ack。
- agent/task_executor.py: 新增 _maybe_propose_subtasks() —— 执行顶层种子时用 _parse_task 拆分,
把每个子任务经 proposal_callback 提案入池(由编排器 review→create_task→其他 agent 自选)。
仅顶层种子提案(parent 为空、source 非 agent_proposed/dynamic_handoff),子任务不再递归提案,无环。
env ENABLE_AUTONOMOUS_PROPOSALS(默认 on)可关。
2) _parse_task 又脆又静默退化为单任务(原 max_tokens=2000 截断 + 严格 json.loads + except 兜底):
- max_tokens 可配 AGENT_PLAN_MAX_TOKENS(默认 65536;注:qwen3.7-max 网关上限即 65536,>之 400);
- 新增 _coerce_subtasks 宽容解析(裸数组 / ``` 围栏 / {"subtasks":[...]} / 从文本抠 [...]);
- 解析失败重试 1 次再退化;成功打 INFO "Decomposed into N",退化打明确 WARNING(可观测)。
派发:提案子任务 required_capabilities 置空(像种子,任意空闲 agent 可自选)。否则 LLM 给的具体能力
不是固定能力池子集 → can_agent_run_task 永远拒 → 子任务卡 PENDING(本地实测到的死锁)。能力提示保留在
agent_role(不门控派发);能力感知路由(把子任务能力映射到能力池)作为后续增强,见 #75。
本地验证(Docker Desktop k8s,qwen3.7-max,池16):两次 run 复现「种子→拆 6/8 子任务→派给 6/8 个不同
agent 并行执行」,此前恒为单 agent。
测试(本地全过):test-runtime-contract / test-contract-freeze / test-merge-smoke / test-workflow-e2e /
test-autonomous-tasks / test-agent-launcher / test-convergence。
影响:仅 agent/(执行单元);不动 Manager↔Swarm 冻结契约 / 计费 / 审计 / 编排器接口。
Refs #75
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
665 lines
30 KiB
Python
665 lines
30 KiB
Python
"""Task execution engine with OpenAI-compatible LLM provider integration.
|
|
|
|
Merged from agent_swarm_v4:
|
|
- OpenAI-only provider configuration (OPENAI_*/MODEL_* env vars and custom base URLs)
|
|
- Peer-collaboration hook and specialist-role alignment prompting
|
|
- Preserves model invocation, handoff decision hooks, workspace summarization,
|
|
file-application behavior, and the Manager billing/audit usage attribution
|
|
(X-Agent/X-Agnet headers, usage payload, billing_source)
|
|
"""
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Callable, Optional
|
|
|
|
from openai import AsyncOpenAI
|
|
|
|
from .handoff_logic import should_handoff
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class TaskExecutor:
|
|
"""Executes tasks using a configurable OpenAI-compatible API."""
|
|
|
|
def __init__(self, agent_id: str, workspace_dir: str):
|
|
self.agent_id = agent_id
|
|
self.workspace_dir = workspace_dir
|
|
|
|
api_key = (
|
|
os.getenv("OPENAI_API_KEY")
|
|
or os.getenv("MODEL_API_KEY")
|
|
)
|
|
if not api_key:
|
|
raise ValueError("OPENAI_API_KEY environment variable not set")
|
|
|
|
self.model = (
|
|
os.getenv("OPENAI_MODEL")
|
|
or os.getenv("MODEL_NAME")
|
|
or os.getenv("MODEL_ID")
|
|
or "gpt-4o-mini"
|
|
)
|
|
self.api_base = (
|
|
os.getenv("OPENAI_API_BASE")
|
|
or os.getenv("MODEL_API_BASE")
|
|
or "https://api.openai.com/v1"
|
|
)
|
|
self.api_mode = "openai"
|
|
self.client = AsyncOpenAI(api_key=api_key, base_url=self.api_base)
|
|
self.client_type = "openai"
|
|
|
|
self.usage = self._empty_usage()
|
|
self.current_context: dict = {}
|
|
|
|
async def execute_task(
|
|
self,
|
|
task_id: str,
|
|
description: str,
|
|
context: dict,
|
|
handoff_callback: Optional[Callable] = None,
|
|
agent_capabilities: Optional[list[str]] = None,
|
|
peer_collaboration_callback: Optional[Callable] = None,
|
|
proposal_callback: Optional[Callable] = None,
|
|
) -> dict:
|
|
logger.info(f"Executing task {task_id}: {description}")
|
|
|
|
try:
|
|
started_at = time.time()
|
|
self.current_context = context or {}
|
|
self.usage = self._empty_usage(context)
|
|
|
|
# agent_swarm#7 — bottom-up decomposition (the swarm's canonical fan-out path): when this
|
|
# is a top-level seed, decompose it and PROPOSE the subtasks to the shared pool so peers
|
|
# self-select them, instead of doing the whole objective solo. Proposed/child tasks never
|
|
# re-propose (guarded in _maybe_propose_subtasks) → no loop. See autonomous-task-generation.md.
|
|
proposed = await self._maybe_propose_subtasks(task_id, description, context, proposal_callback)
|
|
if proposed:
|
|
return {
|
|
"success": True,
|
|
"task_id": task_id,
|
|
"summary": f"Decomposed the objective into {proposed} subtask(s) and proposed them to the swarm pool for peers to execute (agent_swarm#7).",
|
|
"subtasks": [],
|
|
"proposed_subtasks": proposed,
|
|
"agent_id": self.agent_id,
|
|
"usage": self._usage_payload(time.time() - started_at),
|
|
}
|
|
|
|
multi_agent_leaf_mode = self._multi_agent_leaf_mode(context)
|
|
allow_handoff = self._allow_dynamic_handoff(context)
|
|
|
|
if multi_agent_leaf_mode:
|
|
subtasks = [{
|
|
"description": description,
|
|
"complexity": "medium",
|
|
"estimated_time": 30,
|
|
"dependencies": [],
|
|
"required_capabilities": context.get("required_capabilities") or ["general"],
|
|
}]
|
|
elif self._subtask_handoff_enabled():
|
|
subtasks = await self._parse_task(description, context)
|
|
else:
|
|
subtasks = [{
|
|
"description": description,
|
|
"complexity": "medium",
|
|
"estimated_time": 30,
|
|
"dependencies": [],
|
|
"required_capabilities": ["general"],
|
|
}]
|
|
|
|
results = []
|
|
for subtask in subtasks:
|
|
if handoff_callback and self._subtask_handoff_enabled() and allow_handoff:
|
|
decision = should_handoff(
|
|
subtask,
|
|
self.agent_id,
|
|
agent_capabilities=agent_capabilities,
|
|
)
|
|
if decision.should_handoff:
|
|
await handoff_callback(
|
|
task_id=task_id,
|
|
subtask=subtask,
|
|
target_capabilities=decision.target_capabilities,
|
|
)
|
|
results.append({
|
|
"subtask": subtask,
|
|
"status": "handed_off",
|
|
"reason": decision.reason,
|
|
"target_capabilities": decision.target_capabilities,
|
|
})
|
|
continue
|
|
|
|
results.append(await self._execute_subtask(subtask, task_id, context, peer_collaboration_callback))
|
|
|
|
success = all(r["status"] in ["completed", "handed_off"] for r in results)
|
|
awaiting_handoff = any(r["status"] == "handed_off" for r in results)
|
|
payload = {
|
|
"success": success,
|
|
"task_id": task_id,
|
|
"subtasks": results,
|
|
"awaiting_handoff": awaiting_handoff,
|
|
"agent_id": self.agent_id,
|
|
"usage": self._usage_payload(time.time() - started_at),
|
|
}
|
|
if not success:
|
|
# Surface the model's OWN failure explanation (what it saw / what was missing) as a
|
|
# top-level `error`, so the orchestrator/Manager records WHY instead of a generic
|
|
# "Task failed" (agent/main.py uses this as the failure reason). Lead with the model's
|
|
# summary/error — NOT the task prompt — so the reason reads as the diagnosis, not the
|
|
# ask. summary is usually the fuller "saw X, missing Y" narrative; append a distinct
|
|
# error for the concise cause.
|
|
failed = [r for r in results if r.get("status") not in ("completed", "handed_off")]
|
|
explanations = []
|
|
for r in failed:
|
|
summary = str(r.get("summary") or "").strip()
|
|
error = str(r.get("error") or "").strip()
|
|
text = summary or error or "subtask failed without detail"
|
|
if summary and error and error not in summary:
|
|
text = f"{summary} ({error})"
|
|
explanations.append(text)
|
|
detail = "; ".join(explanations) or "subtask(s) failed without detail"
|
|
payload["error"] = detail
|
|
logger.error(f"Task {task_id} failed: {detail}")
|
|
return payload
|
|
except Exception as e:
|
|
logger.error(f"Error executing task {task_id}: {e}")
|
|
return {
|
|
"success": False,
|
|
"task_id": task_id,
|
|
"error": str(e),
|
|
"agent_id": self.agent_id,
|
|
"usage": self._usage_payload(time.time() - started_at if "started_at" in locals() else 0),
|
|
}
|
|
finally:
|
|
self.current_context = {}
|
|
|
|
async def _maybe_propose_subtasks(self, task_id: str, description: str, context: dict,
|
|
proposal_callback: Optional[Callable]) -> int:
|
|
"""agent_swarm#7: decompose a top-level seed and propose each subtask to the shared pool.
|
|
|
|
Returns the number of subtasks proposed (0 = not a seed / disabled / undecomposable, caller
|
|
then executes normally). Guards against loops: only top-level seeds (no parent, source not
|
|
agent_proposed/dynamic_handoff) propose; the proposed children won't re-propose."""
|
|
if not proposal_callback:
|
|
return 0
|
|
ctx = context or {}
|
|
if ctx.get("parent_task_id") is not None:
|
|
return 0
|
|
if ctx.get("source") in ("agent_proposed", "dynamic_handoff"):
|
|
return 0
|
|
if os.getenv("ENABLE_AUTONOMOUS_PROPOSALS", "true").lower() not in {"1", "true", "yes"}:
|
|
return 0
|
|
try:
|
|
subtasks = await self._parse_task(description, ctx)
|
|
except Exception as e:
|
|
logger.warning(f"Seed decomposition failed: {e}")
|
|
return 0
|
|
if not subtasks or len(subtasks) < 2:
|
|
return 0 # nothing to fan out — execute as a single task
|
|
proposed = 0
|
|
for st in subtasks:
|
|
desc = (st.get("description") or "").strip()
|
|
if not desc:
|
|
continue
|
|
caps = st.get("required_capabilities") or []
|
|
try:
|
|
await proposal_callback(
|
|
description=desc,
|
|
reason="bottom-up decomposition of the seed objective (agent_swarm#7)",
|
|
confidence=0.8,
|
|
origin_task_id=task_id,
|
|
trigger_event="seed_decomposition",
|
|
shared_state_snapshot={"origin_task": task_id},
|
|
# Capabilities are a HINT (agent_role), not a dispatch gate: leave
|
|
# required_capabilities empty so ANY idle agent can self-select the subtask
|
|
# (like the seed). LLM-specific caps (e.g. flask/sqlite) aren't a subset of the
|
|
# fixed pool caps → can_agent_run_task would reject → task stuck PENDING (the
|
|
# dispatch gap we hit). Self-selection + τ handle routing instead.
|
|
agent_role=",".join(caps) or "general",
|
|
required_capabilities=[],
|
|
)
|
|
proposed += 1
|
|
except Exception as e:
|
|
logger.warning(f"propose_task failed for a subtask: {e}")
|
|
if proposed:
|
|
logger.info(f"Proposed {proposed} subtask(s) to the shared pool (agent_swarm#7)")
|
|
return proposed
|
|
|
|
async def _parse_task(self, description: str, context: dict) -> list[dict]:
|
|
# agent_swarm#75 (LOCAL fix — not for upstream as-is): the old impl capped output at
|
|
# max_tokens=2000 + strict json.loads + silently fell back to a SINGLE task on any error,
|
|
# so large tasks (their breakdown JSON exceeds 2000 → truncated → parse fail) never split →
|
|
# the swarm degraded to one agent. Fix: bigger configurable budget, lenient parsing
|
|
# (accept bare array or {"subtasks":[...]}), one retry, and explicit decompose/fallback logs.
|
|
# Decomposition output budget (env-tunable). qwen3.7-max max output = 65536 tokens
|
|
# (gateway rejects >65536 with HTTP 400 InvalidParameter), so default to the full 65536
|
|
# so large-task breakdown JSON never truncates. It's a cap, not a target.
|
|
max_tokens = int(os.getenv("AGENT_PLAN_MAX_TOKENS", "65536") or 65536)
|
|
prompt = f"""You are a task planning assistant. Break down the following programming task into concrete, actionable subtasks.
|
|
|
|
Task: {description}
|
|
|
|
Context: {json.dumps(context, indent=2)}
|
|
|
|
Return a JSON array of subtasks, where each subtask has:
|
|
- description: Clear description of what needs to be done
|
|
- complexity: \"low\", \"medium\", or \"high\"
|
|
- estimated_time: Estimated time in minutes
|
|
- dependencies: List of subtask indices this depends on (empty if none)
|
|
- required_capabilities: List of capabilities needed
|
|
|
|
Return ONLY the JSON array, no other text."""
|
|
last_err = None
|
|
for attempt in range(2): # one retry before degrading
|
|
try:
|
|
content = await self._complete(prompt, max_tokens=max_tokens)
|
|
subtasks = self._coerce_subtasks(content)
|
|
if subtasks:
|
|
logger.info(f"Decomposed task into {len(subtasks)} subtask(s)")
|
|
return subtasks
|
|
last_err = "no subtasks parsed"
|
|
except Exception as e:
|
|
last_err = e
|
|
logger.warning(f"Task decomposition parse failed (attempt {attempt + 1}/2): {e}")
|
|
logger.warning(f"Task decomposition fell back to a single task (reason: {last_err}); the run will not fan out")
|
|
return [{
|
|
"description": description,
|
|
"complexity": "medium",
|
|
"estimated_time": 30,
|
|
"dependencies": [],
|
|
"required_capabilities": ["general"],
|
|
}]
|
|
|
|
def _coerce_subtasks(self, content: str) -> list[dict]:
|
|
"""Lenient parse of the planner output into a subtask list (agent_swarm#75).
|
|
|
|
Accepts a bare JSON array, a ```-fenced array, or an object like {"subtasks": [...]}.
|
|
Returns [] when nothing usable is found (caller retries / degrades)."""
|
|
text = self._strip_json_fence(content or "")
|
|
obj = None
|
|
try:
|
|
obj = json.loads(text)
|
|
except Exception:
|
|
start, end = text.find("["), text.rfind("]")
|
|
if start != -1 and end > start:
|
|
try:
|
|
obj = json.loads(text[start:end + 1])
|
|
except Exception:
|
|
obj = None
|
|
if isinstance(obj, dict):
|
|
obj = obj.get("subtasks") or obj.get("tasks")
|
|
if not isinstance(obj, list):
|
|
return []
|
|
return [s for s in obj if isinstance(s, dict) and s.get("description")]
|
|
|
|
async def _execute_subtask(self, subtask: dict, task_id: str, context: dict, peer_collaboration_callback: Optional[Callable]) -> dict:
|
|
content = ""
|
|
try:
|
|
description = subtask["description"]
|
|
workspace_files = self._summarize_workspace()
|
|
workspace_context = self._collect_workspace_context()
|
|
user_prompt = context.get("user_prompt") or context.get("run_goal") or context.get("root_task_description") or ""
|
|
specialist_role = context.get("specialist_role", "general")
|
|
dependency_artifacts = context.get("dependency_artifacts") or []
|
|
implementation_artifacts = [
|
|
artifact for artifact in dependency_artifacts
|
|
if "implementation" in (artifact.get("task_id") or "")
|
|
]
|
|
testing_artifacts = [
|
|
artifact for artifact in dependency_artifacts
|
|
if "testing" in (artifact.get("task_id") or "")
|
|
]
|
|
peer_context = []
|
|
peer_agents = context.get("peer_agents") or []
|
|
should_consult_peers = specialist_role in {"testing", "documentation"}
|
|
if peer_collaboration_callback and peer_agents and should_consult_peers:
|
|
max_peer_consults = int((context or {}).get("max_peer_consults", 2) or 2)
|
|
preferred_roles = []
|
|
if specialist_role in {"testing", "documentation"}:
|
|
preferred_roles = ["implementation"]
|
|
|
|
ordered_peers = sorted(
|
|
peer_agents,
|
|
key=lambda peer: 0 if peer.get("role") in preferred_roles else 1,
|
|
)
|
|
|
|
for peer in ordered_peers[:max_peer_consults]:
|
|
try:
|
|
reply = await peer_collaboration_callback(
|
|
task_id=task_id,
|
|
target_agent_id=peer.get("agent_id"),
|
|
content=(
|
|
f"Specialist role: {specialist_role}. "
|
|
f"Please provide guidance and confirm behavior for: {description}. "
|
|
f"Original user request: {user_prompt}"
|
|
),
|
|
timeout_seconds=float((context or {}).get("peer_timeout_seconds", 10.0) or 10.0),
|
|
)
|
|
peer_context.append(
|
|
{
|
|
"agent_id": peer.get("agent_id"),
|
|
"role": peer.get("role"),
|
|
"content": reply.get("content", ""),
|
|
}
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(f"Peer collaboration failed with {peer.get('agent_id')}: {exc}")
|
|
prompt = f"""You are a programming assistant working in a collaborative agent system.
|
|
|
|
Original user request:
|
|
{user_prompt}
|
|
|
|
Your specialist role:
|
|
{specialist_role}
|
|
|
|
Task: {description}
|
|
|
|
Dependency artifacts from other specialists:
|
|
{json.dumps(dependency_artifacts, indent=2)}
|
|
|
|
Implementation artifacts relevant to alignment:
|
|
{json.dumps(implementation_artifacts, indent=2)}
|
|
|
|
Testing artifacts relevant to alignment:
|
|
{json.dumps(testing_artifacts, indent=2)}
|
|
|
|
Workspace directory: {self.workspace_dir}
|
|
Current workspace files:
|
|
{json.dumps(workspace_files, indent=2)}
|
|
Relevant workspace file contents:
|
|
{json.dumps(workspace_context, indent=2)}
|
|
Peer specialist input:
|
|
{json.dumps(peer_context, indent=2)}
|
|
|
|
Alignment requirements:
|
|
- If your role is testing, align your tests with the implementation artifacts and their stated error semantics.
|
|
- If your role is documentation, align your docs with both implementation and testing artifacts.
|
|
- Do not invent behavior that conflicts with dependency artifacts unless you explicitly surface an error.
|
|
- Treat implementation artifacts as the source of truth for API behavior and exception semantics.
|
|
- If peer specialist input conflicts with implementation artifacts, prefer implementation semantics and explain the correction in your changes summary.
|
|
- If your role is documentation or testing, update only your specialist outputs to converge on implementation behavior unless the implementation artifact is clearly missing or contradictory.
|
|
|
|
Return your response as JSON with this structure:
|
|
{{
|
|
\"status\": \"completed\" or \"failed\",
|
|
\"summary\": \"Brief description of what was done\",
|
|
\"files\": [{{\"path\": \"relative/path.py\", \"action\": \"write\", \"content\": \"complete file content\"}}],
|
|
\"changes\": \"Detailed description of changes\",
|
|
\"error\": \"Error message if failed, null otherwise\"
|
|
}}
|
|
|
|
Return ONLY the JSON, no other text."""
|
|
content = await self._complete(prompt, max_tokens=4000)
|
|
result = self._parse_json_response(content)
|
|
if result.get("status") == "completed":
|
|
apply_result = await self._apply_file_changes(result.get("files", []))
|
|
result["files_modified"] = apply_result["files_modified"]
|
|
result["files_deleted"] = apply_result["files_deleted"]
|
|
if apply_result["errors"]:
|
|
result["status"] = "failed"
|
|
result["error"] = "; ".join(apply_result["errors"])
|
|
logger.warning(
|
|
f"Subtask for task {task_id} failed applying file changes: {result['error']}"
|
|
)
|
|
else:
|
|
# LLM returned 2xx but declared the subtask not completed: log its reason and a
|
|
# bounded snippet of the model output so the failure is diagnosable from agent logs.
|
|
logger.warning(
|
|
"Subtask for task %s reported status=%r (error=%s summary=%s); llm_response[:500]=%s",
|
|
task_id, result.get("status"), result.get("error"),
|
|
result.get("summary"), (content or "").strip()[:500],
|
|
)
|
|
result["subtask"] = subtask
|
|
return result
|
|
except Exception as e:
|
|
# Parse failures / API errors land here; include a bounded model-output snippet
|
|
# (model-generated text, no secrets) to explain why parsing/execution failed.
|
|
logger.error(
|
|
f"Error executing subtask for task {task_id}: {e}; "
|
|
f"llm_response[:500]={(content or '').strip()[:500]}"
|
|
)
|
|
return {
|
|
"subtask": subtask,
|
|
"status": "failed",
|
|
"error": str(e),
|
|
"summary": f"Failed to execute: {e}",
|
|
}
|
|
|
|
async def _complete(self, prompt: str, max_tokens: int) -> str:
|
|
extra_headers = self._model_attribution_headers()
|
|
response = await self.client.chat.completions.create(
|
|
model=self.model,
|
|
messages=[{"role": "user", "content": prompt}],
|
|
max_tokens=max_tokens,
|
|
extra_headers=extra_headers or None,
|
|
)
|
|
self._record_openai_usage(response)
|
|
return response.choices[0].message.content or ""
|
|
|
|
async def peer_reply(self, *, query: str, capabilities: list[str],
|
|
last_summary: Optional[str], max_tokens: Optional[int] = None) -> dict:
|
|
"""Compose a substantive, grounded reply to a peer agent's query (one bounded LLM call).
|
|
|
|
Returns {stance, content, evidence, refs}. `content` is what the requesting agent reads.
|
|
Bounded by PEER_CONSULT_MAX_TOKENS (default 500).
|
|
"""
|
|
if max_tokens is None:
|
|
max_tokens = int(os.getenv("PEER_CONSULT_MAX_TOKENS", "500"))
|
|
workspace_files = self._summarize_workspace()
|
|
workspace_context = self._collect_workspace_context(max_files=8)
|
|
prompt = f"""You are a specialist agent being consulted by a peer in a collaborative swarm.
|
|
Your capabilities: {', '.join(capabilities)}
|
|
Your latest completed work (summary): {last_summary or 'none'}
|
|
|
|
A peer asks:
|
|
{query}
|
|
|
|
Your workspace files:
|
|
{json.dumps(workspace_files, indent=2)}
|
|
Relevant workspace file contents:
|
|
{json.dumps(workspace_context, indent=2)}
|
|
|
|
Answer concisely and concretely, grounded in YOUR actual work/artifacts. Treat implementation
|
|
artifacts as the source of truth for behavior and exception semantics; if the peer's assumption
|
|
conflicts with your work, say so.
|
|
|
|
Return ONLY JSON:
|
|
{{\"stance\": \"agree|disagree|info\", \"content\": \"<concise answer to the peer>\", \"evidence\": \"<what in your work supports this>\", \"refs\": [\"relative/file/path\"]}}"""
|
|
content = await self._complete(prompt, max_tokens=max_tokens)
|
|
result = self._parse_json_response(content)
|
|
if not result.get("content"):
|
|
result["content"] = (content or "").strip()[:1000]
|
|
result.setdefault("stance", "info")
|
|
result.setdefault("evidence", "")
|
|
result.setdefault("refs", [])
|
|
return result
|
|
|
|
def _empty_usage(self, context: Optional[dict] = None) -> dict:
|
|
plan = ((context or {}).get("orchestration_plan") or {})
|
|
billing = plan.get("billing_context") or {}
|
|
return {
|
|
"model_id": self.model,
|
|
"model_tokens": 0,
|
|
"prompt_tokens": 0,
|
|
"completion_tokens": 0,
|
|
"model_cost_usd": 0.0,
|
|
"runtime_seconds": 0.0,
|
|
"billing_source": billing.get("provider") or os.getenv("BILLING_SOURCE", "unknown"),
|
|
}
|
|
|
|
def _record_openai_usage(self, response):
|
|
usage = getattr(response, "usage", None)
|
|
if not usage:
|
|
return
|
|
prompt_tokens = int(getattr(usage, "prompt_tokens", 0) or 0)
|
|
completion_tokens = int(getattr(usage, "completion_tokens", 0) or 0)
|
|
total_tokens = int(getattr(usage, "total_tokens", 0) or prompt_tokens + completion_tokens)
|
|
self._add_usage(prompt_tokens, completion_tokens, total_tokens)
|
|
|
|
def _add_usage(self, prompt_tokens: int, completion_tokens: int, total_tokens: int):
|
|
self.usage["prompt_tokens"] += prompt_tokens
|
|
self.usage["completion_tokens"] += completion_tokens
|
|
self.usage["model_tokens"] += total_tokens
|
|
input_cost = float(os.getenv("MODEL_INPUT_COST_PER_1M", "0") or 0)
|
|
output_cost = float(os.getenv("MODEL_OUTPUT_COST_PER_1M", "0") or 0)
|
|
self.usage["model_cost_usd"] += (
|
|
prompt_tokens * input_cost / 1_000_000
|
|
+ completion_tokens * output_cost / 1_000_000
|
|
)
|
|
|
|
def _usage_payload(self, runtime_seconds: float) -> dict:
|
|
usage = dict(self.usage)
|
|
usage["runtime_seconds"] = runtime_seconds
|
|
return usage
|
|
|
|
def _model_attribution_headers(self) -> dict:
|
|
headers = {}
|
|
mapping = {
|
|
"manager_deployment_id": ["X-Agent-Manager-Deployment-ID", "X-Agnet-Manager-Deployment-ID"],
|
|
"swarm_id": ["X-Agent-Swarm-ID", "X-Agnet-Swarm-ID"],
|
|
"task_id": ["X-Agent-Task-ID", "X-Agnet-Task-ID"],
|
|
"agent_role": ["X-Agent-Agent-Role", "X-Agnet-Agent-Role"],
|
|
"correlation_id": ["X-Correlation-ID"],
|
|
"model_id": ["X-Agent-Model-ID", "X-Agnet-Model-ID"],
|
|
}
|
|
for key, header_names in mapping.items():
|
|
value = self.current_context.get(key)
|
|
if value:
|
|
for header in header_names:
|
|
headers[header] = str(value)
|
|
headers.setdefault("X-Agent-Model-ID", self.model)
|
|
headers.setdefault("X-Agnet-Model-ID", self.model)
|
|
return headers
|
|
|
|
def _subtask_handoff_enabled(self) -> bool:
|
|
return os.getenv("ENABLE_SUBTASK_HANDOFF", "false").lower() in {"1", "true", "yes"}
|
|
|
|
def _multi_agent_leaf_mode(self, context: Optional[dict]) -> bool:
|
|
return self._subtask_handoff_enabled() and (context or {}).get("workflow_mode") == "multi_agent"
|
|
|
|
def _allow_dynamic_handoff(self, context: Optional[dict]) -> bool:
|
|
if (context or {}).get("workflow_mode") != "multi_agent":
|
|
return True
|
|
return bool((context or {}).get("allow_handoff", False))
|
|
|
|
def _strip_json_fence(self, content: str) -> str:
|
|
content = content.strip()
|
|
if not content.startswith("```"):
|
|
return content
|
|
lines = content.split("\n")
|
|
if lines and lines[0].startswith("```"):
|
|
lines = lines[1:]
|
|
if lines and lines[-1].strip() == "```":
|
|
lines = lines[:-1]
|
|
return "\n".join(lines).strip()
|
|
|
|
def _parse_json_response(self, content: str) -> dict:
|
|
content = self._strip_json_fence(content)
|
|
try:
|
|
return json.loads(content)
|
|
except json.JSONDecodeError:
|
|
start = content.find("{")
|
|
end = content.rfind("}")
|
|
if start == -1 or end == -1 or end <= start:
|
|
raise
|
|
return json.loads(content[start:end + 1])
|
|
|
|
def _summarize_workspace(self, max_files: int = 80) -> list[str]:
|
|
root = Path(self.workspace_dir)
|
|
if not root.exists():
|
|
return []
|
|
ignored_dirs = {".git", "__pycache__", "node_modules", ".venv", "venv"}
|
|
files = []
|
|
for path in root.rglob("*"):
|
|
if len(files) >= max_files:
|
|
break
|
|
if not path.is_file():
|
|
continue
|
|
if any(part in ignored_dirs for part in path.relative_to(root).parts):
|
|
continue
|
|
files.append(str(path.relative_to(root)))
|
|
return sorted(files)
|
|
|
|
def _collect_workspace_context(self, max_files: int = 20, max_bytes_per_file: int = 6000) -> dict[str, str]:
|
|
root = Path(self.workspace_dir)
|
|
if not root.exists():
|
|
return {}
|
|
ignored_dirs = {".git", "__pycache__", "node_modules", ".venv", "venv"}
|
|
allowed_suffixes = {".py", ".js", ".ts", ".tsx", ".jsx", ".json", ".md", ".txt", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".sh"}
|
|
context = {}
|
|
for path in sorted(root.rglob("*")):
|
|
if len(context) >= max_files:
|
|
break
|
|
if not path.is_file():
|
|
continue
|
|
relative = path.relative_to(root)
|
|
if any(part in ignored_dirs for part in relative.parts):
|
|
continue
|
|
if path.suffix and path.suffix.lower() not in allowed_suffixes:
|
|
continue
|
|
try:
|
|
data = path.read_bytes()[:max_bytes_per_file]
|
|
context[str(relative)] = data.decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
continue
|
|
except Exception as e:
|
|
logger.warning(f"Failed to read workspace file {relative}: {e}")
|
|
return context
|
|
|
|
def _resolve_workspace_path(self, file_path: str) -> Path:
|
|
if not file_path or os.path.isabs(file_path):
|
|
raise ValueError(f"Invalid relative path: {file_path}")
|
|
root = Path(self.workspace_dir).resolve()
|
|
resolved = (root / file_path).resolve()
|
|
if root != resolved and root not in resolved.parents:
|
|
raise ValueError(f"Path escapes workspace: {file_path}")
|
|
return resolved
|
|
|
|
async def _apply_file_changes(self, files: list[dict]) -> dict:
|
|
result = {"files_modified": [], "files_deleted": [], "errors": []}
|
|
for file_change in files:
|
|
path = file_change.get("path")
|
|
action = file_change.get("action", "write")
|
|
try:
|
|
target = self._resolve_workspace_path(path)
|
|
if action == "write":
|
|
content = file_change.get("content")
|
|
if content is None:
|
|
raise ValueError(f"Missing content for {path}")
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_text(content, encoding="utf-8")
|
|
result["files_modified"].append(path)
|
|
elif action == "delete":
|
|
if target.exists():
|
|
target.unlink()
|
|
result["files_deleted"].append(path)
|
|
else:
|
|
raise ValueError(f"Unsupported file action for {path}: {action}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to apply file change {path}: {e}")
|
|
result["errors"].append(f"{path}: {e}")
|
|
return result
|
|
|
|
async def read_file(self, file_path: str) -> Optional[str]:
|
|
try:
|
|
full_path = os.path.join(self.workspace_dir, file_path)
|
|
with open(full_path, "r", encoding="utf-8") as f:
|
|
return f.read()
|
|
except Exception as e:
|
|
logger.error(f"Error reading file {file_path}: {e}")
|
|
return None
|
|
|
|
async def write_file(self, file_path: str, content: str) -> bool:
|
|
try:
|
|
full_path = self._resolve_workspace_path(file_path)
|
|
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
|
with open(full_path, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
logger.info(f"Wrote file: {file_path}")
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Error writing file {file_path}: {e}")
|
|
return False
|