agentic-workflow-orchestrator 1.0.0__py3-none-any.whl
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.
- agentic_workflow_orchestrator-1.0.0.dist-info/METADATA +125 -0
- agentic_workflow_orchestrator-1.0.0.dist-info/RECORD +13 -0
- agentic_workflow_orchestrator-1.0.0.dist-info/WHEEL +5 -0
- agentic_workflow_orchestrator-1.0.0.dist-info/top_level.txt +1 -0
- orchestrator/__init__.py +63 -0
- orchestrator/engine.py +245 -0
- orchestrator/llm.py +390 -0
- orchestrator/mcp_integration.py +283 -0
- orchestrator/py.typed +0 -0
- orchestrator/ratelimit.py +65 -0
- orchestrator/registry.py +104 -0
- orchestrator/report.py +121 -0
- orchestrator/spec.py +233 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentic-workflow-orchestrator
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Orchestrate multi-agent workflows with autonomous reasoning, parallelism, rate limiting, and LLM integration
|
|
5
|
+
Home-page: https://github.com/org/orchestrator
|
|
6
|
+
Author: Manikandan Kasi
|
|
7
|
+
Author-email: Orchestrator Team <info@orchestrator.dev>
|
|
8
|
+
License: Apache License 2.0
|
|
9
|
+
Project-URL: Homepage, https://github.com/org/orchestrator
|
|
10
|
+
Project-URL: Documentation, https://github.com/org/orchestrator/blob/main/QUICKSTART.md
|
|
11
|
+
Project-URL: Repository, https://github.com/org/orchestrator
|
|
12
|
+
Project-URL: Issues, https://github.com/org/orchestrator/issues
|
|
13
|
+
Keywords: orchestration,workflow,multi-agent,agent,concurrency,rate-limiting,llm,mcp,asyncio
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Operating System :: OS Independent
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
Provides-Extra: llm-anthropic
|
|
27
|
+
Requires-Dist: anthropic>=0.7.0; extra == "llm-anthropic"
|
|
28
|
+
Provides-Extra: llm-openai
|
|
29
|
+
Requires-Dist: openai>=1.0.0; extra == "llm-openai"
|
|
30
|
+
Provides-Extra: llm-gemini
|
|
31
|
+
Requires-Dist: google-generativeai>=0.3.0; extra == "llm-gemini"
|
|
32
|
+
Provides-Extra: llm-xai
|
|
33
|
+
Requires-Dist: openai>=1.0.0; extra == "llm-xai"
|
|
34
|
+
Provides-Extra: llm-huggingface
|
|
35
|
+
Requires-Dist: transformers>=4.30.0; extra == "llm-huggingface"
|
|
36
|
+
Requires-Dist: torch>=2.0.0; extra == "llm-huggingface"
|
|
37
|
+
Provides-Extra: llm-azure
|
|
38
|
+
Requires-Dist: openai>=1.0.0; extra == "llm-azure"
|
|
39
|
+
Provides-Extra: llm
|
|
40
|
+
Requires-Dist: anthropic>=0.7.0; extra == "llm"
|
|
41
|
+
Requires-Dist: openai>=1.0.0; extra == "llm"
|
|
42
|
+
Requires-Dist: google-generativeai>=0.3.0; extra == "llm"
|
|
43
|
+
Requires-Dist: transformers>=4.30.0; extra == "llm"
|
|
44
|
+
Requires-Dist: torch>=2.0.0; extra == "llm"
|
|
45
|
+
Provides-Extra: engine
|
|
46
|
+
Provides-Extra: dev
|
|
47
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
48
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
49
|
+
Requires-Dist: black>=23.0; extra == "dev"
|
|
50
|
+
Requires-Dist: isort>=5.12; extra == "dev"
|
|
51
|
+
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
52
|
+
Dynamic: author
|
|
53
|
+
Dynamic: home-page
|
|
54
|
+
Dynamic: requires-python
|
|
55
|
+
|
|
56
|
+
# orchestrator (Python reference SDK)
|
|
57
|
+
|
|
58
|
+
In-process workflow orchestration engine + authoring API. Zero required dependencies
|
|
59
|
+
(stdlib `asyncio`); `pyyaml` is optional (for `.yaml` specs), `anthropic` optional (for the
|
|
60
|
+
real LLM adapter).
|
|
61
|
+
|
|
62
|
+
## Concepts
|
|
63
|
+
|
|
64
|
+
- **Workflow** — a DAG of tasks loaded from a template (`Workflow.from_file/from_dict/from_json`).
|
|
65
|
+
- **Skill** — a registered handler `(ctx, inputs) -> output`, sync or `async`.
|
|
66
|
+
- **Tool** — a registered callable a skill invokes via `ctx.call_tool(...)`.
|
|
67
|
+
- **Orchestrator** — runs a workflow, enforcing concurrency + rate limits, and returns a `Report`.
|
|
68
|
+
|
|
69
|
+
## Minimal usage
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from orchestrator import Workflow, Orchestrator, Registry, MockLLM
|
|
73
|
+
|
|
74
|
+
reg = Registry()
|
|
75
|
+
|
|
76
|
+
@reg.tool("http.get")
|
|
77
|
+
def http_get(url):
|
|
78
|
+
return {"url": url, "ok": True}
|
|
79
|
+
|
|
80
|
+
@reg.skill("market.fetch")
|
|
81
|
+
def fetch(ctx, inputs):
|
|
82
|
+
ctx.call_tool("http.get", url=f"/prices/{inputs['ticker']}")
|
|
83
|
+
ctx.record_decision("selected primary feed", rationale="lowest latency")
|
|
84
|
+
return {"ticker": inputs["ticker"], "last": 199.4}
|
|
85
|
+
|
|
86
|
+
@reg.skill("llm.classify")
|
|
87
|
+
def classify(ctx, inputs):
|
|
88
|
+
r = ctx.llm(messages=[{"role": "user", "content": inputs["text"]}], model="claude-opus-4-8")
|
|
89
|
+
return r.text
|
|
90
|
+
|
|
91
|
+
wf = Workflow.from_file("../spec/examples/market-analysis.json")
|
|
92
|
+
report = Orchestrator(reg, llm=MockLLM()).run_sync(wf, inputs={"ticker": "AAPL"})
|
|
93
|
+
print(report.to_json())
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## The `ctx` (SkillContext) API
|
|
97
|
+
|
|
98
|
+
| Member | Purpose |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `ctx.inputs` | resolved task inputs |
|
|
101
|
+
| `ctx.call_tool(name, **kwargs)` | invoke a registered tool |
|
|
102
|
+
| `ctx.llm(messages, tools=, model=, **opts)` | call the configured LLM provider; usage is recorded |
|
|
103
|
+
| `ctx.record_decision(summary, rationale=, data=)` | append a critical decision to the trace |
|
|
104
|
+
| `ctx.log(msg)`, `ctx.attempt`, `ctx.cancelled` | logging / retry attempt / cooperative cancel flag |
|
|
105
|
+
|
|
106
|
+
## Report
|
|
107
|
+
|
|
108
|
+
`report.to_dict()` / `report.to_json()` give: `status`, `duration_ms`, per-task
|
|
109
|
+
`{status, duration_ms, attempts, llm, decisions, error}`, `critical_path` (bottleneck chain),
|
|
110
|
+
`totals` (llm tokens, tool calls, retries), `errors`, and the resolved `output`.
|
|
111
|
+
|
|
112
|
+
## Run
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
python3 examples/run_market.py
|
|
116
|
+
python3 tests/test_engine.py
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Using a real LLM
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from orchestrator import AnthropicProvider, Orchestrator
|
|
123
|
+
orch = Orchestrator(reg, llm=AnthropicProvider(model="claude-opus-4-8")) # needs ANTHROPIC_API_KEY + `pip install anthropic`
|
|
124
|
+
```
|
|
125
|
+
Confirm current Claude model IDs/limits from the Claude API reference before pinning them.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
orchestrator/__init__.py,sha256=iJnmq4UfaQuuSG3fsD1kZh7ZRfwpjnp69mwKrDOEUDs,1530
|
|
2
|
+
orchestrator/engine.py,sha256=t3yDrTYOAbVgNXn13dEq7MJY5Xl4c_13wyrGiFECmbg,10203
|
|
3
|
+
orchestrator/llm.py,sha256=V0jFx-JbhSVFutUxOycuYzfYeePvr5cZroP4OvgSeUo,13985
|
|
4
|
+
orchestrator/mcp_integration.py,sha256=X8-8anjlFUeCDUNYKRnfnTrIbalsOPsQCRvw4Fmu5C4,9600
|
|
5
|
+
orchestrator/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
orchestrator/ratelimit.py,sha256=iwJ016zE1d35Lml_Yt2e4wtW8wSHuBha4lH_IqpnFPg,2344
|
|
7
|
+
orchestrator/registry.py,sha256=RY54m6I2QFQPk9v-PHM0c7S0tnp-6nbrglr0uag0wKQ,3646
|
|
8
|
+
orchestrator/report.py,sha256=1Q9UYsDbJwGN4v7qw-kIAXtX4FHsKP6OZmtqFUvRLOU,3418
|
|
9
|
+
orchestrator/spec.py,sha256=VwdskmTyQdGawJughj6eq14x8CPB7DaNlMNi-jT1eFE,7627
|
|
10
|
+
agentic_workflow_orchestrator-1.0.0.dist-info/METADATA,sha256=7PSCQYvnO_UBuraBfCmYlxzX5Y6XW6biJVTRZh3_Tn0,4969
|
|
11
|
+
agentic_workflow_orchestrator-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
12
|
+
agentic_workflow_orchestrator-1.0.0.dist-info/top_level.txt,sha256=xxQ-0cX7ZiHS5jT83NoSu1MB43Iw3IGXRlLTZrI3QX4,13
|
|
13
|
+
agentic_workflow_orchestrator-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
orchestrator
|
orchestrator/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Multi-agent workflow orchestration SDK — Python reference implementation.
|
|
2
|
+
|
|
3
|
+
Quick start:
|
|
4
|
+
|
|
5
|
+
from orchestrator import Workflow, Orchestrator, Registry, MockLLM
|
|
6
|
+
|
|
7
|
+
reg = Registry()
|
|
8
|
+
|
|
9
|
+
@reg.skill("market.fetch")
|
|
10
|
+
def fetch(ctx, inputs):
|
|
11
|
+
ctx.record_decision("selected primary feed", rationale="lowest latency")
|
|
12
|
+
return {"ticker": inputs["ticker"], "last": 199.4}
|
|
13
|
+
|
|
14
|
+
wf = Workflow.from_file("spec/examples/market-analysis.json")
|
|
15
|
+
report = Orchestrator(reg, llm=MockLLM()).run_sync(wf, {"ticker": "AAPL"})
|
|
16
|
+
print(report.to_json())
|
|
17
|
+
"""
|
|
18
|
+
from .engine import Orchestrator, TaskFailed
|
|
19
|
+
from .llm import (
|
|
20
|
+
AnthropicProvider,
|
|
21
|
+
AzureOpenAIProvider,
|
|
22
|
+
GeminiProvider,
|
|
23
|
+
HuggingFaceProvider,
|
|
24
|
+
LlmProvider,
|
|
25
|
+
LlmResult,
|
|
26
|
+
MockLLM,
|
|
27
|
+
OpenAIProvider,
|
|
28
|
+
XAIProvider,
|
|
29
|
+
)
|
|
30
|
+
from .ratelimit import TokenBucket
|
|
31
|
+
from .registry import Registry, SkillContext
|
|
32
|
+
from .report import Decision, LlmUsage, Report, TaskSpan, Totals
|
|
33
|
+
from .spec import RateLimit, Retry, SpecError, Task, Workflow, resolve_template
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"Orchestrator",
|
|
37
|
+
"TaskFailed",
|
|
38
|
+
"Registry",
|
|
39
|
+
"SkillContext",
|
|
40
|
+
"Workflow",
|
|
41
|
+
"Task",
|
|
42
|
+
"RateLimit",
|
|
43
|
+
"Retry",
|
|
44
|
+
"SpecError",
|
|
45
|
+
"resolve_template",
|
|
46
|
+
"Report",
|
|
47
|
+
"TaskSpan",
|
|
48
|
+
"Decision",
|
|
49
|
+
"LlmUsage",
|
|
50
|
+
"Totals",
|
|
51
|
+
"LlmProvider",
|
|
52
|
+
"LlmResult",
|
|
53
|
+
"MockLLM",
|
|
54
|
+
"AnthropicProvider",
|
|
55
|
+
"OpenAIProvider",
|
|
56
|
+
"GeminiProvider",
|
|
57
|
+
"XAIProvider",
|
|
58
|
+
"HuggingFaceProvider",
|
|
59
|
+
"AzureOpenAIProvider",
|
|
60
|
+
"TokenBucket",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
__version__ = "0.1.0"
|
orchestrator/engine.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""The orchestration engine: DAG scheduler with concurrency + rate limiting.
|
|
2
|
+
|
|
3
|
+
This is the Python reference implementation. It runs workflows in-process and
|
|
4
|
+
defines the canonical scheduling/report semantics that the Go engine and other
|
|
5
|
+
SDKs must reproduce against the shared conformance fixtures.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import inspect
|
|
11
|
+
import random
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from typing import Any, Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
from .llm import LlmProvider
|
|
19
|
+
from .ratelimit import NullBucket, bucket_for, token_bucket_for
|
|
20
|
+
from .registry import Registry, SkillContext
|
|
21
|
+
from .report import Report, TaskSpan, Totals, compute_critical_path
|
|
22
|
+
from .spec import Retry, Task, Workflow, resolve_template
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TaskFailed(Exception):
|
|
26
|
+
def __init__(self, task_id: str, message: str):
|
|
27
|
+
super().__init__(f"task {task_id!r} failed: {message}")
|
|
28
|
+
self.task_id = task_id
|
|
29
|
+
self.message = message
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Orchestrator:
|
|
33
|
+
"""Embedded engine. Construct with a Registry and (optionally) an LLM provider."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, registry: Registry, llm: Optional[LlmProvider] = None):
|
|
36
|
+
self.registry = registry
|
|
37
|
+
self.llm = llm
|
|
38
|
+
|
|
39
|
+
def run_sync(self, workflow: Workflow, inputs: Optional[Dict[str, Any]] = None) -> Report:
|
|
40
|
+
return asyncio.run(self.run(workflow, inputs))
|
|
41
|
+
|
|
42
|
+
async def run(self, workflow: Workflow, inputs: Optional[Dict[str, Any]] = None) -> Report:
|
|
43
|
+
inputs = inputs or {}
|
|
44
|
+
self._validate_inputs(workflow, inputs)
|
|
45
|
+
run_id = "r_" + uuid.uuid4().hex[:16]
|
|
46
|
+
started_wall = datetime.now(timezone.utc)
|
|
47
|
+
t0 = time.monotonic()
|
|
48
|
+
|
|
49
|
+
task_map = workflow.task_map()
|
|
50
|
+
children: Dict[str, List[str]] = defaultdict(list)
|
|
51
|
+
indeg: Dict[str, int] = {}
|
|
52
|
+
for t in workflow.tasks:
|
|
53
|
+
indeg[t.id] = len(t.depends_on)
|
|
54
|
+
for dep in t.depends_on:
|
|
55
|
+
children[dep].append(t.id)
|
|
56
|
+
|
|
57
|
+
# Rate buckets: one global, one per provider.
|
|
58
|
+
global_bucket = bucket_for(workflow.rate_limit, "global")
|
|
59
|
+
prov_req = {n: bucket_for(p.rate_limit, f"{n}.req") for n, p in workflow.providers.items()}
|
|
60
|
+
prov_tok = {n: token_bucket_for(p.rate_limit, f"{n}.tok") for n, p in workflow.providers.items()}
|
|
61
|
+
|
|
62
|
+
sem = asyncio.Semaphore(workflow.max_parallel)
|
|
63
|
+
|
|
64
|
+
results: Dict[str, Any] = {}
|
|
65
|
+
spans: Dict[str, TaskSpan] = {}
|
|
66
|
+
totals = Totals()
|
|
67
|
+
|
|
68
|
+
ready: List[str] = [t.id for t in workflow.tasks if indeg[t.id] == 0]
|
|
69
|
+
skipped: set = set()
|
|
70
|
+
running: Dict[asyncio.Task, str] = {}
|
|
71
|
+
run_failed = False
|
|
72
|
+
first_error: Optional[str] = None
|
|
73
|
+
|
|
74
|
+
def mark_descendants_skipped(start: str) -> None:
|
|
75
|
+
stack = list(children[start])
|
|
76
|
+
while stack:
|
|
77
|
+
cid = stack.pop()
|
|
78
|
+
if cid in skipped or cid in spans:
|
|
79
|
+
continue
|
|
80
|
+
skipped.add(cid)
|
|
81
|
+
spans[cid] = TaskSpan(id=cid, status="skipped")
|
|
82
|
+
stack.extend(children[cid])
|
|
83
|
+
|
|
84
|
+
while ready or running:
|
|
85
|
+
while ready:
|
|
86
|
+
tid = ready.pop(0)
|
|
87
|
+
if tid in skipped:
|
|
88
|
+
continue
|
|
89
|
+
coro = self._execute_task(
|
|
90
|
+
workflow, task_map[tid], run_id, results, sem,
|
|
91
|
+
global_bucket, prov_req, prov_tok,
|
|
92
|
+
)
|
|
93
|
+
running[asyncio.ensure_future(coro)] = tid
|
|
94
|
+
|
|
95
|
+
if not running:
|
|
96
|
+
break
|
|
97
|
+
|
|
98
|
+
done, _ = await asyncio.wait(running.keys(), return_when=asyncio.FIRST_COMPLETED)
|
|
99
|
+
for fut in done:
|
|
100
|
+
tid = running.pop(fut)
|
|
101
|
+
span: TaskSpan = fut.result() # _execute_task never raises
|
|
102
|
+
spans[tid] = span
|
|
103
|
+
totals.retries += max(0, span.attempts - 1)
|
|
104
|
+
if span.llm:
|
|
105
|
+
totals.llm_tokens_in += span.llm.tokens_in
|
|
106
|
+
totals.llm_tokens_out += span.llm.tokens_out
|
|
107
|
+
totals.tool_calls += getattr(span, "_tool_calls", 0)
|
|
108
|
+
|
|
109
|
+
if span.status == "succeeded":
|
|
110
|
+
results[tid] = span.output
|
|
111
|
+
for c in children[tid]:
|
|
112
|
+
indeg[c] -= 1
|
|
113
|
+
if indeg[c] == 0 and c not in skipped:
|
|
114
|
+
ready.append(c)
|
|
115
|
+
else: # failed
|
|
116
|
+
if first_error is None:
|
|
117
|
+
first_error = span.error
|
|
118
|
+
on_error = task_map[tid].on_error or workflow.default_on_error
|
|
119
|
+
if on_error == "fail_fast":
|
|
120
|
+
run_failed = True
|
|
121
|
+
for f in list(running.keys()):
|
|
122
|
+
f.cancel()
|
|
123
|
+
if running:
|
|
124
|
+
await asyncio.wait(running.keys())
|
|
125
|
+
running.clear()
|
|
126
|
+
ready.clear()
|
|
127
|
+
# Everything not yet terminal is skipped.
|
|
128
|
+
for other in task_map:
|
|
129
|
+
if other not in spans:
|
|
130
|
+
spans[other] = TaskSpan(id=other, status="skipped")
|
|
131
|
+
break
|
|
132
|
+
else:
|
|
133
|
+
mark_descendants_skipped(tid)
|
|
134
|
+
if run_failed:
|
|
135
|
+
break
|
|
136
|
+
|
|
137
|
+
depends_on = {t.id: t.depends_on for t in workflow.tasks}
|
|
138
|
+
status = "failed" if (run_failed or any(s.status == "failed" for s in spans.values())) else "succeeded"
|
|
139
|
+
output = None
|
|
140
|
+
if status == "succeeded" and workflow.output:
|
|
141
|
+
scope = {"input": inputs, **{tid: {"output": out} for tid, out in results.items()}}
|
|
142
|
+
try:
|
|
143
|
+
output = resolve_template(workflow.output, scope)
|
|
144
|
+
except Exception: # noqa: BLE001 - output is best-effort
|
|
145
|
+
output = None
|
|
146
|
+
|
|
147
|
+
ordered = [spans[t.id] for t in workflow.tasks if t.id in spans]
|
|
148
|
+
return Report(
|
|
149
|
+
workflow=workflow.name,
|
|
150
|
+
run_id=run_id,
|
|
151
|
+
status=status,
|
|
152
|
+
started_at=started_wall.isoformat(),
|
|
153
|
+
duration_ms=int((time.monotonic() - t0) * 1000),
|
|
154
|
+
tasks=ordered,
|
|
155
|
+
critical_path=compute_critical_path(spans, depends_on),
|
|
156
|
+
totals=totals,
|
|
157
|
+
errors=[first_error] if first_error else [],
|
|
158
|
+
output=output,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
async def _execute_task(
|
|
162
|
+
self, workflow, task: Task, run_id, results, sem,
|
|
163
|
+
global_bucket, prov_req, prov_tok,
|
|
164
|
+
) -> TaskSpan:
|
|
165
|
+
retry = task.retry or workflow.default_retry or Retry()
|
|
166
|
+
timeout = task.timeout if task.timeout is not None else workflow.default_timeout
|
|
167
|
+
# Resolve template refs against run inputs + outputs of completed deps.
|
|
168
|
+
scope = {"input": self._current_inputs, **{tid: {"output": out} for tid, out in results.items()}}
|
|
169
|
+
resolved_inputs = resolve_template(task.inputs, scope)
|
|
170
|
+
|
|
171
|
+
span = TaskSpan(id=task.id, status="failed", attempts=0)
|
|
172
|
+
t_task = time.monotonic()
|
|
173
|
+
last_err = "unknown error"
|
|
174
|
+
|
|
175
|
+
for attempt in range(1, retry.max + 2): # max retries => max+1 total attempts
|
|
176
|
+
span.attempts = attempt
|
|
177
|
+
ctx = SkillContext(
|
|
178
|
+
run_id=run_id, task_id=task.id, inputs=resolved_inputs, attempt=attempt,
|
|
179
|
+
registry=self.registry, llm_provider=self.llm,
|
|
180
|
+
on_tool_call=lambda: None,
|
|
181
|
+
)
|
|
182
|
+
try:
|
|
183
|
+
async with sem:
|
|
184
|
+
await global_bucket.acquire()
|
|
185
|
+
if task.provider:
|
|
186
|
+
await prov_req.get(task.provider, NullBucket()).acquire()
|
|
187
|
+
output = await self._invoke(task, ctx, timeout)
|
|
188
|
+
span.status = "succeeded"
|
|
189
|
+
span.output = output
|
|
190
|
+
span.decisions = ctx.decisions
|
|
191
|
+
span.llm = ctx.llm_usage
|
|
192
|
+
span._tool_calls = ctx.tool_calls # type: ignore[attr-defined]
|
|
193
|
+
span.duration_ms = int((time.monotonic() - t_task) * 1000)
|
|
194
|
+
return span
|
|
195
|
+
except asyncio.TimeoutError:
|
|
196
|
+
last_err = f"timeout after {timeout}s"
|
|
197
|
+
except asyncio.CancelledError:
|
|
198
|
+
span.status = "skipped"
|
|
199
|
+
span.error = "cancelled"
|
|
200
|
+
span.duration_ms = int((time.monotonic() - t_task) * 1000)
|
|
201
|
+
return span
|
|
202
|
+
except Exception as exc: # noqa: BLE001
|
|
203
|
+
last_err = f"{type(exc).__name__}: {exc}"
|
|
204
|
+
span.decisions = ctx.decisions
|
|
205
|
+
span.llm = ctx.llm_usage
|
|
206
|
+
span._tool_calls = ctx.tool_calls # type: ignore[attr-defined]
|
|
207
|
+
if attempt <= retry.max:
|
|
208
|
+
await asyncio.sleep(self._backoff(retry, attempt))
|
|
209
|
+
|
|
210
|
+
span.status = "failed"
|
|
211
|
+
span.error = last_err
|
|
212
|
+
span.duration_ms = int((time.monotonic() - t_task) * 1000)
|
|
213
|
+
return span
|
|
214
|
+
|
|
215
|
+
async def _invoke(self, task: Task, ctx: SkillContext, timeout: Optional[float]) -> Any:
|
|
216
|
+
handler = self.registry.get_skill(task.skill)
|
|
217
|
+
if inspect.iscoroutinefunction(handler):
|
|
218
|
+
coro = handler(ctx, ctx.inputs)
|
|
219
|
+
else:
|
|
220
|
+
loop = asyncio.get_event_loop()
|
|
221
|
+
coro = loop.run_in_executor(None, lambda: handler(ctx, ctx.inputs))
|
|
222
|
+
if timeout is not None:
|
|
223
|
+
return await asyncio.wait_for(coro, timeout=timeout)
|
|
224
|
+
return await coro
|
|
225
|
+
|
|
226
|
+
@staticmethod
|
|
227
|
+
def _backoff(retry: Retry, attempt: int) -> float:
|
|
228
|
+
if retry.backoff == "none":
|
|
229
|
+
base = 0.0
|
|
230
|
+
elif retry.backoff == "fixed":
|
|
231
|
+
base = retry.base
|
|
232
|
+
else: # exponential
|
|
233
|
+
base = retry.base * (2 ** (attempt - 1))
|
|
234
|
+
if retry.jitter and base > 0:
|
|
235
|
+
base *= 0.5 + random.random() / 2
|
|
236
|
+
return base
|
|
237
|
+
|
|
238
|
+
# --- inputs plumbing ---
|
|
239
|
+
_current_inputs: Dict[str, Any] = {}
|
|
240
|
+
|
|
241
|
+
def _validate_inputs(self, workflow: Workflow, inputs: Dict[str, Any]) -> None:
|
|
242
|
+
self._current_inputs = inputs
|
|
243
|
+
for name, schema in workflow.inputs_schema.items():
|
|
244
|
+
if schema.get("required") and name not in inputs:
|
|
245
|
+
raise ValueError(f"missing required input: {name!r}")
|