Skip to content

Commit e7a5ab9

Browse files
authored
Merge pull request #4711 from Agenta-AI/add-code-hook-workflow-config
[feat] Enable full configuration of `code` and `hook` `workflows` and their `schemas`
2 parents 14251f2 + f4b519f commit e7a5ab9

20 files changed

Lines changed: 1096 additions & 242 deletions

File tree

sdks/python/agenta/sdk/decorators/running.py

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,27 @@
4343
retrieve_handler,
4444
retrieve_interface,
4545
retrieve_configuration,
46+
parse_uri,
4647
)
48+
from agenta.sdk.engines.running.handlers import remote_forward_v0
4749

4850
import agenta as ag
4951

5052

5153
log = get_module_logger(__name__)
5254

5355

56+
def _is_custom_hook(uri: Optional[str]) -> bool:
57+
"""True for a custom hook URI (any provider/version), e.g. agenta:custom:hook:v0."""
58+
if not uri:
59+
return False
60+
try:
61+
_provider, kind, key, _version = parse_uri(uri)
62+
except Exception:
63+
return False
64+
return kind == "custom" and key == "hook"
65+
66+
5467
class InvokeFn(Protocol):
5568
async def __call__(
5669
self,
@@ -133,6 +146,8 @@ def __init__(
133146
#
134147
revision: Optional[dict] = None,
135148
# -------------------------------------------------------------------- #
149+
remote: bool = False,
150+
# -------------------------------------------------------------------- #
136151
**kwargs,
137152
):
138153
# -------------------------------------------------------------------- #
@@ -194,6 +209,8 @@ def __init__(
194209

195210
self.handler = None
196211

212+
self.remote = remote
213+
197214
self.middlewares = [
198215
VaultMiddleware(),
199216
ResolverMiddleware(),
@@ -205,22 +222,25 @@ def __init__(
205222
self.uri = _data.uri
206223

207224
if self.uri is not None:
208-
self._retrieve_handler(self.uri)
209-
210-
if self.handler:
211-
registered = retrieve_interface(self.uri)
212-
if registered:
213-
# merge registered interface into revision data, keeping caller overrides
214-
merged = registered.model_dump(exclude_none=True)
215-
merged.update(self.revision.data.model_dump(exclude_none=True))
216-
self.revision.data = WorkflowRevisionData(**merged)
217-
self.uri = self.revision.data.uri
218-
219-
registered_config = retrieve_configuration(self.uri)
220-
if registered_config and not self.revision.data.parameters:
221-
self.revision.data.parameters = registered_config.parameters
222-
223-
self.parameters = self.revision.data.parameters
225+
# A user custom hook must run its own installed handler (local) or
226+
# forward to its url (remote); the URI must not resolve to a managed
227+
# handler that would shadow the function attached by the decorator.
228+
if not _is_custom_hook(self.uri):
229+
self._retrieve_handler(self.uri)
230+
231+
registered = retrieve_interface(self.uri)
232+
if registered:
233+
# merge registered interface into revision data, keeping caller overrides
234+
merged = registered.model_dump(exclude_none=True)
235+
merged.update(self.revision.data.model_dump(exclude_none=True))
236+
self.revision.data = WorkflowRevisionData(**merged)
237+
self.uri = self.revision.data.uri
238+
239+
registered_config = retrieve_configuration(self.uri)
240+
if registered_config and not self.revision.data.parameters:
241+
self.revision.data.parameters = registered_config.parameters
242+
243+
self.parameters = self.revision.data.parameters
224244

225245
def __call__(self, handler: Optional[Callable[..., Any]] = None) -> Workflow:
226246
if self.handler is None and handler is not None:
@@ -373,6 +393,21 @@ async def invoke(
373393
)
374394
running_ctx.parameters = self.parameters
375395

396+
# remote=True forwards to the workflow url; otherwise run the
397+
# installed handler. Seeding it here lets the resolver keep the
398+
# decorator's handler instead of re-resolving by URI.
399+
running_ctx.handler = remote_forward_v0 if self.remote else self.handler
400+
log.info(
401+
"workflow handler bound",
402+
uri=self.uri,
403+
remote=self.remote,
404+
handler=getattr(
405+
running_ctx.handler,
406+
"__name__",
407+
type(running_ctx.handler).__name__,
408+
),
409+
)
410+
376411
async def terminal(req: WorkflowInvokeRequest):
377412
return None
378413

sdks/python/agenta/sdk/engines/running/errors.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,21 @@ def __init__(self, message: str, stacktrace: Optional[str] = None):
250250
)
251251

252252

253+
class CustomHookHandlerNotDefinedV0Error(ErrorStatus):
254+
code: int = 500
255+
type: str = f"{ERRORS_BASE_URL}#v0:workflows:custom-hook-handler-not-defined"
256+
257+
def __init__(self) -> None:
258+
super().__init__(
259+
code=self.code,
260+
type=self.type,
261+
message=(
262+
"Custom hook has no handler. Define a local handler on the workflow, "
263+
"or set remote=True to forward to its configured url."
264+
),
265+
)
266+
267+
253268
class CustomCodeServerV0Error(ErrorStatus):
254269
code: int = 500
255270
type: str = f"{ERRORS_BASE_URL}#v0:workflows:custom-code-server-error"

sdks/python/agenta/sdk/engines/running/handlers.py

Lines changed: 76 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
from agenta.sdk.engines.running.templates import EVALUATOR_TEMPLATES
4242
from agenta.sdk.engines.running.errors import (
4343
CustomCodeServerV0Error,
44+
CustomHookHandlerNotDefinedV0Error,
4445
ErrorStatus,
4546
InvalidConfigurationParametersV0Error,
4647
InvalidConfigurationParameterV0Error,
@@ -2226,8 +2227,15 @@ async def chat_v0(
22262227
)
22272228

22282229

2229-
@instrument(ignore_inputs=["parameters"])
2230-
async def hook_v0(
2230+
def _extract_revision_field(value: Optional[Data], field: str) -> Optional[Any]:
2231+
if isinstance(value, dict):
2232+
data = value.get("data") if "data" in value else value
2233+
if isinstance(data, dict):
2234+
return data.get(field)
2235+
return None
2236+
2237+
2238+
async def remote_forward_v0(
22312239
request: Optional[Data] = None,
22322240
revision: Optional[Data] = None,
22332241
#
@@ -2238,37 +2246,21 @@ async def hook_v0(
22382246
trace: Optional[Data] = None,
22392247
testcase: Optional[Data] = None,
22402248
) -> Any:
2241-
"""
2242-
Webhook-based application handler for CUSTOM app types.
2249+
"""Run a workflow remotely by forwarding the request to its ``url``.
22432250
2244-
Forwards the request to an external webhook URL and returns the response.
2245-
The webhook URL is read from the workflow interface (``url`` field in
2246-
revision data), not from ``parameters``.
2247-
2248-
Args:
2249-
request: Optional canonical request envelope.
2250-
revision: Optional revision data containing the webhook URL.
2251-
parameters: Configuration parameters forwarded to the webhook.
2252-
inputs: Inputs to forward to the webhook.
2253-
outputs: Optional outputs to forward to the webhook.
2254-
trace: Optional trace data to forward to the webhook.
2255-
testcase: Optional testcase data to forward to the webhook.
2256-
2257-
Returns:
2258-
The response from the webhook.
2251+
Selected when a workflow declares ``remote=True``. Reads ``url`` (and optional
2252+
``headers``) from the revision data, POSTs to ``{url}/invoke``, and returns the
2253+
response. This is execution-location logic, not a per-URI handler.
22592254
"""
22602255
from agenta.sdk.contexts.running import RunningContext
22612256

2262-
def _extract_webhook_url(value: Optional[Data]) -> Optional[str]:
2263-
if isinstance(value, dict):
2264-
data = value.get("data") if "data" in value else value
2265-
if isinstance(data, dict):
2266-
url = data.get("url")
2267-
return str(url) if url else None
2268-
return None
2269-
22702257
ctx = RunningContext.get()
2271-
webhook_url = _extract_webhook_url(revision) or _extract_webhook_url(ctx.revision)
2258+
webhook_url = _extract_revision_field(revision, "url") or _extract_revision_field(
2259+
ctx.revision, "url"
2260+
)
2261+
headers = _extract_revision_field(revision, "headers") or _extract_revision_field(
2262+
ctx.revision, "headers"
2263+
)
22722264

22732265
if not webhook_url:
22742266
raise MissingConfigurationParameterV0Error(path="url")
@@ -2283,6 +2275,12 @@ def _extract_webhook_url(value: Optional[Data]) -> Optional[str]:
22832275
got=webhook_url,
22842276
) from exc
22852277

2278+
# The stored url is the service base (pre-/invoke); the invoke surface lives
2279+
# at /invoke and is always appended.
2280+
target_url = f"{webhook_url.rstrip('/')}/invoke"
2281+
2282+
log.info("remote_forward_v0 POST", url=target_url)
2283+
22862284
json_payload = {
22872285
"inputs": inputs or {},
22882286
"parameters": parameters or {},
@@ -2294,11 +2292,19 @@ def _extract_webhook_url(value: Optional[Data]) -> Optional[str]:
22942292
if testcase is not None:
22952293
json_payload["testcase"] = testcase
22962294

2295+
# httpx requires str->str headers; coerce values from revision data.
2296+
request_headers = (
2297+
{str(k): str(v) for k, v in headers.items()}
2298+
if isinstance(headers, dict)
2299+
else None
2300+
)
2301+
22972302
async with httpx.AsyncClient() as client:
22982303
try:
22992304
response = await client.post(
2300-
url=webhook_url,
2305+
url=target_url,
23012306
json=json_payload,
2307+
headers=request_headers,
23022308
timeout=httpx.Timeout(30.0, connect=5.0),
23032309
)
23042310
except Exception as e:
@@ -2330,6 +2336,26 @@ def _extract_webhook_url(value: Optional[Data]) -> Optional[str]:
23302336
return response_bytes.decode("utf-8")
23312337

23322338

2339+
async def hook_v0(
2340+
request: Optional[Data] = None,
2341+
revision: Optional[Data] = None,
2342+
#
2343+
parameters: Optional[Data] = None,
2344+
inputs: Optional[Data] = None,
2345+
outputs: Optional[Union[Data, str]] = None,
2346+
#
2347+
trace: Optional[Data] = None,
2348+
testcase: Optional[Data] = None,
2349+
) -> Any:
2350+
"""Placeholder for the custom-hook URI. Reaching it is a misconfiguration.
2351+
2352+
A custom hook must run its own installed handler (local) or forward to its url
2353+
(``remote=True``). The URI never resolves to this function in either path, so
2354+
being here means a custom hook was invoked without a defined handler.
2355+
"""
2356+
raise CustomHookHandlerNotDefinedV0Error()
2357+
2358+
23332359
def _resolve_reference_value(reference: Any, request: Dict[str, Any]) -> Any:
23342360
"""Resolve a reference that may be a JSONPath/Pointer selector or a literal value.
23352361
@@ -2839,11 +2865,28 @@ async def code_v0(
28392865
got=parameters,
28402866
)
28412867

2842-
if "code" not in parameters:
2843-
raise MissingConfigurationParameterV0Error(path="code")
2868+
from agenta.sdk.contexts.running import RunningContext
28442869

2845-
code = str(parameters["code"])
2846-
runtime = str(parameters.get("runtime") or "python")
2870+
ctx = RunningContext.get()
2871+
2872+
# Canonical source is the revision data; legacy `parameters["code"]` is the fallback.
2873+
code = (
2874+
_extract_revision_field(revision, "script")
2875+
or _extract_revision_field(ctx.revision, "script")
2876+
or parameters.get("code")
2877+
)
2878+
if code is None:
2879+
raise MissingConfigurationParameterV0Error(path="script")
2880+
2881+
runtime = (
2882+
_extract_revision_field(revision, "runtime")
2883+
or _extract_revision_field(ctx.revision, "runtime")
2884+
or parameters.get("runtime")
2885+
or "python"
2886+
)
2887+
2888+
code = str(code)
2889+
runtime = str(runtime)
28472890

28482891
if runtime not in ["python", "javascript", "typescript"]:
28492892
raise InvalidConfigurationParameterV0Error(

sdks/python/agenta/sdk/engines/running/runners/restricted.py

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,12 @@
99
full_write_guard,
1010
)
1111

12-
from agenta.sdk.engines.running.runners.base import CodeRunner
12+
from agenta.sdk.engines.running.runners.base import CodeRunner, normalize_result
1313

1414

1515
# Pure data/iteration builtins that RestrictedPython's safe_builtins omits but
1616
# evaluators routinely need. All operate on data only — none reach the host or
17-
# the class graph, so adding them does not widen the sandbox (escapes go through
18-
# attribute access, which safer_getattr blocks).
17+
# the class graph.
1918
_SAFE_EXTRA_BUILTINS = (
2019
"dict",
2120
"list",
@@ -38,16 +37,21 @@
3837
# pathlib, socket, importlib, io, shutil, ...) or the network (httpx, urllib,
3938
# requests, ...) is excluded. Operators who need unrestricted execution must opt
4039
# into the `local` runner; hostile multi-tenant should use `daytona`.
40+
#
41+
# EXCLUDED intentionally even though they look safe:
42+
# - `typing` — exposes `typing.sys`, giving access to `sys.modules` and
43+
# therefore every already-loaded module (including `os`).
44+
# - `datetime` — same escape: `datetime.sys.modules`.
45+
# - `statistics`— same escape: `statistics.sys.modules`.
46+
# safer_getattr only blocks underscore-prefixed names; plain public attributes
47+
# like `sys` and `modules` on imported module objects are not blocked.
4148
_ALLOWED_IMPORTS = frozenset(
4249
{
4350
"math",
44-
"statistics",
45-
"datetime",
4651
"json",
4752
"re",
4853
"random",
4954
"string",
50-
"typing",
5155
"collections",
5256
"itertools",
5357
"functools",
@@ -129,16 +133,21 @@ def run(
129133
trace: Full trace data (v2 only)
130134
131135
Returns:
132-
Float score between 0 and 1, or None if execution fails
136+
Versions "1"/"2": float score between 0 and 1.
137+
Version "3": any JSON-serializable value (dict, list, str, float, bool).
133138
"""
134139
# Normalize runtime: None means python
135140
runtime = runtime or "python"
136141

137142
# The restricted sandbox runs in-process and only supports Python.
143+
# JavaScript and TypeScript require the Daytona runner
144+
# (AGENTA_SERVICES_CODE_SANDBOX_RUNNER=daytona).
138145
if runtime != "python":
139146
raise ValueError(
140-
f"RestrictedRunner only supports 'python' runtime, got: {runtime}. "
141-
"Use the Daytona runner for javascript/typescript."
147+
f"Runtime '{runtime}' is not supported by the default sandbox. "
148+
"JavaScript and TypeScript evaluators require the Daytona runner. "
149+
"Set AGENTA_SERVICES_CODE_SANDBOX_RUNNER=daytona, or change the "
150+
"runtime to 'python'."
142151
)
143152

144153
try:
@@ -153,24 +162,12 @@ def run(
153162

154163
fn = environment["evaluate"]
155164

156-
if version == "2":
165+
if version in ("2", "3"):
157166
result = fn(inputs, output, trace)
158167
else:
159168
result = fn(app_params, inputs, output, correct_answer)
160169

161-
# Attempt to convert result to float
162-
if isinstance(result, (float, int, str)):
163-
try:
164-
result = float(result)
165-
except ValueError as e:
166-
raise ValueError(f"Result cannot be converted to float: {e}")
167-
168-
if not isinstance(result, float):
169-
raise TypeError(
170-
f"Result is not a float after conversion: {type(result)}"
171-
)
172-
173-
return result
170+
return normalize_result(result, version)
174171

175172
except KeyError as e:
176173
raise KeyError(f"Missing expected key in environment: {e}")

0 commit comments

Comments
 (0)