|
@@ -15,13 +15,16 @@ import hashlib
|
|
|
import time
|
|
import time
|
|
|
from typing import Any, Dict, Optional
|
|
from typing import Any, Dict, Optional
|
|
|
|
|
|
|
|
-from fastapi import APIRouter, Depends, HTTPException
|
|
|
|
|
|
|
+from fastapi import APIRouter, Depends, Header, HTTPException
|
|
|
from fastapi.responses import StreamingResponse
|
|
from fastapi.responses import StreamingResponse
|
|
|
from pydantic import BaseModel, Field
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
|
|
from agentpaas.api.deps import get_tenant, get_database
|
|
from agentpaas.api.deps import get_tenant, get_database
|
|
|
|
|
+from agentpaas.api.errors import api_error
|
|
|
from agentpaas.api.middleware.auth import TenantContext
|
|
from agentpaas.api.middleware.auth import TenantContext
|
|
|
from agentpaas.db.models import Database, gen_id, now_utc
|
|
from agentpaas.db.models import Database, gen_id, now_utc
|
|
|
|
|
+from agentpaas.tenant.rbac import require_permission
|
|
|
|
|
+from agentpaas.tenant.quota import check_concurrency, check_and_reserve
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/agents", tags=["agents"])
|
|
router = APIRouter(prefix="/agents", tags=["agents"])
|
|
|
|
|
|
|
@@ -48,6 +51,22 @@ class RollbackRequest(BaseModel):
|
|
|
target_version: int
|
|
target_version: int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+# ── S21: Security Audit Logging ──
|
|
|
|
|
+
|
|
|
|
|
+def _audit_log(db: Database, event_type: str, tenant_id: str,
|
|
|
|
|
+ agent_id: str = "", details: str = ""):
|
|
|
|
|
+ """S21: Record security-relevant events to audit_log table."""
|
|
|
|
|
+ try:
|
|
|
|
|
+ db.execute(
|
|
|
|
|
+ "INSERT INTO audit_log (id, event_type, tenant_id, agent_id, details, created_at) "
|
|
|
|
|
+ "VALUES (?, ?, ?, ?, ?, ?)",
|
|
|
|
|
+ (gen_id("aud_"), event_type, tenant_id, agent_id, details[:4096], now_utc())
|
|
|
|
|
+ )
|
|
|
|
|
+ db.commit()
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ pass # Audit logging should never break the main flow
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
# ── CRUD Endpoints ──
|
|
# ── CRUD Endpoints ──
|
|
|
|
|
|
|
|
@router.post("", status_code=201)
|
|
@router.post("", status_code=201)
|
|
@@ -57,6 +76,7 @@ async def create_agent(
|
|
|
db: Database = Depends(get_database),
|
|
db: Database = Depends(get_database),
|
|
|
):
|
|
):
|
|
|
"""Create a new agent. Auto-compiles config via lambdagent."""
|
|
"""Create a new agent. Auto-compiles config via lambdagent."""
|
|
|
|
|
+ _audit_log(db, "agent.create", tenant.tenant_id, details=f"name={req.name}")
|
|
|
agent_id = gen_id("ag_")
|
|
agent_id = gen_id("ag_")
|
|
|
config_json = json.dumps(req.config, ensure_ascii=False)
|
|
config_json = json.dumps(req.config, ensure_ascii=False)
|
|
|
config_hash = hashlib.sha256(config_json.encode()).hexdigest()
|
|
config_hash = hashlib.sha256(config_json.encode()).hexdigest()
|
|
@@ -65,7 +85,7 @@ async def create_agent(
|
|
|
try:
|
|
try:
|
|
|
_compile_agent(req.config)
|
|
_compile_agent(req.config)
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
- raise HTTPException(400, {"error": {"code": "INVALID_CONFIG", "message": str(e)}})
|
|
|
|
|
|
|
+ api_error(400, "INVALID_CONFIG", str(e))
|
|
|
|
|
|
|
|
now = now_utc()
|
|
now = now_utc()
|
|
|
db.execute(
|
|
db.execute(
|
|
@@ -122,7 +142,7 @@ async def get_agent(
|
|
|
(agent_id, tenant.tenant_id)
|
|
(agent_id, tenant.tenant_id)
|
|
|
)
|
|
)
|
|
|
if not agent:
|
|
if not agent:
|
|
|
- raise HTTPException(404, {"error": {"code": "AGENT_NOT_FOUND", "message": f"Agent {agent_id} not found"}})
|
|
|
|
|
|
|
+ api_error(404, "AGENT_NOT_FOUND", f"Agent {agent_id} not found")
|
|
|
|
|
|
|
|
version = db.fetchone(
|
|
version = db.fetchone(
|
|
|
"SELECT * FROM agent_versions WHERE agent_id = ? AND version = ?",
|
|
"SELECT * FROM agent_versions WHERE agent_id = ? AND version = ?",
|
|
@@ -141,12 +161,14 @@ async def update_agent(
|
|
|
db: Database = Depends(get_database),
|
|
db: Database = Depends(get_database),
|
|
|
):
|
|
):
|
|
|
"""Update agent config. Auto-increments version."""
|
|
"""Update agent config. Auto-increments version."""
|
|
|
|
|
+ require_permission(tenant, "agents:write")
|
|
|
|
|
+ _audit_log(db, "agent.update", tenant.tenant_id, agent_id)
|
|
|
agent = db.fetchone(
|
|
agent = db.fetchone(
|
|
|
"SELECT * FROM agents WHERE id = ? AND tenant_id = ? AND status = 'active'",
|
|
"SELECT * FROM agents WHERE id = ? AND tenant_id = ? AND status = 'active'",
|
|
|
(agent_id, tenant.tenant_id)
|
|
(agent_id, tenant.tenant_id)
|
|
|
)
|
|
)
|
|
|
if not agent:
|
|
if not agent:
|
|
|
- raise HTTPException(404, {"error": {"code": "AGENT_NOT_FOUND"}})
|
|
|
|
|
|
|
+ api_error(404, "AGENT_NOT_FOUND", f"Agent {agent_id} not found")
|
|
|
|
|
|
|
|
config_json = json.dumps(req.config, ensure_ascii=False)
|
|
config_json = json.dumps(req.config, ensure_ascii=False)
|
|
|
config_hash = hashlib.sha256(config_json.encode()).hexdigest()
|
|
config_hash = hashlib.sha256(config_json.encode()).hexdigest()
|
|
@@ -163,7 +185,7 @@ async def update_agent(
|
|
|
try:
|
|
try:
|
|
|
_compile_agent(req.config)
|
|
_compile_agent(req.config)
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
- raise HTTPException(400, {"error": {"code": "INVALID_CONFIG", "message": str(e)}})
|
|
|
|
|
|
|
+ api_error(400, "INVALID_CONFIG", str(e))
|
|
|
|
|
|
|
|
new_version = agent["current_version"] + 1
|
|
new_version = agent["current_version"] + 1
|
|
|
now = now_utc()
|
|
now = now_utc()
|
|
@@ -188,6 +210,8 @@ async def delete_agent(
|
|
|
db: Database = Depends(get_database),
|
|
db: Database = Depends(get_database),
|
|
|
):
|
|
):
|
|
|
"""Soft delete agent."""
|
|
"""Soft delete agent."""
|
|
|
|
|
+ require_permission(tenant, "agents:write")
|
|
|
|
|
+ _audit_log(db, "agent.delete", tenant.tenant_id, agent_id)
|
|
|
db.execute(
|
|
db.execute(
|
|
|
"UPDATE agents SET status = 'deleted', updated_at = ? WHERE id = ? AND tenant_id = ?",
|
|
"UPDATE agents SET status = 'deleted', updated_at = ? WHERE id = ? AND tenant_id = ?",
|
|
|
(now_utc(), agent_id, tenant.tenant_id)
|
|
(now_utc(), agent_id, tenant.tenant_id)
|
|
@@ -209,7 +233,7 @@ async def rollback_agent(
|
|
|
(agent_id, req.target_version)
|
|
(agent_id, req.target_version)
|
|
|
)
|
|
)
|
|
|
if not version:
|
|
if not version:
|
|
|
- raise HTTPException(404, {"error": {"code": "VERSION_NOT_FOUND"}})
|
|
|
|
|
|
|
+ api_error(404, "VERSION_NOT_FOUND", f"Version {req.target_version} not found for agent {agent_id}")
|
|
|
|
|
|
|
|
db.execute(
|
|
db.execute(
|
|
|
"UPDATE agents SET current_version = ?, updated_at = ? WHERE id = ? AND tenant_id = ?",
|
|
"UPDATE agents SET current_version = ?, updated_at = ? WHERE id = ? AND tenant_id = ?",
|
|
@@ -227,6 +251,7 @@ async def run_agent(
|
|
|
req: RunRequest,
|
|
req: RunRequest,
|
|
|
tenant: TenantContext = Depends(get_tenant),
|
|
tenant: TenantContext = Depends(get_tenant),
|
|
|
db: Database = Depends(get_database),
|
|
db: Database = Depends(get_database),
|
|
|
|
|
+ idempotency_key: Optional[str] = Header(None, alias="X-Idempotency-Key"),
|
|
|
):
|
|
):
|
|
|
"""
|
|
"""
|
|
|
Synchronous agent execution.
|
|
Synchronous agent execution.
|
|
@@ -237,13 +262,30 @@ async def run_agent(
|
|
|
let result = Runtime.reduce(term, input) in
|
|
let result = Runtime.reduce(term, input) in
|
|
|
(result, trace)
|
|
(result, trace)
|
|
|
"""
|
|
"""
|
|
|
|
|
+ # SEC-04: RBAC check
|
|
|
|
|
+ require_permission(tenant, "agents:execute")
|
|
|
|
|
+
|
|
|
|
|
+ # Idempotency check — return cached response if already completed
|
|
|
|
|
+ if idempotency_key:
|
|
|
|
|
+ existing = db.fetchone(
|
|
|
|
|
+ "SELECT id, output, status FROM runs WHERE idempotency_key = ? AND tenant_id = ?",
|
|
|
|
|
+ (idempotency_key, tenant.tenant_id)
|
|
|
|
|
+ )
|
|
|
|
|
+ if existing and existing["status"] == "completed":
|
|
|
|
|
+ return existing
|
|
|
|
|
+
|
|
|
|
|
+ # SEC-03: Rate limiting — concurrency and token quota
|
|
|
|
|
+ await check_concurrency(tenant.tenant_id)
|
|
|
|
|
+ await check_and_reserve(tenant.tenant_id)
|
|
|
|
|
+ # S21: Audit log
|
|
|
|
|
+ _audit_log(db, "agent.run", tenant.tenant_id, agent_id)
|
|
|
# Load agent
|
|
# Load agent
|
|
|
agent = db.fetchone(
|
|
agent = db.fetchone(
|
|
|
"SELECT * FROM agents WHERE id = ? AND tenant_id = ? AND status = 'active'",
|
|
"SELECT * FROM agents WHERE id = ? AND tenant_id = ? AND status = 'active'",
|
|
|
(agent_id, tenant.tenant_id)
|
|
(agent_id, tenant.tenant_id)
|
|
|
)
|
|
)
|
|
|
if not agent:
|
|
if not agent:
|
|
|
- raise HTTPException(404, {"error": {"code": "AGENT_NOT_FOUND"}})
|
|
|
|
|
|
|
+ api_error(404, "AGENT_NOT_FOUND", f"Agent {agent_id} not found")
|
|
|
|
|
|
|
|
version_rec = db.fetchone(
|
|
version_rec = db.fetchone(
|
|
|
"SELECT * FROM agent_versions WHERE agent_id = ? AND version = ?",
|
|
"SELECT * FROM agent_versions WHERE agent_id = ? AND version = ?",
|
|
@@ -263,28 +305,34 @@ async def run_agent(
|
|
|
run_id = gen_id("run_")
|
|
run_id = gen_id("run_")
|
|
|
now = now_utc()
|
|
now = now_utc()
|
|
|
db.execute(
|
|
db.execute(
|
|
|
- "INSERT INTO runs (id, agent_id, agent_version, tenant_id, input, status, created_at) "
|
|
|
|
|
- "VALUES (?, ?, ?, ?, ?, 'running', ?)",
|
|
|
|
|
- (run_id, agent_id, agent["current_version"], tenant.tenant_id, req.input, now)
|
|
|
|
|
|
|
+ "INSERT INTO runs (id, agent_id, agent_version, tenant_id, input, status, idempotency_key, created_at) "
|
|
|
|
|
+ "VALUES (?, ?, ?, ?, ?, 'running', ?, ?)",
|
|
|
|
|
+ (run_id, agent_id, agent["current_version"], tenant.tenant_id, req.input, idempotency_key, now)
|
|
|
)
|
|
)
|
|
|
db.commit()
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
+ # Resolve agent_dir (from DB or empty)
|
|
|
|
|
+ agent_dir = agent.get("agent_dir", "") or ""
|
|
|
|
|
+
|
|
|
# Execute via lambdagent
|
|
# Execute via lambdagent
|
|
|
t0 = time.time()
|
|
t0 = time.time()
|
|
|
try:
|
|
try:
|
|
|
- result, trace_info = _execute_agent(config, req.input)
|
|
|
|
|
|
|
+ result, trace_info = _execute_agent(
|
|
|
|
|
+ config, req.input, agent_dir=agent_dir, run_id=run_id,
|
|
|
|
|
+ )
|
|
|
duration_ms = int((time.time() - t0) * 1000)
|
|
duration_ms = int((time.time() - t0) * 1000)
|
|
|
|
|
+ workspace_path = trace_info.get("workspace_path", "")
|
|
|
|
|
|
|
|
db.execute(
|
|
db.execute(
|
|
|
"UPDATE runs SET status='completed', output=?, duration_ms=?, "
|
|
"UPDATE runs SET status='completed', output=?, duration_ms=?, "
|
|
|
- "input_tokens=?, output_tokens=?, steps=?, completed_at=? WHERE id=?",
|
|
|
|
|
|
|
+ "input_tokens=?, output_tokens=?, steps=?, workspace_path=?, completed_at=? WHERE id=?",
|
|
|
(str(result), duration_ms,
|
|
(str(result), duration_ms,
|
|
|
trace_info.get("input_tokens", 0), trace_info.get("output_tokens", 0),
|
|
trace_info.get("input_tokens", 0), trace_info.get("output_tokens", 0),
|
|
|
- trace_info.get("steps", 0), now_utc(), run_id)
|
|
|
|
|
|
|
+ trace_info.get("steps", 0), workspace_path, now_utc(), run_id)
|
|
|
)
|
|
)
|
|
|
db.commit()
|
|
db.commit()
|
|
|
|
|
|
|
|
- return {
|
|
|
|
|
|
|
+ resp = {
|
|
|
"run_id": run_id,
|
|
"run_id": run_id,
|
|
|
"status": "completed",
|
|
"status": "completed",
|
|
|
"output": str(result),
|
|
"output": str(result),
|
|
@@ -296,6 +344,9 @@ async def run_agent(
|
|
|
"duration_ms": duration_ms,
|
|
"duration_ms": duration_ms,
|
|
|
},
|
|
},
|
|
|
}
|
|
}
|
|
|
|
|
+ if workspace_path:
|
|
|
|
|
+ resp["workspace_path"] = workspace_path
|
|
|
|
|
+ return resp
|
|
|
|
|
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
duration_ms = int((time.time() - t0) * 1000)
|
|
duration_ms = int((time.time() - t0) * 1000)
|
|
@@ -307,7 +358,7 @@ async def run_agent(
|
|
|
(json.dumps({"code": type(e).__name__}), duration_ms, now_utc(), run_id)
|
|
(json.dumps({"code": type(e).__name__}), duration_ms, now_utc(), run_id)
|
|
|
)
|
|
)
|
|
|
db.commit()
|
|
db.commit()
|
|
|
- raise HTTPException(500, {"error": {"code": "EXECUTION_ERROR", "message": "Agent execution failed", "run_id": run_id}})
|
|
|
|
|
|
|
+ api_error(500, "EXECUTION_ERROR", "Agent execution failed", details={"run_id": run_id})
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Streaming Execution (SSE) ──
|
|
# ── Streaming Execution (SSE) ──
|
|
@@ -335,7 +386,7 @@ async def run_agent_stream(
|
|
|
(agent_id, tenant.tenant_id)
|
|
(agent_id, tenant.tenant_id)
|
|
|
)
|
|
)
|
|
|
if not agent:
|
|
if not agent:
|
|
|
- raise HTTPException(404, {"error": {"code": "AGENT_NOT_FOUND"}})
|
|
|
|
|
|
|
+ api_error(404, "AGENT_NOT_FOUND", f"Agent {agent_id} not found")
|
|
|
|
|
|
|
|
version_rec = db.fetchone(
|
|
version_rec = db.fetchone(
|
|
|
"SELECT * FROM agent_versions WHERE agent_id = ? AND version = ?",
|
|
"SELECT * FROM agent_versions WHERE agent_id = ? AND version = ?",
|
|
@@ -350,19 +401,27 @@ async def run_agent_stream(
|
|
|
target = target.setdefault(p, {})
|
|
target = target.setdefault(p, {})
|
|
|
target[parts[-1]] = val
|
|
target[parts[-1]] = val
|
|
|
|
|
|
|
|
|
|
+ # Resolve agent_dir
|
|
|
|
|
+ agent_dir = agent.get("agent_dir", "") or ""
|
|
|
|
|
+
|
|
|
import queue
|
|
import queue
|
|
|
- event_queue = queue.Queue()
|
|
|
|
|
|
|
+ # FIX-09: Bounded queue prevents OOM from slow clients
|
|
|
|
|
+ event_queue = queue.Queue(maxsize=1000)
|
|
|
|
|
|
|
|
def generate():
|
|
def generate():
|
|
|
import threading
|
|
import threading
|
|
|
|
|
|
|
|
def _run():
|
|
def _run():
|
|
|
try:
|
|
try:
|
|
|
- result, trace_info = _execute_agent(config, req.input, on_step=event_queue.put)
|
|
|
|
|
|
|
+ result, trace_info = _execute_agent(
|
|
|
|
|
+ config, req.input, on_step=event_queue.put,
|
|
|
|
|
+ agent_dir=agent_dir, run_id="",
|
|
|
|
|
+ )
|
|
|
event_queue.put({"event": "done", "data": {
|
|
event_queue.put({"event": "done", "data": {
|
|
|
"status": "completed", "output": str(result),
|
|
"status": "completed", "output": str(result),
|
|
|
"steps": trace_info.get("steps", 0),
|
|
"steps": trace_info.get("steps", 0),
|
|
|
"total_tokens": trace_info.get("total_tokens", 0),
|
|
"total_tokens": trace_info.get("total_tokens", 0),
|
|
|
|
|
+ "workspace_path": trace_info.get("workspace_path", ""),
|
|
|
}})
|
|
}})
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
event_queue.put({"event": "error", "data": {"message": str(e)}})
|
|
event_queue.put({"event": "error", "data": {"message": str(e)}})
|
|
@@ -392,6 +451,7 @@ async def list_versions(
|
|
|
db: Database = Depends(get_database),
|
|
db: Database = Depends(get_database),
|
|
|
):
|
|
):
|
|
|
"""List all versions of an agent."""
|
|
"""List all versions of an agent."""
|
|
|
|
|
+ require_permission(tenant, "agents:read")
|
|
|
versions = db.fetchall(
|
|
versions = db.fetchall(
|
|
|
"SELECT v.*, a.current_version FROM agent_versions v "
|
|
"SELECT v.*, a.current_version FROM agent_versions v "
|
|
|
"JOIN agents a ON v.agent_id = a.id "
|
|
"JOIN agents a ON v.agent_id = a.id "
|
|
@@ -431,7 +491,7 @@ async def record_run(
|
|
|
(agent_id, tenant.tenant_id)
|
|
(agent_id, tenant.tenant_id)
|
|
|
)
|
|
)
|
|
|
if not agent:
|
|
if not agent:
|
|
|
- raise HTTPException(404, {"error": {"code": "AGENT_NOT_FOUND"}})
|
|
|
|
|
|
|
+ api_error(404, "AGENT_NOT_FOUND", f"Agent {agent_id} not found")
|
|
|
|
|
|
|
|
run_id = gen_id("run_")
|
|
run_id = gen_id("run_")
|
|
|
now = now_utc()
|
|
now = now_utc()
|
|
@@ -484,17 +544,34 @@ def _compile_agent(config: dict):
|
|
|
os.unlink(tmp_path)
|
|
os.unlink(tmp_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
-def _execute_agent(config: dict, input_text: str, on_step=None):
|
|
|
|
|
|
|
+def _execute_agent(config: dict, input_text: str, on_step=None,
|
|
|
|
|
+ agent_dir: str = "", run_id: str = ""):
|
|
|
"""Execute agent via lambdagent. Returns (result, trace_info).
|
|
"""Execute agent via lambdagent. Returns (result, trace_info).
|
|
|
|
|
|
|
|
Args:
|
|
Args:
|
|
|
- on_step: Optional callback for streaming. Receives StepEvent dicts
|
|
|
|
|
- from ReActEngine with keys: event, data.
|
|
|
|
|
|
|
+ config: Agent YAML config dict.
|
|
|
|
|
+ input_text: User prompt.
|
|
|
|
|
+ on_step: Optional callback for streaming.
|
|
|
|
|
+ agent_dir: Agent directory path — if set, creates workspace/run_{timestamp}/.
|
|
|
|
|
+ run_id: Run ID for workspace metadata.
|
|
|
"""
|
|
"""
|
|
|
- import tempfile, yaml, os
|
|
|
|
|
- with tempfile.NamedTemporaryFile(mode='w', suffix='.yml', delete=False, encoding='utf-8') as f:
|
|
|
|
|
- yaml.dump(config, f, allow_unicode=True)
|
|
|
|
|
- tmp_path = f.name
|
|
|
|
|
|
|
+ import yaml, os
|
|
|
|
|
+ from agentpaas.engine.sandbox import create_run_workspace, save_run_artifacts
|
|
|
|
|
+
|
|
|
|
|
+ # 创建 run workspace(如果有 agent_dir)
|
|
|
|
|
+ workspace_path = ""
|
|
|
|
|
+ if agent_dir:
|
|
|
|
|
+ workspace_path = create_run_workspace(agent_dir, run_id, input_text, config)
|
|
|
|
|
+
|
|
|
|
|
+ # 配置写入 workspace 或 /tmp
|
|
|
|
|
+ if workspace_path:
|
|
|
|
|
+ config_path = os.path.join(workspace_path, "config.yml")
|
|
|
|
|
+ else:
|
|
|
|
|
+ import tempfile
|
|
|
|
|
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.yml', delete=False, encoding='utf-8') as f:
|
|
|
|
|
+ yaml.dump(config, f, allow_unicode=True)
|
|
|
|
|
+ config_path = f.name
|
|
|
|
|
+
|
|
|
try:
|
|
try:
|
|
|
from lambdagent.fromconfig import from_config
|
|
from lambdagent.fromconfig import from_config
|
|
|
from lambdagent.core import Context
|
|
from lambdagent.core import Context
|
|
@@ -524,8 +601,8 @@ def _execute_agent(config: dict, input_text: str, on_step=None):
|
|
|
})
|
|
})
|
|
|
overrides["on_chunk"] = _chunk_adapter
|
|
overrides["on_chunk"] = _chunk_adapter
|
|
|
|
|
|
|
|
- term = from_config(tmp_path, **overrides)
|
|
|
|
|
- ctx = Context()
|
|
|
|
|
|
|
+ term = from_config(config_path, **overrides)
|
|
|
|
|
+ ctx = Context(workspace_path=workspace_path, run_id=run_id)
|
|
|
result = term.apply(input_text, ctx)
|
|
result = term.apply(input_text, ctx)
|
|
|
|
|
|
|
|
# Extract trace info
|
|
# Extract trace info
|
|
@@ -534,7 +611,21 @@ def _execute_agent(config: dict, input_text: str, on_step=None):
|
|
|
"input_tokens": sum(getattr(e, 'tokens_used', 0) for e in ctx.trace),
|
|
"input_tokens": sum(getattr(e, 'tokens_used', 0) for e in ctx.trace),
|
|
|
"output_tokens": 0,
|
|
"output_tokens": 0,
|
|
|
"total_tokens": sum(getattr(e, 'tokens_used', 0) for e in ctx.trace),
|
|
"total_tokens": sum(getattr(e, 'tokens_used', 0) for e in ctx.trace),
|
|
|
|
|
+ "workspace_path": workspace_path,
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ # 保存 run 产物
|
|
|
|
|
+ if workspace_path:
|
|
|
|
|
+ save_run_artifacts(
|
|
|
|
|
+ workspace_path, str(result), ctx.trace,
|
|
|
|
|
+ 0, trace_info["input_tokens"], 0, trace_info["steps"],
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
return result, trace_info
|
|
return result, trace_info
|
|
|
finally:
|
|
finally:
|
|
|
- os.unlink(tmp_path)
|
|
|
|
|
|
|
+ # 只删除 /tmp 临时文件,workspace 里的保留
|
|
|
|
|
+ if not workspace_path:
|
|
|
|
|
+ try:
|
|
|
|
|
+ os.unlink(config_path)
|
|
|
|
|
+ except OSError:
|
|
|
|
|
+ pass
|