feat: plans - longrunning tasks
This commit is contained in:
+206
-6
@@ -148,6 +148,7 @@ class PendingAction:
|
||||
endpoint: str
|
||||
payload: dict[str, Any]
|
||||
method: str = "POST"
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
@@ -159,6 +160,8 @@ class ToolRegistry:
|
||||
scheduler: WakeScheduler | None = None,
|
||||
scmdb: SCMDBClient | None = None,
|
||||
cornerstone: CornerstoneClient | None = None,
|
||||
plan_store: Any | None = None,
|
||||
plan_runner: Any | None = None,
|
||||
) -> None:
|
||||
self.uex = uex
|
||||
self.scmdb = scmdb or SCMDBClient()
|
||||
@@ -166,6 +169,8 @@ class ToolRegistry:
|
||||
self.require_write_approval = require_write_approval
|
||||
self.memory = memory
|
||||
self.scheduler = scheduler
|
||||
self.plan_store = plan_store
|
||||
self.plan_runner = plan_runner
|
||||
self.pending_actions: dict[str, PendingAction] = {}
|
||||
self.handlers: dict[str, ToolHandler] = {
|
||||
"search_marketplace_listings": self.search_marketplace_listings,
|
||||
@@ -178,6 +183,13 @@ class ToolRegistry:
|
||||
"recall_memory": self.recall_memory,
|
||||
"schedule_wake_job": self.schedule_wake_job,
|
||||
"list_wake_jobs": self.list_wake_jobs,
|
||||
"create_continual_plan": self.create_continual_plan,
|
||||
"list_continual_plans": self.list_continual_plans,
|
||||
"get_continual_plan": self.get_continual_plan,
|
||||
"pause_continual_plan": self.pause_continual_plan,
|
||||
"resume_continual_plan": self.resume_continual_plan,
|
||||
"cancel_continual_plan": self.cancel_continual_plan,
|
||||
"run_continual_plan_now": self.run_continual_plan_now,
|
||||
"check_uex_notifications": self.check_uex_notifications,
|
||||
"list_scmdb_versions": self.list_scmdb_versions,
|
||||
"search_scmdb_missions": self.search_scmdb_missions,
|
||||
@@ -285,6 +297,11 @@ class ToolRegistry:
|
||||
"message": {"type": "string"},
|
||||
"hash": {"type": "string"},
|
||||
"id_negotiation": {"type": "integer"},
|
||||
"id_listing": {"type": "integer"},
|
||||
"plan_id": {"type": "string"},
|
||||
"plan_item_id": {"type": "integer"},
|
||||
"candidate_id": {"type": "integer"},
|
||||
"listing_slug": {"type": "string"},
|
||||
"is_production": {"type": "integer", "enum": [0, 1], "default": 1},
|
||||
},
|
||||
},
|
||||
@@ -376,6 +393,83 @@ class ToolRegistry:
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_continual_plan",
|
||||
"description": "Create a durable multi-run plan. Use this for long-running marketplace work over days. kind=buying uses structured listing/candidate tracking; kind=custom continues through an agent wake prompt. All UEX writes are draft-only for approval.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"required": ["title", "objective"],
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"objective": {"type": "string"},
|
||||
"kind": {"type": "string", "enum": ["buying", "custom"], "default": "buying"},
|
||||
"cadence": {"type": "string", "description": "Five-field cron expression, default every six hours."},
|
||||
"constraints": {"type": "object", "description": "Plan-specific options such as message_tone, excluded_sellers, preferred_locations, max_unit_price, or custom instructions."},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"item_name": {"type": "string"},
|
||||
"desired_quantity": {"type": "integer", "minimum": 1},
|
||||
"max_unit_price": {"type": "number"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_continual_plans",
|
||||
"description": "List durable continual plans and their statuses.",
|
||||
"parameters": {"type": "object", "properties": {"include_inactive": {"type": "boolean", "default": True}}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_continual_plan",
|
||||
"description": "Get one continual plan with checklist items, candidates, negotiations, and event history.",
|
||||
"parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "pause_continual_plan",
|
||||
"description": "Pause a continual plan so scheduled runs stop.",
|
||||
"parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "resume_continual_plan",
|
||||
"description": "Resume a paused or needs-input continual plan. It only becomes active when it has checklist items.",
|
||||
"parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "cancel_continual_plan",
|
||||
"description": "Cancel a continual plan.",
|
||||
"parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run_continual_plan_now",
|
||||
"description": "Run one continual plan immediately and put the result in the Inbox.",
|
||||
"parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -400,13 +494,17 @@ class ToolRegistry:
|
||||
if not action:
|
||||
return {"error": f"Pending action not found: {action_id}"}
|
||||
if action.method == "DELETE":
|
||||
return await self.uex.delete(action.endpoint, action.payload, authenticated=True)
|
||||
return await self.uex.post(action.endpoint, self._production_payload(action.endpoint, action.payload), authenticated=True)
|
||||
result = await self.uex.delete(action.endpoint, action.payload, authenticated=True)
|
||||
else:
|
||||
result = await self.uex.post(action.endpoint, self._production_payload(action.endpoint, action.payload), authenticated=True)
|
||||
self._record_pending_action_result(action, "approved", result)
|
||||
return result
|
||||
|
||||
async def decline(self, action_id: str) -> dict[str, Any]:
|
||||
action = self.pending_actions.pop(action_id, None)
|
||||
if not action:
|
||||
return {"error": f"Pending action not found: {action_id}"}
|
||||
self._record_pending_action_result(action, "declined", {})
|
||||
return {
|
||||
"declined": True,
|
||||
"pending_action": {
|
||||
@@ -415,6 +513,7 @@ class ToolRegistry:
|
||||
"method": action.method,
|
||||
"endpoint": action.endpoint,
|
||||
"payload": action.payload,
|
||||
"metadata": action.metadata or {},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1046,10 +1145,24 @@ class ToolRegistry:
|
||||
message: str,
|
||||
hash: str | None = None,
|
||||
id_negotiation: int | None = None,
|
||||
id_listing: int | None = None,
|
||||
plan_id: str | None = None,
|
||||
plan_item_id: int | None = None,
|
||||
candidate_id: int | None = None,
|
||||
listing_slug: str | None = None,
|
||||
is_production: int = 1,
|
||||
) -> dict[str, Any]:
|
||||
payload = {"message": message, "hash": hash, "id_negotiation": id_negotiation, "is_production": is_production}
|
||||
return self._pending("Send negotiation message", "marketplace_negotiations_messages", payload)
|
||||
payload = {"message": message, "hash": hash, "id_negotiation": id_negotiation, "id_listing": id_listing, "is_production": is_production}
|
||||
metadata = {
|
||||
"plan_id": plan_id,
|
||||
"plan_item_id": plan_item_id,
|
||||
"candidate_id": candidate_id,
|
||||
"listing_id": id_listing,
|
||||
"listing_slug": listing_slug,
|
||||
"hash": hash,
|
||||
"id_negotiation": id_negotiation,
|
||||
}
|
||||
return self._pending("Send negotiation message", "marketplace_negotiations_messages", payload, metadata=metadata)
|
||||
|
||||
async def draft_marketplace_listing(self, **payload: Any) -> dict[str, Any]:
|
||||
return self._pending("Post marketplace listing", "marketplace_advertise", payload)
|
||||
@@ -1083,6 +1196,68 @@ class ToolRegistry:
|
||||
return {"error": "Scheduler is not configured."}
|
||||
return {"scheduled_jobs": self.scheduler.list_jobs()}
|
||||
|
||||
async def create_continual_plan(
|
||||
self,
|
||||
title: str,
|
||||
objective: str,
|
||||
kind: str = "buying",
|
||||
items: list[dict[str, Any]] | None = None,
|
||||
constraints: dict[str, Any] | None = None,
|
||||
cadence: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if self.plan_store is None:
|
||||
return {"error": "Continual plan store is not configured."}
|
||||
plan = self.plan_store.create_plan(title, kind=kind, objective=objective, items=items or [], constraints=constraints or {}, cadence=cadence)
|
||||
if self.scheduler is not None and plan.get("status") == "active":
|
||||
self.scheduler.schedule_plan(plan)
|
||||
plan = self.plan_store.get_plan(plan["id"]) or plan
|
||||
return {"plan": plan}
|
||||
|
||||
async def list_continual_plans(self, include_inactive: bool = True) -> dict[str, Any]:
|
||||
if self.plan_store is None:
|
||||
return {"error": "Continual plan store is not configured."}
|
||||
return {"plans": self.plan_store.list_plans(include_inactive=include_inactive)}
|
||||
|
||||
async def get_continual_plan(self, plan_id: str) -> dict[str, Any]:
|
||||
if self.plan_store is None:
|
||||
return {"error": "Continual plan store is not configured."}
|
||||
plan = self.plan_store.get_plan(plan_id)
|
||||
if not plan:
|
||||
return {"error": f"Plan not found: {plan_id}"}
|
||||
return {"plan": plan}
|
||||
|
||||
async def pause_continual_plan(self, plan_id: str) -> dict[str, Any]:
|
||||
if self.plan_store is None:
|
||||
return {"error": "Continual plan store is not configured."}
|
||||
if self.scheduler is not None:
|
||||
self.scheduler.unschedule_plan(plan_id)
|
||||
return {"plan": self.plan_store.set_status(plan_id, "paused")}
|
||||
|
||||
async def resume_continual_plan(self, plan_id: str) -> dict[str, Any]:
|
||||
if self.plan_store is None:
|
||||
return {"error": "Continual plan store is not configured."}
|
||||
plan = self.plan_store.get_plan(plan_id)
|
||||
if not plan:
|
||||
return {"error": f"Plan not found: {plan_id}"}
|
||||
next_status = "active" if plan.get("items") else "needs_input"
|
||||
plan = self.plan_store.set_status(plan_id, next_status)
|
||||
if self.scheduler is not None and plan and plan.get("status") == "active":
|
||||
self.scheduler.schedule_plan(plan)
|
||||
plan = self.plan_store.get_plan(plan_id)
|
||||
return {"plan": plan}
|
||||
|
||||
async def cancel_continual_plan(self, plan_id: str) -> dict[str, Any]:
|
||||
if self.plan_store is None:
|
||||
return {"error": "Continual plan store is not configured."}
|
||||
if self.scheduler is not None:
|
||||
self.scheduler.unschedule_plan(plan_id)
|
||||
return {"plan": self.plan_store.set_status(plan_id, "canceled")}
|
||||
|
||||
async def run_continual_plan_now(self, plan_id: str) -> dict[str, Any]:
|
||||
if self.plan_runner is None:
|
||||
return {"error": "Continual plan runner is not configured."}
|
||||
return await self.plan_runner.run_plan(plan_id)
|
||||
|
||||
async def check_uex_notifications(self) -> dict[str, Any]:
|
||||
response = await self.uex.get_user_notifications()
|
||||
notifications = response.get("notifications") or []
|
||||
@@ -1280,11 +1455,19 @@ class ToolRegistry:
|
||||
"locations": locations[:limit],
|
||||
}
|
||||
|
||||
def _pending(self, label: str, endpoint: str, payload: dict[str, Any], method: str = "POST") -> dict[str, Any]:
|
||||
def _pending(
|
||||
self,
|
||||
label: str,
|
||||
endpoint: str,
|
||||
payload: dict[str, Any],
|
||||
method: str = "POST",
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
action_id = str(uuid.uuid4())
|
||||
payload = {key: value for key, value in payload.items() if value is not None}
|
||||
metadata = {key: value for key, value in (metadata or {}).items() if value is not None}
|
||||
payload = self._production_payload(endpoint, payload)
|
||||
self.pending_actions[action_id] = PendingAction(action_id, label, endpoint, payload, method)
|
||||
self.pending_actions[action_id] = PendingAction(action_id, label, endpoint, payload, method, metadata)
|
||||
return {
|
||||
"pending_action": {
|
||||
"id": action_id,
|
||||
@@ -1292,10 +1475,27 @@ class ToolRegistry:
|
||||
"method": method,
|
||||
"endpoint": endpoint,
|
||||
"payload": payload,
|
||||
"metadata": metadata,
|
||||
"approval_required": self.require_write_approval,
|
||||
}
|
||||
}
|
||||
|
||||
def _record_pending_action_result(self, action: PendingAction, result_kind: str, result: dict[str, Any]) -> None:
|
||||
metadata = action.metadata or {}
|
||||
plan_id = metadata.get("plan_id")
|
||||
if not plan_id or self.plan_store is None:
|
||||
return
|
||||
message = f"{action.label} {result_kind} for continual plan."
|
||||
event_metadata = {"action_id": action.id, "endpoint": action.endpoint, "payload": action.payload, "result": result, **metadata}
|
||||
self.plan_store.add_event(plan_id, result_kind, message, event_metadata)
|
||||
if result_kind == "approved" and action.endpoint == "marketplace_negotiations_messages":
|
||||
self.plan_store.add_negotiation(
|
||||
plan_id,
|
||||
metadata.get("plan_item_id"),
|
||||
metadata.get("candidate_id"),
|
||||
{**metadata, "status": "approved"},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _production_payload(endpoint: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if endpoint not in UEX_PRODUCTION_WRITE_RESOURCES:
|
||||
|
||||
Reference in New Issue
Block a user