licos-agent-runtime 0.3.4__tar.gz → 0.3.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/PKG-INFO +2 -2
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/pyproject.toml +2 -2
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/app.py +105 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/run_config.py +1 -1
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/service.py +129 -44
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/stream_runner.py +31 -17
- licos_agent_runtime-0.3.6/src/licos_agent_runtime/workflow_checkpoint.py +194 -0
- licos_agent_runtime-0.3.6/src/licos_agent_runtime/workflow_durability.py +240 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_execution_errors.py +99 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_service_stream_protocol.py +29 -1
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_stream_runner.py +58 -1
- licos_agent_runtime-0.3.6/tests/test_workflow_checkpoint.py +137 -0
- licos_agent_runtime-0.3.6/tests/test_workflow_durability.py +153 -0
- licos_agent_runtime-0.3.6/tests/test_workflow_durability_routes.py +75 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_workflow_protocol.py +2 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/.gitignore +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/README.md +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/__init__.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/__main__.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/agent_canvas.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/agent_config.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/agent_graph.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/agent_input.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/attachments.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/context.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/credit_availability.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/errors.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/event_store.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/event_subscriptions.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/graph_contract.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/graph_loader.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/harness_approval.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/harness_config.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/harness_execution.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/harness_planning.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/harness_verification.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/logging.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/model_identity.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/model_usage.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/observability.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/openai.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/output_files.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/parser.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/payload.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/project_agent.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/project_files.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/project_skills.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/runtime_config.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/session_files.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/session_memory.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/stream_protocol.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/telemetry.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/trace_records.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/verify.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/workflow_canvas.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/workflow_protocol.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_agent_config.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_agent_graph.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_app_cors.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_attachment_inputs.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_context.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_credit_availability.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_credit_availability_routes.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_deployment_layout.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_event_delivery_api.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_event_subscriptions.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_graph_contract.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_graph_loader.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_harness.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_model_identity.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_model_usage.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_observability.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_openai.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_parser.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_payload.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_project_files.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_project_skills.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_run_config.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_runtime_config.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_static_assets.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_stream_protocol.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_trace_records.py +0 -0
- {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_verify.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: licos-agent-runtime
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.6
|
|
4
4
|
Summary: LICOS Python agent runtime for LangGraph/LangChain projects
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Requires-Dist: fastapi<1,>=0.121
|
|
@@ -15,7 +15,7 @@ Requires-Dist: langgraph-sdk<1,>=0.3
|
|
|
15
15
|
Requires-Dist: langgraph<2,>=1.1
|
|
16
16
|
Requires-Dist: langsmith<1,>=0.4
|
|
17
17
|
Requires-Dist: licos-dev-sdk>=0.4.9
|
|
18
|
-
Requires-Dist: licos-platform-sdk>=0.5.
|
|
18
|
+
Requires-Dist: licos-platform-sdk>=0.5.8
|
|
19
19
|
Requires-Dist: pydantic<3,>=2.12
|
|
20
20
|
Requires-Dist: pyyaml<7,>=6
|
|
21
21
|
Requires-Dist: uvicorn<1,>=0.38
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "licos-agent-runtime"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.6"
|
|
8
8
|
description = "LICOS Python agent runtime for LangGraph/LangChain projects"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -21,7 +21,7 @@ dependencies = [
|
|
|
21
21
|
"langgraph-checkpoint-postgres>=3.0,<4",
|
|
22
22
|
"langsmith>=0.4,<1",
|
|
23
23
|
"httpx>=0.27,<1",
|
|
24
|
-
"licos-platform-sdk>=0.5.
|
|
24
|
+
"licos-platform-sdk>=0.5.8",
|
|
25
25
|
"licos-dev-sdk>=0.4.9",
|
|
26
26
|
"filelock>=3.16,<4",
|
|
27
27
|
"PyYAML>=6,<7",
|
|
@@ -26,6 +26,8 @@ from .service import GraphService
|
|
|
26
26
|
from .session_files import session_user_id
|
|
27
27
|
from .stream_runner import RunOpt, agent_stream_handler, workflow_stream_handler
|
|
28
28
|
from .trace_records import clear_run_records, get_run_record, list_run_records
|
|
29
|
+
from .workflow_durability import WorkflowInstanceConflict, WorkflowInstanceStore, load_workflow_durability_config
|
|
30
|
+
from .workflow_protocol import workflow_end_event, workflow_log_id, workflow_start_event
|
|
29
31
|
|
|
30
32
|
logger = logging.getLogger(__name__)
|
|
31
33
|
|
|
@@ -52,6 +54,28 @@ def create_app(service: GraphService | None = None) -> FastAPI:
|
|
|
52
54
|
subscriptions = EventSubscriptions(runtime_service)
|
|
53
55
|
interactive_runs: dict[str, tuple[asyncio.Task, str, float]] = {}
|
|
54
56
|
|
|
57
|
+
def durable_store() -> WorkflowInstanceStore:
|
|
58
|
+
config = load_workflow_durability_config()
|
|
59
|
+
if config is None or is_agent_project():
|
|
60
|
+
raise HTTPException(status_code=404, detail="Durable WORKFLOW is not enabled")
|
|
61
|
+
return WorkflowInstanceStore(config)
|
|
62
|
+
|
|
63
|
+
async def prepare_durable(payload: dict[str, Any], ctx: Any) -> Any:
|
|
64
|
+
config = load_workflow_durability_config() if not is_agent_project() else None
|
|
65
|
+
if config is None:
|
|
66
|
+
return None
|
|
67
|
+
store = WorkflowInstanceStore(config)
|
|
68
|
+
try:
|
|
69
|
+
cached = await store.begin(payload, ctx)
|
|
70
|
+
except WorkflowInstanceConflict as exc:
|
|
71
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
72
|
+
except PermissionError as exc:
|
|
73
|
+
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
74
|
+
except ValueError as exc:
|
|
75
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
76
|
+
ctx.metadata["workflow_store"] = store
|
|
77
|
+
return cached
|
|
78
|
+
|
|
55
79
|
@asynccontextmanager
|
|
56
80
|
async def lifespan(app: FastAPI):
|
|
57
81
|
try:
|
|
@@ -172,6 +196,9 @@ def create_app(service: GraphService | None = None) -> FastAPI:
|
|
|
172
196
|
watch_task.cancel()
|
|
173
197
|
runtime_service.approvals.unwatch(ctx.run_id)
|
|
174
198
|
else:
|
|
199
|
+
cached = await prepare_durable(payload, ctx)
|
|
200
|
+
if cached is not None:
|
|
201
|
+
return cached
|
|
175
202
|
result = await runtime_service.run_with_timeout(payload, ctx)
|
|
176
203
|
if isinstance(result, dict) and result.get("status") == "timeout":
|
|
177
204
|
status_code = 504
|
|
@@ -199,6 +226,8 @@ def create_app(service: GraphService | None = None) -> FastAPI:
|
|
|
199
226
|
error_code = str(exc.code)
|
|
200
227
|
error_message = str(exc)
|
|
201
228
|
raise
|
|
229
|
+
except HTTPException:
|
|
230
|
+
raise
|
|
202
231
|
except Exception as exc:
|
|
203
232
|
status_code = 500
|
|
204
233
|
error_response = runtime_service.error_classifier.get_error_response(
|
|
@@ -264,6 +293,16 @@ def create_app(service: GraphService | None = None) -> FastAPI:
|
|
|
264
293
|
workspace_id=ctx.identity.workspace_id,
|
|
265
294
|
)
|
|
266
295
|
|
|
296
|
+
cached = await prepare_durable(payload, ctx)
|
|
297
|
+
if cached is not None:
|
|
298
|
+
async def cached_events():
|
|
299
|
+
log_id = workflow_log_id()
|
|
300
|
+
yield runtime_service._sse_event(workflow_start_event(run_id=ctx.run_id, log_id=log_id))
|
|
301
|
+
yield runtime_service._sse_event(workflow_end_event(
|
|
302
|
+
run_id=ctx.run_id, log_id=log_id, output=cached, time_cost_ms=0,
|
|
303
|
+
))
|
|
304
|
+
return StreamingResponse(cached_events(), media_type="text/event-stream")
|
|
305
|
+
|
|
267
306
|
if is_agent_project():
|
|
268
307
|
stream_generator = agent_stream_handler(
|
|
269
308
|
payload=payload,
|
|
@@ -300,6 +339,72 @@ def create_app(service: GraphService | None = None) -> FastAPI:
|
|
|
300
339
|
except PermissionError as exc:
|
|
301
340
|
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
302
341
|
|
|
342
|
+
@app.get("/workflow/instances/{instance_id}")
|
|
343
|
+
async def workflow_instance_status(instance_id: str, request: Request) -> dict[str, Any]:
|
|
344
|
+
ctx = new_context(method="workflow_instance_status", headers=request.headers)
|
|
345
|
+
request_context.set(ctx)
|
|
346
|
+
try:
|
|
347
|
+
row = await durable_store().get(instance_id, ctx)
|
|
348
|
+
return {
|
|
349
|
+
"instance_id": instance_id,
|
|
350
|
+
"status": row["status"],
|
|
351
|
+
"graph_version": row["graph_version"],
|
|
352
|
+
"last_run_id": row["last_run_id"],
|
|
353
|
+
"output": json.loads(row["output_data"]) if row["output_data"] else None,
|
|
354
|
+
"error": row["error_message"],
|
|
355
|
+
}
|
|
356
|
+
except LookupError as exc:
|
|
357
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
358
|
+
except PermissionError as exc:
|
|
359
|
+
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
360
|
+
except ValueError as exc:
|
|
361
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
362
|
+
|
|
363
|
+
@app.post("/workflow/instances/{instance_id}/resume")
|
|
364
|
+
async def workflow_instance_resume(instance_id: str, request: Request) -> dict[str, Any]:
|
|
365
|
+
ctx = new_context(method="workflow_resume", headers=request.headers)
|
|
366
|
+
request_context.set(ctx)
|
|
367
|
+
store = durable_store()
|
|
368
|
+
try:
|
|
369
|
+
await ensure_credit_available(
|
|
370
|
+
"GRAPH_RUN",
|
|
371
|
+
request_id=ctx.run_id,
|
|
372
|
+
current_user_id=ctx.identity.current_user_id,
|
|
373
|
+
current_user_org_id=ctx.identity.current_user_org_id,
|
|
374
|
+
owner_user_id=ctx.identity.user_id,
|
|
375
|
+
owner_org_id=ctx.identity.owner_org_id,
|
|
376
|
+
workspace_id=ctx.identity.workspace_id,
|
|
377
|
+
)
|
|
378
|
+
await store.claim_resume(instance_id, ctx)
|
|
379
|
+
ctx.metadata["workflow_store"] = store
|
|
380
|
+
return await runtime_service.run_with_timeout(None, ctx)
|
|
381
|
+
except WorkflowInstanceConflict as exc:
|
|
382
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
383
|
+
except LookupError as exc:
|
|
384
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
385
|
+
except PermissionError as exc:
|
|
386
|
+
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
387
|
+
except ValueError as exc:
|
|
388
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
389
|
+
|
|
390
|
+
@app.post("/workflow/instances/{instance_id}/cancel")
|
|
391
|
+
async def workflow_instance_cancel(instance_id: str, request: Request) -> dict[str, Any]:
|
|
392
|
+
ctx = new_context(method="workflow_instance_cancel", headers=request.headers)
|
|
393
|
+
request_context.set(ctx)
|
|
394
|
+
try:
|
|
395
|
+
result = await durable_store().cancel(instance_id, ctx)
|
|
396
|
+
if result["status"] == "cancelled" and result["run_id"] in runtime_service.running_tasks:
|
|
397
|
+
runtime_service.cancel_run(result["run_id"], ctx)
|
|
398
|
+
return result
|
|
399
|
+
except WorkflowInstanceConflict as exc:
|
|
400
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
401
|
+
except LookupError as exc:
|
|
402
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
403
|
+
except PermissionError as exc:
|
|
404
|
+
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
405
|
+
except ValueError as exc:
|
|
406
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
407
|
+
|
|
303
408
|
@app.get("/agent/approvals/{run_id}")
|
|
304
409
|
async def agent_approvals(run_id: str, request: Request) -> dict[str, Any]:
|
|
305
410
|
ctx = new_context(method="approval_list", headers=request.headers)
|
|
@@ -66,6 +66,7 @@ from .workflow_protocol import (
|
|
|
66
66
|
workflow_start_event,
|
|
67
67
|
)
|
|
68
68
|
from .workflow_canvas import canvas_submit_response, workflow_canvas_response, workflow_node_code_locations
|
|
69
|
+
from .workflow_durability import WorkflowInstanceStore, load_workflow_durability_config
|
|
69
70
|
|
|
70
71
|
logger = logging.getLogger(__name__)
|
|
71
72
|
|
|
@@ -200,7 +201,8 @@ class GraphService:
|
|
|
200
201
|
self.agent_module = agent_module
|
|
201
202
|
self.graph_module = graph_module
|
|
202
203
|
self.running_tasks: dict[str, asyncio.Task] = {}
|
|
203
|
-
self._run_owners: dict[str, str] = {}
|
|
204
|
+
self._run_owners: dict[str, tuple[str, str | None, str | None]] = {}
|
|
205
|
+
self._run_contexts: dict[str, Context] = {}
|
|
204
206
|
self.error_classifier = ErrorClassifier()
|
|
205
207
|
self._agent_stream_runner = AgentStreamRunner()
|
|
206
208
|
self._workflow_stream_runner = WorkflowStreamRunner()
|
|
@@ -246,6 +248,8 @@ class GraphService:
|
|
|
246
248
|
|
|
247
249
|
if self._graph is None:
|
|
248
250
|
self._graph = get_graph_instance(self.graph_module, ctx)
|
|
251
|
+
if load_workflow_durability_config() and getattr(self._graph, "checkpointer", None) is None:
|
|
252
|
+
raise ValueError("Durable WORKFLOW must compile its graph with workflow_checkpointer()")
|
|
249
253
|
return self._graph
|
|
250
254
|
|
|
251
255
|
@staticmethod
|
|
@@ -270,15 +274,20 @@ class GraphService:
|
|
|
270
274
|
stream_runner = self._get_stream_runner()
|
|
271
275
|
yield from stream_runner.stream(payload, graph, run_config, ctx)
|
|
272
276
|
|
|
273
|
-
async def run(self, payload: dict[str, Any], ctx: Context | None = None) -> dict[str, Any]:
|
|
277
|
+
async def run(self, payload: dict[str, Any] | None, ctx: Context | None = None) -> dict[str, Any]:
|
|
274
278
|
ctx = ctx or new_context("run")
|
|
275
279
|
run_id = ctx.run_id
|
|
276
280
|
agent_project = is_agent_project()
|
|
281
|
+
workflow_store: WorkflowInstanceStore | None = ctx.metadata.get("workflow_store")
|
|
282
|
+
heartbeat_task: asyncio.Task[Any] | None = None
|
|
277
283
|
recorder = RunTraceRecorder(ctx, project_kind="agent" if agent_project else "workflow")
|
|
278
284
|
logger.info("Starting run: run_id=%s", run_id)
|
|
279
285
|
|
|
280
286
|
context_token = request_context.set(ctx)
|
|
281
287
|
try:
|
|
288
|
+
if workflow_store is not None:
|
|
289
|
+
await workflow_store.assert_running(ctx)
|
|
290
|
+
heartbeat_task = asyncio.create_task(workflow_store.heartbeat(ctx, asyncio.current_task()))
|
|
282
291
|
if agent_project:
|
|
283
292
|
request = payload
|
|
284
293
|
snapshot = self._agent_snapshot(ctx)
|
|
@@ -302,8 +311,9 @@ class GraphService:
|
|
|
302
311
|
) if snapshot and snapshot.harness else await invocation
|
|
303
312
|
turn.commit(extract_answer(result))
|
|
304
313
|
else:
|
|
305
|
-
payload
|
|
306
|
-
|
|
314
|
+
if payload is not None:
|
|
315
|
+
payload = materialize_input_files(payload)
|
|
316
|
+
recorder.set_root_input(payload if payload is not None else {"resume": True})
|
|
307
317
|
graph = self._get_graph(ctx)
|
|
308
318
|
self._attach_trace_code_locations(recorder, graph)
|
|
309
319
|
run_config = append_trace_callback(init_run_config(graph, ctx), recorder)
|
|
@@ -314,9 +324,13 @@ class GraphService:
|
|
|
314
324
|
result = await enrich_workflow_output_file_urls(result, ctx)
|
|
315
325
|
if isinstance(result, dict):
|
|
316
326
|
result.setdefault("run_id", run_id)
|
|
327
|
+
if workflow_store is not None:
|
|
328
|
+
await workflow_store.update_status(ctx, "completed", output=result)
|
|
317
329
|
_save_record_safely(recorder, output=result)
|
|
318
330
|
return result
|
|
319
331
|
response = {"run_id": run_id, "result": result}
|
|
332
|
+
if workflow_store is not None:
|
|
333
|
+
await workflow_store.update_status(ctx, "completed", output=response)
|
|
320
334
|
_save_record_safely(recorder, output=response)
|
|
321
335
|
return response
|
|
322
336
|
if result is None:
|
|
@@ -329,11 +343,17 @@ class GraphService:
|
|
|
329
343
|
_save_record_safely(recorder, output=response)
|
|
330
344
|
return response
|
|
331
345
|
except asyncio.CancelledError:
|
|
346
|
+
if workflow_store is not None:
|
|
347
|
+
status = ("cancelled" if ctx.metadata.get("workflow_cancel_requested") else
|
|
348
|
+
"failed" if ctx.metadata.get("workflow_timeout") else "interrupted")
|
|
349
|
+
await workflow_store.update_status(ctx, status, error="Execution timeout" if status == "failed" else None)
|
|
332
350
|
logger.info("Run cancelled: run_id=%s", run_id)
|
|
333
351
|
response = {"status": "cancelled", "run_id": run_id, "message": "Execution was cancelled"}
|
|
334
352
|
_save_record_safely(recorder, status="error", output=response, error="cancelled")
|
|
335
353
|
raise
|
|
336
354
|
except Exception as exc:
|
|
355
|
+
if workflow_store is not None:
|
|
356
|
+
await workflow_store.update_status(ctx, "failed", error=str(exc))
|
|
337
357
|
_save_record_safely(recorder, status="error", error=exc)
|
|
338
358
|
err = self.error_classifier.classify(exc, {"node_name": "run", "run_id": run_id})
|
|
339
359
|
logger.error(
|
|
@@ -344,8 +364,14 @@ class GraphService:
|
|
|
344
364
|
)
|
|
345
365
|
raise
|
|
346
366
|
finally:
|
|
347
|
-
|
|
348
|
-
|
|
367
|
+
try:
|
|
368
|
+
if heartbeat_task is not None:
|
|
369
|
+
heartbeat_task.cancel()
|
|
370
|
+
with suppress(asyncio.CancelledError):
|
|
371
|
+
await heartbeat_task
|
|
372
|
+
finally:
|
|
373
|
+
request_context.reset(context_token)
|
|
374
|
+
self.approvals.forget_run(run_id)
|
|
349
375
|
self.running_tasks.pop(run_id, None)
|
|
350
376
|
telemetry.flush()
|
|
351
377
|
|
|
@@ -358,7 +384,7 @@ class GraphService:
|
|
|
358
384
|
except Exception as exc:
|
|
359
385
|
logger.warning("Failed to resolve workflow code locations: trace_id=%s error=%s", recorder.trace_id, exc)
|
|
360
386
|
|
|
361
|
-
async def run_with_timeout(self, payload: dict[str, Any], ctx: Context) -> dict[str, Any]:
|
|
387
|
+
async def run_with_timeout(self, payload: dict[str, Any] | None, ctx: Context) -> dict[str, Any]:
|
|
362
388
|
active = self.running_tasks.get(ctx.run_id)
|
|
363
389
|
if active is not None and not active.done():
|
|
364
390
|
raise ValueError(f"run_id is already active: {ctx.run_id}")
|
|
@@ -367,25 +393,30 @@ class GraphService:
|
|
|
367
393
|
snapshot = self._agent_snapshot(ctx)
|
|
368
394
|
if snapshot and snapshot.harness:
|
|
369
395
|
timeout = min(timeout, snapshot.harness.execution.max_duration_seconds)
|
|
370
|
-
|
|
371
|
-
|
|
396
|
+
if ctx.identity.user_token:
|
|
397
|
+
self._run_owners[ctx.run_id] = self._run_owner(ctx)
|
|
398
|
+
self._run_contexts[ctx.run_id] = ctx
|
|
372
399
|
task = asyncio.create_task(self.run(payload, ctx))
|
|
373
400
|
self.running_tasks[ctx.run_id] = task
|
|
374
401
|
try:
|
|
375
402
|
completed, _ = await asyncio.wait({task}, timeout=timeout)
|
|
376
403
|
if completed:
|
|
377
404
|
return await task
|
|
405
|
+
ctx.metadata["workflow_timeout"] = True
|
|
378
406
|
return {
|
|
379
407
|
"status": "timeout",
|
|
380
408
|
"run_id": ctx.run_id,
|
|
381
409
|
"message": f"Execution timeout: exceeded {timeout:g} seconds",
|
|
382
410
|
}
|
|
383
411
|
finally:
|
|
384
|
-
|
|
385
|
-
task.
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
412
|
+
try:
|
|
413
|
+
if not task.done():
|
|
414
|
+
task.cancel()
|
|
415
|
+
with suppress(asyncio.CancelledError):
|
|
416
|
+
await task
|
|
417
|
+
finally:
|
|
418
|
+
self._run_owners.pop(ctx.run_id, None)
|
|
419
|
+
self._run_contexts.pop(ctx.run_id, None)
|
|
389
420
|
|
|
390
421
|
async def stream_sse(
|
|
391
422
|
self,
|
|
@@ -446,7 +477,7 @@ class GraphService:
|
|
|
446
477
|
recorder = RunTraceRecorder(ctx, project_kind="agent")
|
|
447
478
|
|
|
448
479
|
if ctx.identity.user_token:
|
|
449
|
-
self._run_owners[run_id] =
|
|
480
|
+
self._run_owners[run_id] = self._run_owner(ctx)
|
|
450
481
|
|
|
451
482
|
yield self._sse_event(message_start_event(local_message_id, server_message_id))
|
|
452
483
|
|
|
@@ -598,35 +629,45 @@ class GraphService:
|
|
|
598
629
|
run_opt: RunOpt,
|
|
599
630
|
) -> AsyncIterable[str]:
|
|
600
631
|
run_id = ctx.run_id
|
|
632
|
+
workflow_store: WorkflowInstanceStore | None = ctx.metadata.get("workflow_store")
|
|
633
|
+
heartbeat_task: asyncio.Task[Any] | None = None
|
|
601
634
|
log_id = workflow_log_id()
|
|
602
635
|
start_perf = time.perf_counter()
|
|
603
636
|
event_index = 0
|
|
604
|
-
payload = materialize_input_files(payload)
|
|
605
637
|
recorder = RunTraceRecorder(ctx, project_kind="workflow")
|
|
606
|
-
recorder.set_root_input(payload)
|
|
607
638
|
final_output: Any = {}
|
|
608
639
|
stream_error: BaseException | str | None = None
|
|
609
640
|
status_code = 200
|
|
610
641
|
error_code: str | None = None
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
642
|
+
saw_end = False
|
|
643
|
+
if ctx.identity.user_token:
|
|
644
|
+
self._run_owners[run_id] = self._run_owner(ctx)
|
|
645
|
+
self._run_contexts[run_id] = ctx
|
|
646
|
+
if workflow_store is not None:
|
|
647
|
+
heartbeat_task = asyncio.create_task(workflow_store.heartbeat(ctx, asyncio.current_task()))
|
|
614
648
|
|
|
615
649
|
queue: asyncio.Queue[dict[str, Any] | BaseException | None] = asyncio.Queue()
|
|
616
650
|
|
|
617
651
|
async def produce() -> None:
|
|
618
652
|
try:
|
|
619
|
-
|
|
653
|
+
if workflow_store is not None:
|
|
654
|
+
await workflow_store.assert_running(ctx)
|
|
655
|
+
prepared_payload = materialize_input_files(payload)
|
|
656
|
+
recorder.set_root_input(prepared_payload)
|
|
657
|
+
async for event in self._workflow_platform_events(prepared_payload, ctx, run_opt, log_id, start_perf, recorder):
|
|
620
658
|
await queue.put(event)
|
|
621
659
|
except BaseException as exc:
|
|
622
660
|
await queue.put(exc)
|
|
623
661
|
finally:
|
|
624
662
|
await queue.put(None)
|
|
625
663
|
|
|
664
|
+
context_token = request_context.set(ctx)
|
|
626
665
|
producer = asyncio.create_task(produce())
|
|
627
666
|
ping_interval = run_opt.workflow_ping_interval_seconds
|
|
628
667
|
|
|
629
668
|
try:
|
|
669
|
+
yield self._sse_event(workflow_start_event(run_id=run_id, log_id=log_id), event_index)
|
|
670
|
+
event_index += 1
|
|
630
671
|
while True:
|
|
631
672
|
try:
|
|
632
673
|
if ping_interval and ping_interval > 0:
|
|
@@ -646,6 +687,8 @@ class GraphService:
|
|
|
646
687
|
code = stream_error_code(item) or WORKFLOW_DEFAULT_ERROR_CODE
|
|
647
688
|
message = str(item) or item.__class__.__name__
|
|
648
689
|
stream_error = item
|
|
690
|
+
if workflow_store is not None:
|
|
691
|
+
await workflow_store.update_status(ctx, "failed", error=message)
|
|
649
692
|
status_code = 500
|
|
650
693
|
error_code = str(code)
|
|
651
694
|
yield self._sse_event(
|
|
@@ -657,38 +700,66 @@ class GraphService:
|
|
|
657
700
|
|
|
658
701
|
if item.get("type") == "workflow_end":
|
|
659
702
|
final_output = item.get("output", {})
|
|
703
|
+
if workflow_store is not None:
|
|
704
|
+
await workflow_store.update_status(ctx, "completed", output=final_output)
|
|
705
|
+
saw_end = True
|
|
660
706
|
if item.get("type") == "error":
|
|
661
707
|
stream_error = item.get("message") or "workflow error"
|
|
708
|
+
if workflow_store is not None:
|
|
709
|
+
await workflow_store.update_status(ctx, "failed", error=str(stream_error))
|
|
662
710
|
status_code = 500
|
|
663
711
|
error_code = str(item.get("code") or WORKFLOW_DEFAULT_ERROR_CODE)
|
|
664
712
|
yield self._sse_event(item, event_index)
|
|
665
713
|
event_index += 1
|
|
714
|
+
if item.get("type") in {"error", "workflow_end"}:
|
|
715
|
+
break
|
|
666
716
|
except asyncio.CancelledError:
|
|
667
717
|
producer.cancel()
|
|
668
|
-
|
|
718
|
+
cancelled = workflow_store is None or bool(ctx.metadata.get("workflow_cancel_requested"))
|
|
719
|
+
stream_error = "cancelled" if cancelled else "interrupted"
|
|
720
|
+
if workflow_store is not None:
|
|
721
|
+
await workflow_store.update_status(ctx, "cancelled" if cancelled else "interrupted")
|
|
669
722
|
status_code = 499
|
|
670
|
-
error_code = "CANCELLED"
|
|
723
|
+
error_code = "CANCELLED" if cancelled else "INTERRUPTED"
|
|
671
724
|
yield self._sse_event(
|
|
672
|
-
workflow_error_event(run_id=run_id, log_id=log_id, code=1, message=
|
|
725
|
+
workflow_error_event(run_id=run_id, log_id=log_id, code=1, message=str(stream_error)),
|
|
673
726
|
event_index,
|
|
674
727
|
)
|
|
675
728
|
finally:
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
729
|
+
try:
|
|
730
|
+
if not producer.done():
|
|
731
|
+
producer.cancel()
|
|
732
|
+
with suppress(asyncio.CancelledError):
|
|
733
|
+
await producer
|
|
734
|
+
if workflow_store is not None and not saw_end and stream_error is None:
|
|
735
|
+
stream_error = "workflow stream ended without a terminal event"
|
|
736
|
+
await workflow_store.update_status(ctx, "failed", error=str(stream_error))
|
|
737
|
+
finally:
|
|
738
|
+
try:
|
|
739
|
+
if heartbeat_task is not None:
|
|
740
|
+
heartbeat_task.cancel()
|
|
741
|
+
with suppress(asyncio.CancelledError):
|
|
742
|
+
await heartbeat_task
|
|
743
|
+
finally:
|
|
744
|
+
try:
|
|
745
|
+
if stream_error is not None:
|
|
746
|
+
_save_record_safely(recorder, status="error", output=final_output, error=stream_error)
|
|
747
|
+
else:
|
|
748
|
+
_save_record_safely(recorder, output=final_output)
|
|
749
|
+
observability.record_request_end(
|
|
750
|
+
ctx,
|
|
751
|
+
route="/stream_run",
|
|
752
|
+
status_code=status_code,
|
|
753
|
+
latency_ms=_elapsed_perf_ms(start_perf),
|
|
754
|
+
error_code=error_code,
|
|
755
|
+
error_message=stream_error,
|
|
756
|
+
)
|
|
757
|
+
finally:
|
|
758
|
+
self.running_tasks.pop(run_id, None)
|
|
759
|
+
self._run_owners.pop(run_id, None)
|
|
760
|
+
self._run_contexts.pop(run_id, None)
|
|
761
|
+
request_context.reset(context_token)
|
|
762
|
+
telemetry.flush()
|
|
692
763
|
|
|
693
764
|
async def _workflow_platform_events(
|
|
694
765
|
self,
|
|
@@ -754,6 +825,7 @@ class GraphService:
|
|
|
754
825
|
code=WORKFLOW_DEFAULT_ERROR_CODE,
|
|
755
826
|
message=str(error),
|
|
756
827
|
)
|
|
828
|
+
return
|
|
757
829
|
|
|
758
830
|
final_output = await enrich_workflow_output_file_urls(final_output, ctx)
|
|
759
831
|
yield workflow_end_event(
|
|
@@ -806,6 +878,10 @@ class GraphService:
|
|
|
806
878
|
return value.strip()
|
|
807
879
|
return node_id or "workflow_node"
|
|
808
880
|
|
|
881
|
+
@staticmethod
|
|
882
|
+
def _run_owner(ctx: Context) -> tuple[str, str | None, str | None]:
|
|
883
|
+
return (session_user_id(ctx), ctx.identity.workspace_id, ctx.identity.project_id)
|
|
884
|
+
|
|
809
885
|
def cancel_run(self, run_id: str, ctx: Context | None = None) -> dict[str, Any]:
|
|
810
886
|
logger.info("Attempting to cancel run_id=%s", run_id)
|
|
811
887
|
task = self.running_tasks.get(run_id)
|
|
@@ -821,12 +897,15 @@ class GraphService:
|
|
|
821
897
|
"run_id": run_id,
|
|
822
898
|
"message": "Task has already completed.",
|
|
823
899
|
}
|
|
824
|
-
if ctx is not None
|
|
900
|
+
if ctx is not None:
|
|
825
901
|
if not ctx.identity.user_token:
|
|
826
|
-
raise ValueError("
|
|
902
|
+
raise ValueError("Cancellation requires an authenticated Bearer token")
|
|
827
903
|
owner = self._run_owners.get(run_id)
|
|
828
|
-
if owner is None or
|
|
829
|
-
raise PermissionError("Only the requesting user may cancel this
|
|
904
|
+
if owner is None or self._run_owner(ctx) != owner:
|
|
905
|
+
raise PermissionError("Only the requesting user may cancel this run")
|
|
906
|
+
request_ctx = self._run_contexts.get(run_id)
|
|
907
|
+
if request_ctx is not None:
|
|
908
|
+
request_ctx.metadata["workflow_cancel_requested"] = True
|
|
830
909
|
task.cancel()
|
|
831
910
|
return {
|
|
832
911
|
"status": "success",
|
|
@@ -897,6 +976,12 @@ class GraphService:
|
|
|
897
976
|
parser = LangGraphParser(graph)
|
|
898
977
|
result["nodes"] = parser.get_nodes_metadata()
|
|
899
978
|
result["agent_config"] = read_agent_config()
|
|
979
|
+
else:
|
|
980
|
+
durability = load_workflow_durability_config()
|
|
981
|
+
result["durability"] = {
|
|
982
|
+
"enabled": durability is not None,
|
|
983
|
+
"graph_version": durability.graph_version if durability else None,
|
|
984
|
+
}
|
|
900
985
|
return result
|
|
901
986
|
|
|
902
987
|
def agent_config_info(self) -> dict[str, Any]:
|
{licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/stream_runner.py
RENAMED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
from contextlib import suppress
|
|
4
5
|
from dataclasses import dataclass
|
|
5
6
|
from typing import Any, AsyncIterable, Callable, Iterable
|
|
6
7
|
|
|
@@ -119,6 +120,8 @@ async def _registered_stream(
|
|
|
119
120
|
run_opt: RunOpt | None = None,
|
|
120
121
|
) -> AsyncIterable[str]:
|
|
121
122
|
queue: asyncio.Queue[str | None] = asyncio.Queue()
|
|
123
|
+
detached = False
|
|
124
|
+
durable = ctx.metadata.get("workflow_store") is not None
|
|
122
125
|
|
|
123
126
|
async def produce() -> None:
|
|
124
127
|
try:
|
|
@@ -126,28 +129,39 @@ async def _registered_stream(
|
|
|
126
129
|
if run_opt is not None:
|
|
127
130
|
kwargs["run_opt"] = run_opt
|
|
128
131
|
async for item in stream_sse_func(payload, **kwargs):
|
|
129
|
-
|
|
132
|
+
if not detached:
|
|
133
|
+
await queue.put(item)
|
|
130
134
|
except asyncio.CancelledError:
|
|
131
|
-
|
|
135
|
+
if not detached:
|
|
136
|
+
await queue.put(sse_event_func({"status": "cancelled", "run_id": run_id}))
|
|
132
137
|
raise
|
|
133
138
|
except Exception as exc:
|
|
134
|
-
|
|
139
|
+
if not detached:
|
|
140
|
+
await queue.put(sse_event_func(error_classifier.get_error_response(exc, {"run_id": run_id})))
|
|
135
141
|
finally:
|
|
136
|
-
|
|
142
|
+
if not detached:
|
|
143
|
+
await queue.put(None)
|
|
137
144
|
|
|
138
145
|
task = asyncio.create_task(produce())
|
|
139
146
|
register_task_func(run_id, task)
|
|
140
147
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
148
|
+
try:
|
|
149
|
+
while True:
|
|
150
|
+
item = await queue.get()
|
|
151
|
+
if item is None:
|
|
152
|
+
break
|
|
153
|
+
yield item
|
|
154
|
+
|
|
155
|
+
if task.done():
|
|
156
|
+
try:
|
|
157
|
+
exception = task.exception()
|
|
158
|
+
except asyncio.CancelledError:
|
|
159
|
+
exception = None
|
|
160
|
+
if exception is not None:
|
|
161
|
+
raise exception
|
|
162
|
+
finally:
|
|
163
|
+
detached = True
|
|
164
|
+
if not durable and not task.done():
|
|
165
|
+
task.cancel()
|
|
166
|
+
with suppress(asyncio.CancelledError):
|
|
167
|
+
await task
|