Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/entrypoints/routers.py
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,7 @@ async def lifespan(*args, **kwargs):

tools = ToolsRouter(
tools_service=tools_service,
workflows_service=workflows_service,
)

triggers = TriggersRouter(
Expand Down
153 changes: 153 additions & 0 deletions api/oss/src/apis/fastapi/tools/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,24 @@
ToolsService,
)
from oss.src.core.gateway.connections.utils import decode_oauth_state
from oss.src.core.workflows.service import WorkflowsService
from oss.src.core.workflows.dtos import (
WorkflowServiceRequest,
WorkflowServiceRequestData,
)
from oss.src.core.shared.dtos import Reference
from oss.src.utils.env import env

from oss.src.core.access.permissions.types import Permission
from oss.src.core.access.permissions.service import check_action_access
from oss.src.apis.fastapi.shared.exceptions import FORBIDDEN_EXCEPTION

# A workflow referenced as an agent tool (a ``type:"reference"`` tool) carries this call_ref
# prefix. Distinct from the Composio 5-segment grammar (``tools.{provider}.{integration}.
# {action}.{connection}``): the callback encodes the targeting axis + identity —
# ``workflow.variant.{slug}[.{version}]`` or ``workflow.environment.{environment}.{slug}``.
_WORKFLOW_CALL_REF_PREFIX = "workflow."

_SLUG_SEGMENT_RE = re.compile(r"[a-zA-Z0-9_-]+")

log = get_module_logger(__name__)
Expand Down Expand Up @@ -115,8 +127,13 @@ def __init__(
self,
*,
tools_service: ToolsService,
workflows_service: Optional[WorkflowsService] = None,
):
self.tools_service = tools_service
# Used to execute a referenced-workflow (@ag.reference) agent tool server-side: a
# ``workflow.{slug}[.{version}]`` call_ref routes here instead of the Composio adapter.
# Optional so a deployment that wires only the tools service still serves gateway tools.
self.workflows_service = workflows_service

self.router = APIRouter()

Expand Down Expand Up @@ -972,6 +989,13 @@ async def call_tool(
if not has_permission:
raise FORBIDDEN_EXCEPTION

# Route by the call_ref prefix: a referenced-workflow (@ag.reference) tool carries a
# ``workflow.{slug}[.{version}]`` call_ref and runs a workflow revision server-side; a
# Composio gateway tool carries the 5-segment ``tools.*`` slug and runs via the adapter.
call_ref = body.data.function.name.replace("__", ".")
if call_ref.startswith(_WORKFLOW_CALL_REF_PREFIX):
return await self._call_workflow_tool(request=request, body=body)
Comment on lines +995 to +997

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify workflow/environment/version slug validators reject consecutive underscores
# wherever referenced workflow tool call_refs are created or accepted.
rg -n -C3 '(__|consecutive underscores|workflow.*slug|slug.*workflow|ReferenceToolConfig|call_ref)' \
  api/oss/src sdks/python/agenta/sdk

Repository: Agenta-AI/agenta

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the exact router code around the cited lines.
sed -n '980,1010p' api/oss/src/apis/fastapi/tools/router.py
printf '\n---\n'
sed -n '1125,1145p' api/oss/src/apis/fastapi/tools/router.py

printf '\n=== slug/call_ref search ===\n'
rg -n -C2 'replace\("__", "\."\)|call_ref|WORKFLOW_CALL_REF_PREFIX|slug|variant_slug|environment_slug|version_slug|consecutive underscores|__' \
  api/oss/src/apis/fastapi/tools/router.py \
  api/oss/src/apis/fastapi \
  sdks/python/agenta/sdk

printf '\n=== workflow/application/evaluator constructors ===\n'
sed -n '118,230p' sdks/python/agenta/sdk/decorators/running.py
printf '\n---\n'
sed -n '660,810p' sdks/python/agenta/sdk/decorators/running.py

printf '\n=== any explicit slug validation helpers ===\n'
rg -n -C3 'def .*slug|slug.*validate|validate.*slug|Regex|consecutive.*underscore|__' \
  api/oss/src sdks/python/agenta/sdk

Repository: Agenta-AI/agenta

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '=== router lines around the two call_ref conversions ===\n'
sed -n '990,1005p' api/oss/src/apis/fastapi/tools/router.py
printf '\n---\n'
sed -n '1128,1140p' api/oss/src/apis/fastapi/tools/router.py

printf '\n=== workflow/tool call_ref construction and parsing ===\n'
rg -n -C3 'call_ref|replace\("__", "\."\)|_WORKFLOW_CALL_REF_PREFIX|function\.name|workflow.*call_ref|tool call_ref' \
  api/oss/src/apis/fastapi/tools/router.py \
  api/oss/src sdks/python/agenta/sdk

printf '\n=== slug validation / model constraints ===\n'
rg -n -C3 'slug.*(regex|pattern|consecutive|underscore|__)|variant_slug|environment_slug|version_slug|Field\(.*slug|constr\(.*slug|StringConstraints|validator.*slug' \
  api/oss/src sdks/python/agenta/sdk

printf '\n=== workflow/application/evaluator definitions ===\n'
sed -n '118,235p' sdks/python/agenta/sdk/decorators/running.py
printf '\n---\n'
sed -n '660,805p' sdks/python/agenta/sdk/decorators/running.py

printf '\n=== any helper that normalizes "__" elsewhere ===\n'
rg -n -C2 'replace\("__", "\."\)|replace\("\.", "__"\)|"__"' api/oss/src sdks/python/agenta/sdk

Repository: Agenta-AI/agenta

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '=== workflow/environment slug field definitions ===\n'
sed -n '90,130p' api/oss/src/core/workflows/dtos.py
printf '\n---\n'
sed -n '70,120p' api/oss/src/core/environments/dtos.py
printf '\n---\n'
sed -n '150,220p' api/oss/src/core/applications/dtos.py
printf '\n---\n'
sed -n '70,120p' api/oss/src/core/queries/dtos.py
printf '\n---\n'
sed -n '90,150p' api/oss/src/core/evaluators/dtos.py

printf '\n=== where workflow/environment slugs are generated ===\n'
rg -n -C2 'uuid4\(\)\.hex|slug=|variant_slug|environment_slug|workflow_slug|query_variant_slug|evaluator_variant_slug' \
  api/oss/src/core/workflows/service.py \
  api/oss/src/core/environments/service.py \
  api/oss/src/core/applications/service.py \
  api/oss/src/core/queries/service.py \
  api/oss/src/core/evaluators/service.py

printf '\n=== any explicit slug validation regexes in core workflows/environments/applications ===\n'
rg -n -C3 'Field\(.*pattern|constr\(|StringConstraints|regex=|pattern=' \
  api/oss/src/core/workflows \
  api/oss/src/core/environments \
  api/oss/src/core/applications \
  api/oss/src/core/queries \
  api/oss/src/core/evaluators

Repository: Agenta-AI/agenta

Length of output: 39752


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '=== workflow base model slug fields ===\n'
rg -n -C3 'class (WorkflowCreate|WorkflowEdit|WorkflowQuery|WorkflowVariantCreate|WorkflowVariantEdit|WorkflowRevisionCommit|Reference)' \
  api/oss/src/core/workflows/dtos.py \
  api/oss/src/core/shared/dtos.py \
  sdks/python/agenta/sdk/agents/tools/models.py

printf '\n=== workflow base model slices ===\n'
sed -n '1,90p' api/oss/src/core/workflows/dtos.py
printf '\n---\n'
sed -n '130,220p' api/oss/src/core/workflows/dtos.py
printf '\n---\n'
sed -n '1,120p' api/oss/src/core/shared/dtos.py
printf '\n---\n'
sed -n '140,220p' sdks/python/agenta/sdk/agents/tools/models.py

printf '\n=== any normalize/encode helper for workflow refs ===\n'
rg -n -C2 'replace\("__", "\."\)|normalize.*reference|call_ref|callRef|workflow\.\{axis\}' \
  sdks/python/agenta/sdk/agents \
  api/oss/src/apis/fastapi/tools/router.py \
  api/oss/src/core/tools/service.py

Repository: Agenta-AI/agenta

Length of output: 28051


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '=== shared slug / identifier model definitions ===\n'
rg -n -C4 'class Slug|Slug\(|pattern=|regex=|constr\(|StringConstraints|Field\(.*slug|def .*slug' \
  api/oss/src/core/shared/dtos.py \
  api/oss/src/core/git/dtos.py \
  sdks/python/agenta/sdk/models/shared.py \
  sdks/python/agenta/sdk/models/workflows.py

printf '\n=== relevant slug slices ===\n'
sed -n '1,120p' api/oss/src/core/shared/dtos.py
printf '\n---\n'
sed -n '1,120p' api/oss/src/core/git/dtos.py
printf '\n---\n'
sed -n '1,160p' sdks/python/agenta/sdk/models/shared.py
printf '\n---\n'
sed -n '1,220p' sdks/python/agenta/sdk/models/workflows.py

printf '\n=== workflow tool reference construction sites ===\n'
rg -n -C2 'ReferenceToolConfig|call_ref = f"workflow\.|workflow\.variant|workflow\.environment|replace\("__", "\."\)' \
  sdks/python/agenta/sdk \
  api/oss/src/apis/fastapi/tools/router.py

Repository: Agenta-AI/agenta

Length of output: 25671


Avoid lossy __. normalization in workflow call refs. Slug.check_url_safety allows consecutive underscores (and dots), so body.data.function.name.replace("__", ".") can turn a valid slug like foo__bar into foo.bar before dispatch. Use a delimiter-safe parse or reject __ in workflow refs.


# Parse tool slug — accept both dot and double-underscore formats.
# Double-underscore is used for LLM function names where dots are forbidden.
slug_parts = body.data.function.name.replace("__", ".").split(".")
Expand Down Expand Up @@ -1060,6 +1084,135 @@ async def call_tool(

return ToolCallResponse(call=result)

@staticmethod
def _validate_slug_segments(*, segments: List[str]) -> None:
"""Reject any call_ref segment with characters outside the safe slug allowlist."""
for segment in segments:
if not _SLUG_SEGMENT_RE.fullmatch(segment):
raise HTTPException(
status_code=400,
detail=f"Invalid characters in workflow tool segment: {segment!r}",
)

async def _call_workflow_tool(
self,
*,
request: Request,
body: ToolCall,
) -> ToolCallResponse:
"""Execute a workflow referenced as an agent tool (``type:"reference"``) server-side.

The model's call routes here via a ``workflow.{axis}.*`` call_ref. We parse the targeting
axis, invoke the selected workflow revision with the model's arguments as ``inputs``, and
return its outputs as the tool result. Connections/secrets the workflow needs stay
server-side (auth is minted from the caller's project + user), the same safety shape a
gateway tool has.

Grammar (after the ``workflow.`` prefix):

- ``variant.{slug}`` / ``variant.{slug}.{version}`` — the workflow's latest revision by
slug, or a pinned revision. Mapped to the ``workflow`` reference family.
- ``environment.{environment}.{slug}`` — whatever revision is deployed in ``environment``
for the workflow ``slug``. Mapped to the ``environment`` + ``workflow`` families (the
environment selects the revision via the derived ``{slug}.revision`` key)."""
if self.workflows_service is None:
raise HTTPException(
status_code=501,
detail="Workflow tools are not enabled on this deployment.",
)

invalid_call_ref = HTTPException(
status_code=400,
detail=(
f"Invalid workflow tool call_ref: {body.data.function.name}. "
"Expected: workflow.variant.{slug}[.{version}] or "
"workflow.environment.{environment}.{slug}"
),
)

remainder = body.data.function.name.replace("__", ".")[
len(_WORKFLOW_CALL_REF_PREFIX) :
]
parts = remainder.split(".")
axis = parts[0] if parts else ""
operands = parts[1:]

if axis == "variant":
# ``variant.{slug}`` (latest) or ``variant.{slug}.{version}`` (pinned).
if len(operands) not in (1, 2):
raise invalid_call_ref
slug = operands[0]
version = operands[1] if len(operands) == 2 else None
segments = [slug] + ([version] if version else [])
self._validate_slug_segments(segments=segments)
references = {"workflow": Reference(slug=slug, version=version)}
elif axis == "environment":
# ``environment.{environment}.{slug}`` — the environment pins the revision; the
# workflow ref supplies the ``{slug}.revision`` selector key the env lookup needs.
if len(operands) != 2:
raise invalid_call_ref
environment, slug = operands
self._validate_slug_segments(segments=[environment, slug])
references = {
"environment": Reference(slug=environment),
"workflow": Reference(slug=slug),
}
else:
raise invalid_call_ref

# Normalise arguments — the LLM may send them as a JSON string.
arguments = body.data.function.arguments
if isinstance(arguments, str):
try:
arguments = json.loads(arguments)
except json.JSONDecodeError as e:
log.warning("Failed to parse workflow tool arguments as JSON: %s", e)
arguments = {}
elif not isinstance(arguments, dict):
arguments = {}
Comment on lines +1163 to +1172

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reject malformed or non-object workflow arguments before invocation.

Invalid JSON falls back to {}, and JSON arrays/scalars parsed from strings bypass the dict check. That can run workflows with unintended inputs or fail later as a 500; return a 400 before invoking.

Suggested fix
         arguments = body.data.function.arguments
         if isinstance(arguments, str):
             try:
                 arguments = json.loads(arguments)
             except json.JSONDecodeError as e:
                 log.warning("Failed to parse workflow tool arguments as JSON: %s", e)
-                arguments = {}
-        elif not isinstance(arguments, dict):
-            arguments = {}
+                raise HTTPException(
+                    status_code=400,
+                    detail="Workflow tool arguments must be a valid JSON object.",
+                ) from e
+
+        if not isinstance(arguments, dict):
+            raise HTTPException(
+                status_code=400,
+                detail="Workflow tool arguments must be a JSON object.",
+            )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Normalise arguments — the LLM may send them as a JSON string.
arguments = body.data.function.arguments
if isinstance(arguments, str):
try:
arguments = json.loads(arguments)
except json.JSONDecodeError as e:
log.warning("Failed to parse workflow tool arguments as JSON: %s", e)
arguments = {}
elif not isinstance(arguments, dict):
arguments = {}
# Normalise arguments — the LLM may send them as a JSON string.
arguments = body.data.function.arguments
if isinstance(arguments, str):
try:
arguments = json.loads(arguments)
except json.JSONDecodeError as e:
log.warning("Failed to parse workflow tool arguments as JSON: %s", e)
raise HTTPException(
status_code=400,
detail="Workflow tool arguments must be a valid JSON object.",
) from e
if not isinstance(arguments, dict):
raise HTTPException(
status_code=400,
detail="Workflow tool arguments must be a JSON object.",
)


invoke_request = WorkflowServiceRequest(
references=references,
data=WorkflowServiceRequestData(inputs=arguments),
)

try:
response = await self.workflows_service.invoke_workflow(
project_id=UUID(request.state.project_id),
user_id=UUID(request.state.user_id),
request=invoke_request,
)
except Exception as e:
log.error("Workflow tool invocation failed", exc_info=True)
raise HTTPException(
status_code=502,
detail=f"Workflow tool '{slug}' invocation failed.",
) from e

status_code = response.status.code if response.status else 200
successful = status_code is not None and 200 <= status_code < 300
outputs = response.data.outputs if response.data else None
message = (
None
if successful
else (response.status.message if response.status else None)
)

result = ToolResult(
id=uuid4(),
data=ToolResultData(
tool_call_id=body.data.id,
content=json.dumps(outputs),
),
status=Status(
timestamp=datetime.now(timezone.utc),
code="STATUS_CODE_OK" if successful else "STATUS_CODE_ERROR",
message=message,
),
)

return ToolCallResponse(call=result)


# ---------------------------------------------------------------------------
# Helpers
Expand Down
44 changes: 44 additions & 0 deletions api/oss/tests/pytest/unit/embeds/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,50 @@ def test_find_object_embeds_in_list(self):
assert embeds[1].location == "items.1"


class TestReferenceToolIsPlainConfig:
"""A workflow referenced as a tool is a plain ``type:"reference"`` config entry, not an embed
marker. The embed resolver has no special case for it: with no ``@ag.embed`` key, the finders
simply ignore it, and an ``@ag.embed`` sibling still resolves normally.
"""

def test_object_finder_ignores_reference_tool_node(self):
config = {
"tools": [
{
"type": "reference",
"ref_by": "variant",
"slug": "summarize",
"name": "summarize",
}
]
}
assert find_object_embeds(config) == []

def test_string_and_snippet_finders_ignore_reference_tool_node(self):
config = {
"tools": [
{"type": "reference", "ref_by": "variant", "slug": "wf"},
]
}
assert find_string_embeds(config) == []
assert find_snippet_embeds(config) == []

def test_sibling_embed_still_resolves_alongside_reference_tool(self):
# An @ag.embed sibling of a type:"reference" tool is still found and resolved; the
# reference tool entry carries no embed so it is skipped.
config = {
"tools": [
{"type": "reference", "ref_by": "variant", "slug": "wf"},
],
"skills": [
{AG_EMBED_KEY: {AG_REFERENCES_KEY: {"workflow": {"slug": "skill"}}}}
],
}
embeds = find_object_embeds(config)
assert len(embeds) == 1
assert embeds[0].location == "skills.0"


class TestFindStringEmbeds:
"""Tests for finding string embeds in configuration."""

Expand Down
Loading
Loading