feat: plans - longrunning tasks

This commit is contained in:
2026-05-07 23:54:58 -04:00
parent d6c2d57fd9
commit e2f87481d6
9 changed files with 1533 additions and 19 deletions
+206 -6
View File
@@ -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: