zcbot/tools/software_jobs.py

278 lines
10 KiB
Python

"""Agent 可调用的专业软件任务工具。"""
from __future__ import annotations
import json
from uuid import UUID, uuid4
from core.software_contracts import (
get_contracts,
get_contract,
node_available_slots,
supported_capabilities,
)
from core.software_jobs import (
SoftwareJobError,
create_job,
get_job,
get_job_request,
list_jobs,
request_job_cancel,
revise_job,
)
from core.software_nodes import list_nodes
from .base import Tool
def _contract_property_schema(name: str) -> dict:
schemas = [
item.submission_schema()["properties"][name]
for item in get_contracts().values()
]
unique = {json.dumps(item, ensure_ascii=False, sort_keys=True): item for item in schemas}
values = list(unique.values())
return values[0] if len(values) == 1 else {"anyOf": values}
class _SoftwareJobTool(Tool):
def __init__(self, user_id: UUID, task_id: UUID, **kwargs) -> None:
super().__init__(**kwargs)
self.user_id = user_id
self.task_id = task_id
class SoftwareCapabilityListTool(_SoftwareJobTool):
name = "software_capability_list"
description = "List professional software capabilities available through managed Windows nodes."
parameters = {"type": "object", "properties": {}, "additionalProperties": False}
def execute(self) -> str:
nodes = list_nodes()
items = [{
"capability": item,
"display_name": get_contract(item).display_name,
"available_nodes": sum(
1
for node in nodes
if node["status"] == "online"
and item in (node.get("capabilities") or [])
and node_available_slots(item, node.get("runtime") or {}) > 0
),
} for item in sorted(supported_capabilities())]
return json.dumps({"capabilities": items}, ensure_ascii=False)
class SoftwareJobSubmitTool(_SoftwareJobTool):
name = "software_job_submit"
description = (
"Submit a managed professional-software job using registered artifacts and a "
"capability contract. Call register_artifact first for workspace files. A successful "
"submission is the terminal action for the current run: report the queued job_id once "
"and end the turn. Completion delivery and artifact publication happen automatically "
"in a later system-managed message."
)
@staticmethod
def _parameters() -> dict:
return {
"type": "object",
"properties": {
"capability": {
"type": "string", "enum": sorted(supported_capabilities())
},
"inputs": _contract_property_schema("inputs"),
"operation": _contract_property_schema("operation"),
"outputs": _contract_property_schema("outputs"),
"completion_action": {
"type": "string",
"enum": ["report", "analyze"],
"default": "report",
"description": (
"Use report to notify with output files only. Use analyze when the "
"user also asked for interpretation, conclusions, or data analysis."
),
},
"idempotency_key": {
"type": "string",
"description": (
"Stable unique key for this exact submission; omit to generate one."
),
},
},
"required": ["capability", "inputs", "operation", "outputs"],
"additionalProperties": False,
}
# Compatibility for callers inspecting the class; Tool.schema below is always fresh.
parameters = _parameters()
@property
def schema(self) -> dict:
value = super().schema
value["function"]["parameters"] = self._parameters()
return value
def execute(
self,
capability: str,
inputs: list[dict] | None = None,
operation: dict | None = None,
outputs: list[dict] | None = None,
completion_action: str = "report",
idempotency_key: str = "",
) -> str:
try:
if not inputs or operation is None or not outputs:
return "[Error] inputs, operation, and outputs are required for a software job"
canonical_inputs = []
for item in inputs:
if not isinstance(item, dict):
return "[Error] each input must be an object"
try:
artifact_id = str(UUID(str(item.get("artifact_id") or "")))
except ValueError:
return "[Error] every artifact_id must be a UUID; call register_artifact first"
canonical = {**item, "artifact_id": artifact_id}
canonical_inputs.append(canonical)
normalized_request = {
"schema_version": get_contract(capability).request_schema[
"properties"
]["schema_version"]["const"],
"inputs": canonical_inputs,
"operation": operation,
"outputs": outputs,
}
job, created = create_job(
self.user_id,
self.task_id,
idempotency_key=idempotency_key.strip() or str(uuid4()),
capability=capability,
completion_action=completion_action,
request=normalized_request,
)
return json.dumps({
**job,
"created": created,
"completion_delivery": "automatic",
"next_action": "end_turn_after_reporting_queued_job_id",
}, ensure_ascii=False)
except (SoftwareJobError, ValueError) as exc:
return f"[Error] {exc}"
class SoftwareJobStatusTool(_SoftwareJobTool):
name = "software_job_status"
description = (
"Check one software job, or list recent jobs in the current task when job_id is omitted. "
"A succeeded job returns output_dir as the authoritative starting directory; "
"inspect or search within that directory to analyze its files."
)
parameters = {
"type": "object",
"properties": {"job_id": {"type": "string"}},
"additionalProperties": False,
}
def execute(self, job_id: str = "") -> str:
try:
if job_id.strip():
item = get_job(self.user_id, UUID(job_id.strip()))
if item is None or item["task_id"] != str(self.task_id):
return "[Error] software job not found"
item["editable_request"] = get_job_request(
self.user_id, self.task_id, UUID(job_id.strip())
)
return json.dumps(item, ensure_ascii=False)
return json.dumps(
{"results": list_jobs(self.user_id, task_id=self.task_id, limit=20)},
ensure_ascii=False,
)
except ValueError:
return "[Error] invalid job_id"
class SoftwareJobReviseTool(_SoftwareJobTool):
name = "software_job_revise"
description = (
"Create a new professional-software job from a prior job in the current task. "
"Reuse the prior registered inputs, provide a complete replacement operation and "
"outputs, and leave the prior job and artifacts unchanged. Call software_job_status "
"first when the prior editable_request is not already in context. A successful revision "
"is the terminal action for the current run; completion and artifacts are delivered "
"automatically in a later system-managed message."
)
@staticmethod
def _parameters() -> dict:
return {
"type": "object",
"properties": {
"source_job_id": {"type": "string", "format": "uuid"},
"operation": _contract_property_schema("operation"),
"outputs": _contract_property_schema("outputs"),
"idempotency_key": {
"type": "string",
"description": (
"Stable unique key for this exact revision; omit to generate one."
),
},
},
"required": ["source_job_id", "operation", "outputs"],
"additionalProperties": False,
}
parameters = _parameters()
@property
def schema(self) -> dict:
value = super().schema
value["function"]["parameters"] = self._parameters()
return value
def execute(
self,
source_job_id: str,
operation: dict | None = None,
outputs: list[dict] | None = None,
idempotency_key: str = "",
) -> str:
try:
if operation is None or not outputs:
return "[Error] operation and outputs are required for a software revision"
job, created = revise_job(
self.user_id,
self.task_id,
UUID(source_job_id.strip()),
idempotency_key=idempotency_key.strip() or str(uuid4()),
operation=operation,
outputs=outputs,
)
return json.dumps({
**job,
"created": created,
"completion_delivery": "automatic",
"next_action": "end_turn_after_reporting_queued_job_id",
}, ensure_ascii=False)
except (SoftwareJobError, ValueError) as exc:
return f"[Error] {exc}"
class SoftwareJobCancelTool(_SoftwareJobTool):
name = "software_job_cancel"
description = "Request cancellation of a software job in the current task after the user asks to stop it."
parameters = {
"type": "object",
"properties": {"job_id": {"type": "string"}},
"required": ["job_id"],
"additionalProperties": False,
}
def execute(self, job_id: str) -> str:
try:
item = get_job(self.user_id, UUID(job_id.strip()))
if item is None or item["task_id"] != str(self.task_id):
return "[Error] software job not found"
job, _ = request_job_cancel(self.user_id, UUID(job_id.strip()))
return json.dumps(job, ensure_ascii=False)
except (ValueError, SoftwareJobError) as exc:
return f"[Error] {exc}"