diff --git a/.gitignore b/.gitignore index 6c91758e28..27b49f8c72 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ web/packages/agenta-api-client/dist/ web/tsconfig.tsbuildinfo # Agent Pi extension bundle, built by `pnpm run build:extension` and in the Docker image. services/agent/dist/ +# Agent runner test/coverage artifacts (vitest writes these on `pnpm test` / coverage runs). +services/agent/test-results/ +services/agent/coverage/ __pycache__/ **/__pycache__/ diff --git a/api/entrypoints/routers.py b/api/entrypoints/routers.py index d90b38c5f1..6564fb8c2e 100644 --- a/api/entrypoints/routers.py +++ b/api/entrypoints/routers.py @@ -87,6 +87,7 @@ from oss.src.core.folders.service import FoldersService from oss.src.core.workflows.service import WorkflowsService from oss.src.core.workflows.service import SimpleWorkflowsService +from oss.src.core.workflows.platform_catalog import PlatformWorkflowCatalog from oss.src.core.evaluators.service import EvaluatorsService from oss.src.core.evaluators.service import SimpleEvaluatorsService from oss.src.core.environments.service import EnvironmentsService @@ -495,6 +496,7 @@ async def lifespan(*args, **kwargs): workflows_service = WorkflowsService( workflows_dao=workflows_dao, + platform_catalog=PlatformWorkflowCatalog(), ) environments_service = EnvironmentsService( diff --git a/api/oss/src/apis/fastapi/workflows/exceptions.py b/api/oss/src/apis/fastapi/workflows/exceptions.py new file mode 100644 index 0000000000..887395602d --- /dev/null +++ b/api/oss/src/apis/fastapi/workflows/exceptions.py @@ -0,0 +1,34 @@ +"""Typed HTTP exceptions and a translation decorator for workflow-domain errors. + +Mirrors ``api/oss/src/apis/fastapi/git/exceptions.py`` but for workflow-specific domain errors +(``oss.src.core.workflows.types``) that the shared git pattern does not cover. Place the decorator +inside ``@intercept_exceptions()`` so the typed HTTP exception is the one re-raised. +""" + +from functools import wraps + +from fastapi import HTTPException + +from oss.src.core.workflows.types import ReservedWorkflowSlug + + +class ReservedWorkflowSlugException(HTTPException): + def __init__( + self, + message: str = "The slug prefix '_agenta.' is reserved for platform workflows.", + ): + super().__init__(status_code=400, detail=message) + + +def handle_workflow_exceptions(): + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + try: + return await func(*args, **kwargs) + except ReservedWorkflowSlug as e: + raise ReservedWorkflowSlugException(message=e.message) from e + + return wrapper + + return decorator diff --git a/api/oss/src/apis/fastapi/workflows/router.py b/api/oss/src/apis/fastapi/workflows/router.py index ca5445fed6..5280a99216 100644 --- a/api/oss/src/apis/fastapi/workflows/router.py +++ b/api/oss/src/apis/fastapi/workflows/router.py @@ -14,6 +14,7 @@ ) from oss.src.core.git.utils import build_retrieval_info from oss.src.apis.fastapi.git.exceptions import handle_git_exceptions +from oss.src.apis.fastapi.workflows.exceptions import handle_workflow_exceptions from oss.src.core.workflows.service import ( WorkflowsService, SimpleWorkflowsService, @@ -598,6 +599,7 @@ async def fetch_workflow_catalog_preset( # WORKFLOWS ---------------------------------------------------------------- @intercept_exceptions() + @handle_workflow_exceptions() async def create_workflow( self, request: Request, @@ -877,6 +879,7 @@ async def query_workflows( # WORKFLOW VARIANTS -------------------------------------------------------- @intercept_exceptions() + @handle_workflow_exceptions() async def create_workflow_variant( self, request: Request, @@ -1151,6 +1154,7 @@ async def query_workflow_variants( return workflow_variants_response @intercept_exceptions() + @handle_workflow_exceptions() @handle_git_exceptions() async def fork_workflow_variant( self, @@ -1188,6 +1192,7 @@ async def fork_workflow_variant( # WORKFLOW REVISIONS ------------------------------------------------------- @intercept_exceptions() + @handle_workflow_exceptions() @handle_git_exceptions() async def create_workflow_revision( self, @@ -1438,6 +1443,7 @@ async def query_workflow_revisions( return workflow_revisions_response @intercept_exceptions() + @handle_workflow_exceptions() async def commit_workflow_revision( self, request: Request, @@ -1929,6 +1935,7 @@ def __init__( # SIMPLE WORKFLOWS --------------------------------------------------------- @intercept_exceptions() + @handle_workflow_exceptions() async def create_simple_workflow( self, request: Request, diff --git a/api/oss/src/core/workflows/dtos.py b/api/oss/src/core/workflows/dtos.py index 93d74f3af2..c4d08a6523 100644 --- a/api/oss/src/core/workflows/dtos.py +++ b/api/oss/src/core/workflows/dtos.py @@ -120,6 +120,10 @@ class WorkflowArtifactFlags(BaseModel): is_application: bool = False is_evaluator: bool = False is_snippet: bool = False + is_skill: bool = False + # platform-owned (read-only): served from the PlatformWorkflowCatalog under the reserved + # `_agenta.*` slug namespace, never the database. Lives in JSONB flags, no migration. + is_platform: bool = False class WorkflowVariantFlags(WorkflowArtifactFlags): @@ -153,6 +157,8 @@ class WorkflowArtifactQueryFlags(BaseModel): is_application: Optional[bool] = None is_evaluator: Optional[bool] = None is_snippet: Optional[bool] = None + is_skill: Optional[bool] = None + is_platform: Optional[bool] = None class WorkflowVariantQueryFlags(WorkflowArtifactQueryFlags): @@ -197,6 +203,8 @@ class WorkflowCatalogFlags(BaseModel): is_application: bool = False is_evaluator: bool = False is_snippet: bool = False + is_skill: bool = False + is_platform: bool = False # workflows -------------------------------------------------------------------- diff --git a/api/oss/src/core/workflows/interfaces.py b/api/oss/src/core/workflows/interfaces.py new file mode 100644 index 0000000000..c958062ac7 --- /dev/null +++ b/api/oss/src/core/workflows/interfaces.py @@ -0,0 +1,64 @@ +"""Core contracts the workflows service depends on (not concrete DB/DAO). + +The :class:`PlatformWorkflowProvider` is the read-only seam for platform-owned workflows served +from code under a reserved slug namespace. ``WorkflowsService`` depends on this interface, never on +a concrete catalogue, so the layering rule (core depends on interfaces, the composition root wires +the implementation) holds. +""" + +from abc import ABC, abstractmethod +from typing import Optional +from uuid import UUID + +from oss.src.core.workflows.dtos import WorkflowRevision + + +class PlatformWorkflowProvider(ABC): + """A read-only provider of synthetic, code-defined workflow revisions. + + Platform workflows live under a reserved slug namespace and are served from code, never the + database. The provider answers two questions for ``WorkflowsService``: whether a slug belongs + to the reserved namespace (so the service short-circuits before any DB lookup and so user + create/edit/commit can be rejected), and what synthetic revision a reserved slug resolves to. + """ + + @abstractmethod + def is_reserved_slug(self, slug: Optional[str]) -> bool: + """Whether ``slug`` is in the reserved platform namespace. + + A slug in this namespace is never read from or written to the database. + """ + + @abstractmethod + def is_reserved_id(self, entity_id: Optional[UUID]) -> bool: + """Whether ``entity_id`` is a synthetic platform artifact / variant / revision id. + + Lets an id-only reference short-circuit to the catalogue (deploy emits synthetic ids), so + a platform id never DB-queries. + """ + + @abstractmethod + def get_revision( + self, + *, + slug: str, + version: Optional[str] = None, + ) -> Optional[WorkflowRevision]: + """Resolve a reserved slug to a synthetic :class:`WorkflowRevision`. + + With no ``version`` (an artifact-level lookup) returns the catalogue entry's ``current`` + version. With a ``version`` (a revision-level lookup) returns that immutable version, or + ``None`` if the slug is unknown or the version does not exist. + """ + + @abstractmethod + def get_revision_by_id( + self, + *, + entity_id: UUID, + ) -> Optional[WorkflowRevision]: + """Resolve a synthetic platform id (artifact / variant / revision) to its revision. + + An artifact or variant id resolves to the ``current`` revision; a revision id pins its + version. Returns ``None`` if ``entity_id`` is not a known platform id. + """ diff --git a/api/oss/src/core/workflows/platform_catalog.py b/api/oss/src/core/workflows/platform_catalog.py new file mode 100644 index 0000000000..44f90ae326 --- /dev/null +++ b/api/oss/src/core/workflows/platform_catalog.py @@ -0,0 +1,241 @@ +"""The platform workflow catalogue: code-defined, read-only platform workflows. + +Agenta ships its own managed workflows (skills today, extensible to other platform workflow kinds +later) to every project without per-project seeding and without a migration. They are served from +this catalogue under a reserved ``_agenta.*`` slug namespace, never the database, and carry +``flags.is_platform=True`` so clients and the frontend treat them as read-only. + +The catalogue is the concrete :class:`PlatformWorkflowProvider`. It holds, per reserved slug, a +``current`` version and a map of immutable versions. An artifact-level lookup (no version) resolves +to ``current``; a revision-level lookup with a version pins that immutable version. Updating an +entry (or adding a ``vN+1``) ships with the release and updates every project at once. + +Trust comes from the platform authoring the content in code: the reserved namespace guarantees a +user cannot author or shadow it, and resolution never falls through to Postgres. +""" + +from typing import Any, Dict, Optional, Tuple +from uuid import UUID, uuid5 + +from agenta.sdk.agents.skills.models import SkillConfig + +from oss.src.core.workflows.dtos import ( + WorkflowRevision, + WorkflowRevisionData, + WorkflowRevisionFlags, +) +from oss.src.core.workflows.interfaces import PlatformWorkflowProvider +from oss.src.core.workflows.types import ( + RESERVED_SLUG_PREFIX, + is_reserved_workflow_slug, +) + + +__all__ = [ + "RESERVED_SLUG_PREFIX", + "PlatformWorkflowCatalog", +] + +# Fixed namespace UUID for deterministic UUIDv5 ids. Stable across instances and restarts so a +# platform workflow keeps the same artifact / variant / revision ids everywhere. Do not change it: +# the ids are derived from it, and changing it would silently re-key every platform workflow. +_PLATFORM_NAMESPACE_UUID = UUID("a6e6b3f2-2c4a-5f3a-9b6f-0a1b2c3d4e5f") + + +# --------------------------------------------------------------------------- +# Platform skill content (single source of the body text) +# --------------------------------------------------------------------------- + +_GETTING_STARTED_BODY = ( + "# Getting started with Agenta agents\n" + "\n" + "This skill orients an agent running on the Agenta platform.\n" + "\n" + "## When to use it\n" + "\n" + "Use it at the start of a task to recall how Agenta agents are expected to behave: be " + "concise, ask for missing inputs, and prefer the tools and skills the agent was given over " + "guessing.\n" + "\n" + "## Conventions\n" + "\n" + "- Greet the user once, then get to work.\n" + "- State assumptions briefly when a request is ambiguous.\n" + "- When a skill or tool references a relative path, resolve it against the skill directory " + "(the parent of SKILL.md) before running it.\n" + "- Keep answers short unless the user asks for depth.\n" +) + + +# --------------------------------------------------------------------------- +# Catalogue definition +# --------------------------------------------------------------------------- +# +# Each entry maps a reserved slug to a `current` version pointer and a map of immutable versions. +# A version payload is a SkillConfig dict, validated at module import (see _validate_catalog). + +_PLATFORM_WORKFLOWS: Dict[str, Dict[str, Any]] = { + "_agenta.agenta-getting-started": { + "current": "v1", + "versions": { + "v1": { + "name": "agenta-getting-started", + "description": ( + "Getting started on the Agenta platform: how an Agenta agent should behave, " + "ask for missing inputs, and use its tools and skills. Use at the start of a " + "task." + ), + "body": _GETTING_STARTED_BODY, + }, + }, + }, +} + + +def _artifact_uuid(*, slug: str) -> UUID: + """Stable UUIDv5 for a platform workflow's artifact (the workflow identity). + + Version-independent: the artifact is the same workflow across every version, so its id must + not change when the catalogue adds a ``vN+1``. Same for the variant. + """ + return uuid5(_PLATFORM_NAMESPACE_UUID, f"artifact:{slug}") + + +def _variant_uuid(*, slug: str) -> UUID: + return uuid5(_PLATFORM_NAMESPACE_UUID, f"variant:{slug}") + + +def _revision_uuid(*, slug: str, version: str) -> UUID: + """Stable UUIDv5 for one immutable revision (version-scoped, unlike artifact / variant).""" + return uuid5(_PLATFORM_NAMESPACE_UUID, f"revision:{slug}:{version}") + + +class PlatformWorkflowCatalog(PlatformWorkflowProvider): + """Code-defined, read-only catalogue of platform workflows keyed by reserved slug.""" + + def __init__( + self, + *, + catalog: Optional[Dict[str, Dict[str, Any]]] = None, + ) -> None: + self._catalog = catalog if catalog is not None else _PLATFORM_WORKFLOWS + self._validate_catalog() + self._index_by_id = self._build_id_index() + + def _build_id_index(self) -> Dict[UUID, Tuple[str, Optional[str]]]: + """Map each deterministic id back to ``(slug, version)``. + + Lets an id-only reference (artifact / variant / revision id) resolve through the catalogue + without a DB query. Artifact and variant ids are version-independent, so they map to + ``(slug, None)`` (the artifact-level lookup that resolves to ``current``); a revision id + maps to its pinned ``(slug, version)``. + """ + index: Dict[UUID, Tuple[str, Optional[str]]] = {} + for slug, entry in self._catalog.items(): + index[_artifact_uuid(slug=slug)] = (slug, None) + index[_variant_uuid(slug=slug)] = (slug, None) + for version in entry.get("versions") or {}: + index[_revision_uuid(slug=slug, version=version)] = (slug, version) + return index + + def _validate_catalog(self) -> None: + """Fail fast at construction if any entry is malformed or any payload is not a valid + SkillConfig. A broken catalogue is a code error, not a runtime input error.""" + for slug, entry in self._catalog.items(): + if not slug.startswith(RESERVED_SLUG_PREFIX): + raise ValueError( + f"Platform workflow slug {slug!r} must start with {RESERVED_SLUG_PREFIX!r}." + ) + current = entry.get("current") + versions = entry.get("versions") or {} + if current not in versions: + raise ValueError( + f"Platform workflow {slug!r} current version {current!r} is not in versions " + f"{sorted(versions)}." + ) + for version, payload in versions.items(): + # Validates the payload conforms to SkillConfig; raises on a malformed entry. + SkillConfig.model_validate(payload) + + def is_reserved_slug(self, slug: Optional[str]) -> bool: + return is_reserved_workflow_slug(slug) + + def is_reserved_id(self, entity_id: Optional[UUID]) -> bool: + return entity_id is not None and entity_id in self._index_by_id + + def get_revision( + self, + *, + slug: str, + version: Optional[str] = None, + ) -> Optional[WorkflowRevision]: + entry = self._catalog.get(slug) + if not entry: + return None + + versions: Dict[str, Any] = entry.get("versions") or {} + + # Artifact-level lookup (no version) -> current. Revision-level lookup -> the pinned + # version. "Latest" is never a version value; it is the no-version artifact lookup. + resolved_version = version if version is not None else entry.get("current") + if resolved_version not in versions: + return None + + skill_config = SkillConfig.model_validate(versions[resolved_version]) + + return self._build_revision( + slug=slug, + version=resolved_version, + skill_config=skill_config, + ) + + def get_revision_by_id( + self, + *, + entity_id: UUID, + ) -> Optional[WorkflowRevision]: + """Resolve an id-only platform reference (artifact / variant / revision id). + + A synthetic id can appear in an id-only ref (deploy emits the artifact / variant ids). The + reverse index maps it to ``(slug, version)`` so it resolves through the catalogue and never + DB-queries. + """ + match = self._index_by_id.get(entity_id) + if match is None: + return None + slug, version = match + return self.get_revision(slug=slug, version=version) + + def _build_revision( + self, + *, + slug: str, + version: str, + skill_config: SkillConfig, + ) -> WorkflowRevision: + artifact_id = _artifact_uuid(slug=slug) + variant_id = _variant_uuid(slug=slug) + revision_id = _revision_uuid(slug=slug, version=version) + + return WorkflowRevision( + id=revision_id, + slug=slug, + version=version, + # + name=skill_config.name, + description=skill_config.description, + # + flags=WorkflowRevisionFlags( + is_skill=True, + is_platform=True, + is_evaluator=False, + ), + # + data=WorkflowRevisionData( + parameters={"skill": skill_config.model_dump(mode="json")}, + ), + # + workflow_id=artifact_id, + workflow_slug=slug, + workflow_variant_id=variant_id, + ) diff --git a/api/oss/src/core/workflows/service.py b/api/oss/src/core/workflows/service.py index f70e7f3719..11c67f0216 100644 --- a/api/oss/src/core/workflows/service.py +++ b/api/oss/src/core/workflows/service.py @@ -91,6 +91,11 @@ needs_default_variant_resolution, validate_retrieve_refs_consistent, ) +from oss.src.core.workflows.interfaces import PlatformWorkflowProvider +from oss.src.core.workflows.types import ( + ReservedWorkflowSlug, + is_reserved_workflow_slug, +) # Resolution is now handled by EmbedsService from oss.src.core.embeds.dtos import ( @@ -123,8 +128,13 @@ class WorkflowsService: "is_application", "is_evaluator", "is_snippet", + "is_skill", + "is_platform", } ) + # Platform-owned flags a user may never persist. Only the synthetic catalogue revision sets + # is_platform=True, and it never touches the DB; any user-supplied value is scrubbed on write. + SERVER_OWNED_FLAG_KEYS = frozenset({"is_platform"}) def __init__( self, @@ -133,15 +143,44 @@ def __init__( # environments_service: Optional["EnvironmentsService"] = None, # type: ignore embeds_service: Optional["EmbedsService"] = None, # type: ignore + platform_catalog: Optional[PlatformWorkflowProvider] = None, ): self.workflows_dao = workflows_dao self.environments_service = environments_service self.embeds_service = embeds_service + self.platform_catalog = platform_catalog @staticmethod def _artifact_cache_key(artifact_id: UUID) -> str: return str(artifact_id) + def _reject_reserved_slug(self, slug: Optional[str]) -> None: + """Reject a user-supplied slug in the reserved platform namespace. + + Platform workflows are served from code by the catalogue; a user must not be able to + author or shadow one. The check is a pure function independent of the catalogue, so it + holds even when no catalogue is injected (evaluators, migrations, the worker). Resolution + of a reserved slug never falls through to the DB, so this guard plus that short-circuit + close both directions. + """ + if is_reserved_workflow_slug(slug): + raise ReservedWorkflowSlug(slug) + + @classmethod + def _scrub_server_owned_flags(cls, flags: dict) -> dict: + """Strip server-owned flags from a user-supplied stored-flags dict before persistence. + + ``is_platform`` is owned by the platform catalogue (the synthetic revision is the only + thing that may set it true, and it never touches the DB). A user-supplied value is silently + coerced to false by dropping the key so a forged ``is_platform=true`` can never round-trip + through the database. + """ + return { + key: value + for key, value in flags.items() + if key not in cls.SERVER_OWNED_FLAG_KEYS + } + async def _get_cached_workflow( self, *, @@ -212,15 +251,17 @@ def _dump_stored_flags(cls, flags: Optional[object]) -> dict: return {} if hasattr(flags, "model_dump"): - return flags.model_dump( + dumped = flags.model_dump( mode="json", exclude_none=True, ) + elif isinstance(flags, dict): + dumped = {key: value for key, value in flags.items() if value is not None} + else: + return {} - if isinstance(flags, dict): - return {key: value for key, value in flags.items() if value is not None} - - return {} + # is_platform is server-owned; never persist a user-supplied value. + return cls._scrub_server_owned_flags(dumped) @classmethod def _dump_stored_revision_flags(cls, flags: Optional[object]) -> dict: @@ -624,6 +665,8 @@ async def create_workflow( # workflow_id: Optional[UUID] = None, ) -> Optional[Workflow]: + self._reject_reserved_slug(workflow_create.slug) + artifact_flags = self._artifact_flags_from_any(workflow_create.flags) artifact_create = ArtifactCreate( **workflow_create.model_dump( @@ -859,6 +902,8 @@ async def create_workflow_variant( # workflow_variant_create: WorkflowVariantCreate, ) -> Optional[WorkflowVariant]: + self._reject_reserved_slug(workflow_variant_create.slug) + _variant_create = VariantCreate( **workflow_variant_create.model_dump( mode="json", @@ -1024,6 +1069,8 @@ async def fork_workflow_variant( workflow_variant_ref: Reference, workflow_revision_ref: Optional[Reference] = None, ) -> Optional[WorkflowVariant]: + self._reject_reserved_slug(workflow_variant_fork.slug) + source_variant = await self.fetch_workflow_variant( project_id=project_id, workflow_variant_ref=workflow_variant_ref, @@ -1132,6 +1179,8 @@ async def create_workflow_revision( # workflow_revision_create: WorkflowRevisionCreate, ) -> Optional[WorkflowRevision]: + self._reject_reserved_slug(workflow_revision_create.slug) + _revision_create = RevisionCreate( **workflow_revision_create.model_dump( mode="json", @@ -1162,6 +1211,134 @@ async def create_workflow_revision( revision=_workflow_revision, ) + @staticmethod + def _ref_is_reserved(ref: Optional[Reference]) -> bool: + return ref is not None and is_reserved_workflow_slug(ref.slug) + + def _ref_has_reserved_id(self, ref: Optional[Reference]) -> bool: + return ( + ref is not None + and self.platform_catalog is not None + and self.platform_catalog.is_reserved_id(ref.id) + ) + + def _resolve_platform_revision( + self, + *, + workflow_ref: Optional[Reference], + workflow_variant_ref: Optional[Reference], + workflow_revision_ref: Optional[Reference], + ) -> tuple[bool, Optional[WorkflowRevision]]: + """Resolve a reserved-namespace reference to a synthetic catalogue revision. + + Returns ``(is_reserved, revision)``. When ``is_reserved`` is True the reference is in the + platform namespace (by reserved ``_agenta.*`` slug, or by a synthetic catalogue id) and the + caller must NOT fall through to the DB — even when ``revision`` is None (an unknown version, + a non-matching paired ref, or no catalogue injected), so a user can never shadow platform + content. The reserved-slug detection is a pure function independent of the catalogue, so a + reserved slug short-circuits even when no catalogue is wired (``revision`` is then None). + A revision-level reference resolves to its ``version``; an artifact / variant reference + resolves to ``current`` (or to a pinned ``version``). A non-reserved reference returns + ``(False, None)`` so the caller continues to the DB path unchanged. + """ + reserved = ( + self._ref_is_reserved(workflow_revision_ref) + or self._ref_is_reserved(workflow_ref) + or self._ref_is_reserved(workflow_variant_ref) + or self._ref_has_reserved_id(workflow_revision_ref) + or self._ref_has_reserved_id(workflow_ref) + or self._ref_has_reserved_id(workflow_variant_ref) + ) + + if not reserved: + return (False, None) + + # Reserved: never fall through to the DB. Without a catalogue we still short-circuit, but + # there is no synthetic content to serve, so return None. + if not self.platform_catalog: + return (True, None) + + # A reserved reference must not silently ignore a paired ref that points elsewhere. Resolve + # the platform revision, then reject (return None) when any sibling ref is non-matching. + revision = self._lookup_platform_revision( + workflow_ref=workflow_ref, + workflow_variant_ref=workflow_variant_ref, + workflow_revision_ref=workflow_revision_ref, + ) + + if revision is not None and not self._platform_refs_consistent( + revision=revision, + workflow_ref=workflow_ref, + workflow_variant_ref=workflow_variant_ref, + workflow_revision_ref=workflow_revision_ref, + ): + return (True, None) + + return (True, revision) + + def _lookup_platform_revision( + self, + *, + workflow_ref: Optional[Reference], + workflow_variant_ref: Optional[Reference], + workflow_revision_ref: Optional[Reference], + ) -> Optional[WorkflowRevision]: + # Slug refs first (the revision ref pins a version), then id-only refs via the reverse + # index. A revision-level slug resolves to its version; artifact / variant to current. + if self._ref_is_reserved(workflow_revision_ref): + return self.platform_catalog.get_revision( + slug=workflow_revision_ref.slug, + version=workflow_revision_ref.version, + ) + if self._ref_is_reserved(workflow_ref): + return self.platform_catalog.get_revision( + slug=workflow_ref.slug, + version=workflow_ref.version, + ) + if self._ref_is_reserved(workflow_variant_ref): + return self.platform_catalog.get_revision( + slug=workflow_variant_ref.slug, + version=workflow_variant_ref.version, + ) + + for ref in (workflow_revision_ref, workflow_ref, workflow_variant_ref): + if self._ref_has_reserved_id(ref): + return self.platform_catalog.get_revision_by_id(entity_id=ref.id) + + return None + + @staticmethod + def _platform_refs_consistent( + *, + revision: WorkflowRevision, + workflow_ref: Optional[Reference], + workflow_variant_ref: Optional[Reference], + workflow_revision_ref: Optional[Reference], + ) -> bool: + """Whether every supplied ref agrees with the resolved platform revision. + + A platform reference that carries a non-matching sibling (e.g. an unrelated variant id) must + not be served as if the extra ref did not exist. + """ + # The reserved namespace uses one slug across all three levels, so every level's slug ref + # is expected to equal the revision's reserved slug. + reserved_slug = revision.workflow_slug + checks = ( + (workflow_ref, revision.workflow_id, reserved_slug), + (workflow_variant_ref, revision.workflow_variant_id, reserved_slug), + (workflow_revision_ref, revision.id, reserved_slug), + ) + for ref, resolved_id, resolved_slug in checks: + if ref is None: + continue + if ref.id is not None and ref.id != resolved_id: + return False + if ref.slug is not None and ref.slug != resolved_slug: + return False + if ref.version is not None and ref.version != revision.version: + return False + return True + async def fetch_workflow_revision( self, *, @@ -1176,6 +1353,19 @@ async def fetch_workflow_revision( if not workflow_ref and not workflow_variant_ref and not workflow_revision_ref: return None + # Platform workflows live under the reserved `_agenta.*` slug namespace (or a synthetic + # catalogue id) and are served from code, never from Postgres. Resolve them before any DB + # lookup so a user can never shadow platform content. A reserved reference never falls + # through to the DB, even when its version is unknown, a paired ref is non-matching, or no + # catalogue is injected; a non-reserved reference falls through unchanged. + is_reserved, platform_revision = self._resolve_platform_revision( + workflow_ref=workflow_ref, + workflow_variant_ref=workflow_variant_ref, + workflow_revision_ref=workflow_revision_ref, + ) + if is_reserved: + return platform_revision + validate_variant_refs_sufficient( variant_ref=workflow_variant_ref, entity_type="workflow", @@ -1497,6 +1687,8 @@ async def commit_workflow_revision( # emit: bool = True, ) -> Optional[WorkflowRevision]: + self._reject_reserved_slug(workflow_revision_commit.slug) + data = workflow_revision_commit.data if data and data.uri and not data.url: _, kind, _, _ = parse_uri(data.uri) diff --git a/api/oss/src/core/workflows/types.py b/api/oss/src/core/workflows/types.py new file mode 100644 index 0000000000..92274c4204 --- /dev/null +++ b/api/oss/src/core/workflows/types.py @@ -0,0 +1,51 @@ +"""Workflow-domain exceptions. + +These are raised by the workflows service and translated to HTTP responses at the API boundary +(see ``api/oss/src/apis/fastapi/workflows/exceptions.py``). Per the api layering rules, services +never raise ``HTTPException`` directly. +""" + +from typing import Optional + + +# Slugs in this namespace are platform-owned: served from code by the catalogue, never the +# database. The detection is a pure function (no catalogue instance) so every write path can +# reject a reserved slug and every read path can short-circuit it even when no catalogue is +# injected. The current slug grammar already allows a leading `_`, `.`, and `-`. +RESERVED_SLUG_PREFIX = "_agenta." + + +def is_reserved_workflow_slug(slug: Optional[str]) -> bool: + """Whether ``slug`` is in the reserved platform namespace (``_agenta.*``). + + Independent of any ``PlatformWorkflowProvider`` so the guard holds even when no catalogue is + wired into ``WorkflowsService`` (evaluators, migrations, the worker). + """ + return bool(slug) and slug.startswith(RESERVED_SLUG_PREFIX) + + +class WorkflowError(Exception): + """Base exception for workflow-domain errors.""" + + def __init__(self, message: str): + self.message = message + super().__init__(message) + + +class ReservedWorkflowSlug(WorkflowError): + """Raised when a user tries to create, edit, or commit a workflow whose slug is in the + reserved platform namespace (``_agenta.*``). + + Platform workflows are served from code by the ``PlatformWorkflowCatalog``; a user must not be + able to author or shadow one. Translated to HTTP 400 at the router. + """ + + def __init__(self, slug: str, message: Optional[str] = None): + self.slug = slug + super().__init__( + message + or ( + f"The slug prefix '_agenta.' is reserved for platform workflows. " + f"Choose a different slug than '{slug}'." + ) + ) diff --git a/api/oss/tests/pytest/unit/workflows/test_flag_ownership.py b/api/oss/tests/pytest/unit/workflows/test_flag_ownership.py index 8f1997e374..44ca10f3ad 100644 --- a/api/oss/tests/pytest/unit/workflows/test_flag_ownership.py +++ b/api/oss/tests/pytest/unit/workflows/test_flag_ownership.py @@ -59,13 +59,44 @@ async def test_create_workflow_persists_only_artifact_flags(): artifact_create = workflows_dao.create_artifact.await_args.kwargs["artifact_create"] assert artifact_create.flags is not None + # is_platform is server-owned and scrubbed from every DB write, so it never appears here. assert artifact_create.flags == { "is_application": True, "is_evaluator": False, "is_snippet": False, + "is_skill": False, } +@pytest.mark.asyncio +async def test_create_workflow_persists_is_skill_artifact_flag(): + workflows_dao = AsyncMock() + service = WorkflowsService(workflows_dao=workflows_dao) + + workflow_id = uuid4() + workflows_dao.create_artifact.return_value = Workflow( + id=workflow_id, + slug="skill-wf", + flags=WorkflowArtifactFlags(is_skill=True), + ) + + await service.create_workflow( + project_id=uuid4(), + user_id=uuid4(), + workflow_create=WorkflowCreate( + slug="skill-wf", + flags=WorkflowFlags(is_skill=True, is_evaluator=False, is_custom=True), + ), + ) + + artifact_create = workflows_dao.create_artifact.await_args.kwargs["artifact_create"] + assert artifact_create.flags is not None + assert artifact_create.flags["is_skill"] is True + assert artifact_create.flags["is_evaluator"] is False + # is_custom is a revision-level (uri-derived) flag and must not land on the artifact. + assert "is_custom" not in artifact_create.flags + + @pytest.mark.asyncio async def test_create_workflow_variant_drops_variant_flags(): workflows_dao = AsyncMock() @@ -276,6 +307,12 @@ async def test_edit_workflow_revision_persists_only_revision_flags(): slug="rev", flags=WorkflowRevisionFlags(is_chat=True), ) + workflows_dao.fetch_revision.return_value = WorkflowRevision( + id=revision_id, + workflow_id=artifact_id, + workflow_variant_id=variant_id, + slug="rev", + ) workflows_dao.fetch_artifact.return_value = Workflow( id=artifact_id, slug="wf", @@ -449,6 +486,11 @@ async def test_edit_workflow_refreshes_artifact_cache(monkeypatch): slug="wf", flags=WorkflowArtifactFlags(is_application=True), ) + workflows_dao.fetch_artifact.return_value = Workflow( + id=workflow_id, + slug="wf", + flags=WorkflowArtifactFlags(is_application=True), + ) monkeypatch.setattr(workflows_service_module, "get_cache", AsyncMock()) set_cache = AsyncMock() diff --git a/api/oss/tests/pytest/unit/workflows/test_platform_catalog.py b/api/oss/tests/pytest/unit/workflows/test_platform_catalog.py new file mode 100644 index 0000000000..d5bf5e22ec --- /dev/null +++ b/api/oss/tests/pytest/unit/workflows/test_platform_catalog.py @@ -0,0 +1,636 @@ +"""Platform workflow catalogue + the ``fetch_workflow_revision`` short-circuit. + +Platform workflows live under the reserved ``_agenta.*`` slug namespace and are served from code +by :class:`PlatformWorkflowCatalog`, never the database. These tests pin: + +- the catalogue resolves an artifact-level lookup to ``current`` and a revision-level lookup to a + pinned version, returns ``None`` for an unknown version, and mints deterministic ids; +- ``WorkflowsService.fetch_workflow_revision`` short-circuits a reserved slug before any DB call, + and leaves the DB path untouched for a normal slug; +- a user cannot create a workflow whose slug is in the reserved namespace. +""" + +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from oss.src.core.embeds.service import EmbedsService +from oss.src.core.shared.dtos import Reference +from oss.src.core.workflows.dtos import ( + Workflow, + WorkflowCreate, + WorkflowEdit, + WorkflowFlags, + WorkflowQuery, + WorkflowArtifactQueryFlags, + WorkflowRevision, + WorkflowRevisionCreate, + WorkflowRevisionData, + WorkflowRevisionFlags, + WorkflowVariantCreate, + WorkflowVariantFork, +) +from oss.src.core.workflows.platform_catalog import ( + RESERVED_SLUG_PREFIX, + PlatformWorkflowCatalog, +) +from oss.src.core.workflows.service import WorkflowsService +from oss.src.core.workflows.types import ( + ReservedWorkflowSlug, + is_reserved_workflow_slug, +) + + +_PLATFORM_SLUG = "_agenta.agenta-getting-started" + +# A two-version catalogue used to pin that the artifact / variant ids are stable across versions +# while the revision id is version-scoped. +_MULTI_VERSION_CATALOG = { + _PLATFORM_SLUG: { + "current": "v2", + "versions": { + "v1": {"name": "demo", "description": "first", "body": "v1 body"}, + "v2": {"name": "demo", "description": "second", "body": "v2 body"}, + }, + }, +} + + +# --------------------------------------------------------------------------- +# Catalogue +# --------------------------------------------------------------------------- + + +def test_is_reserved_slug(): + catalog = PlatformWorkflowCatalog() + + assert catalog.is_reserved_slug(_PLATFORM_SLUG) is True + assert catalog.is_reserved_slug(RESERVED_SLUG_PREFIX + "anything") is True + assert catalog.is_reserved_slug("agenta-getting-started") is False + assert catalog.is_reserved_slug("my-skill") is False + assert catalog.is_reserved_slug(None) is False + + +def test_artifact_level_lookup_resolves_current(): + catalog = PlatformWorkflowCatalog() + + revision = catalog.get_revision(slug=_PLATFORM_SLUG) + + assert revision is not None + assert revision.version == "v1" # the catalogue's current version + assert revision.slug == _PLATFORM_SLUG + # No URI: a skill is non-runnable by construction. + assert revision.data is not None + assert revision.data.uri is None + # The package rides at the canonical parameters.skill selector. + skill = revision.data.parameters["skill"] + assert skill["name"] == "agenta-getting-started" + # Read-only platform skill signal. + assert revision.flags == WorkflowRevisionFlags( + is_skill=True, + is_platform=True, + is_evaluator=False, + ) + + +def test_revision_level_lookup_pins_version_and_is_stable(): + catalog = PlatformWorkflowCatalog() + + current = catalog.get_revision(slug=_PLATFORM_SLUG) + pinned = catalog.get_revision(slug=_PLATFORM_SLUG, version="v1") + + assert pinned is not None + # The pinned v1 is the same immutable revision as current. + assert pinned.id == current.id + assert pinned.workflow_id == current.workflow_id + assert pinned.workflow_variant_id == current.workflow_variant_id + + # Ids are deterministic across catalogue instances (stable across restarts/instances). + other = PlatformWorkflowCatalog().get_revision(slug=_PLATFORM_SLUG) + assert other.id == current.id + assert other.workflow_id == current.workflow_id + + +def test_unknown_version_returns_none(): + catalog = PlatformWorkflowCatalog() + + assert catalog.get_revision(slug=_PLATFORM_SLUG, version="v999") is None + + +def test_unknown_reserved_slug_returns_none(): + catalog = PlatformWorkflowCatalog() + + assert catalog.get_revision(slug="_agenta.does-not-exist") is None + + +# --------------------------------------------------------------------------- +# fetch_workflow_revision short-circuit +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_fetch_revision_short_circuits_reserved_artifact_ref(): + workflows_dao = AsyncMock() + service = WorkflowsService( + workflows_dao=workflows_dao, + platform_catalog=PlatformWorkflowCatalog(), + ) + + revision = await service.fetch_workflow_revision( + project_id=uuid4(), + workflow_ref=Reference(slug=_PLATFORM_SLUG), + ) + + assert revision is not None + assert revision.flags.is_platform is True + assert revision.flags.is_skill is True + assert revision.data.parameters["skill"]["name"] == "agenta-getting-started" + # The reserved slug must never touch Postgres. + workflows_dao.fetch_revision.assert_not_awaited() + workflows_dao.fetch_artifact.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_default_agent_skill_embed_resolves_through_platform_catalog_without_db(): + workflows_dao = AsyncMock() + workflows_service = WorkflowsService( + workflows_dao=workflows_dao, + platform_catalog=PlatformWorkflowCatalog(), + ) + embeds_service = EmbedsService(workflows_service=workflows_service) + workflows_service.embeds_service = embeds_service + + revision = WorkflowRevision( + id=uuid4(), + workflow_id=uuid4(), + workflow_variant_id=uuid4(), + slug="agent-default-config", + data=WorkflowRevisionData( + parameters={ + "agent": { + "skills": [ + { + "@ag.embed": { + "@ag.references": { + "workflow": {"slug": _PLATFORM_SLUG} + }, + "@ag.selector": {"path": "parameters.skill"}, + } + } + ] + } + } + ), + ) + + ( + resolved_revision, + resolution_info, + ) = await workflows_service.resolve_workflow_revision( + project_id=uuid4(), + workflow_revision=revision, + ) + + skill = resolved_revision.data.parameters["agent"]["skills"][0] + assert skill["name"] == "agenta-getting-started" + assert skill["body"].startswith("# Getting started with Agenta agents") + assert resolution_info.embeds_resolved == 1 + # Resolving the platform default skill must use the catalogue, not Postgres. + workflows_dao.fetch_revision.assert_not_awaited() + workflows_dao.fetch_artifact.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_fetch_revision_short_circuits_reserved_revision_ref_with_version(): + workflows_dao = AsyncMock() + service = WorkflowsService( + workflows_dao=workflows_dao, + platform_catalog=PlatformWorkflowCatalog(), + ) + + revision = await service.fetch_workflow_revision( + project_id=uuid4(), + workflow_revision_ref=Reference(slug=_PLATFORM_SLUG, version="v1"), + ) + + assert revision is not None + assert revision.version == "v1" + workflows_dao.fetch_revision.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_fetch_revision_reserved_unknown_version_returns_none_without_db(): + workflows_dao = AsyncMock() + service = WorkflowsService( + workflows_dao=workflows_dao, + platform_catalog=PlatformWorkflowCatalog(), + ) + + revision = await service.fetch_workflow_revision( + project_id=uuid4(), + workflow_revision_ref=Reference(slug=_PLATFORM_SLUG, version="v999"), + ) + + assert revision is None + # An unknown version under the reserved namespace must not fall through to the DB. + workflows_dao.fetch_revision.assert_not_awaited() + workflows_dao.fetch_artifact.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_fetch_revision_non_reserved_slug_uses_db_path(): + workflows_dao = AsyncMock() + service = WorkflowsService( + workflows_dao=workflows_dao, + platform_catalog=PlatformWorkflowCatalog(), + ) + + artifact_id = uuid4() + variant_id = uuid4() + revision_id = uuid4() + workflows_dao.fetch_revision.return_value = WorkflowRevision( + id=revision_id, + workflow_id=artifact_id, + workflow_variant_id=variant_id, + slug="rev", + ) + workflows_dao.fetch_artifact.return_value = Workflow( + id=artifact_id, + slug="my-skill", + ) + + revision = await service.fetch_workflow_revision( + project_id=uuid4(), + workflow_variant_ref=Reference(id=variant_id), + ) + + assert revision is not None + assert revision.id == revision_id + # A non-reserved slug must hit the DB path exactly as before. + workflows_dao.fetch_revision.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# Reserved-prefix create rejection +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_workflow_rejects_reserved_slug(): + workflows_dao = AsyncMock() + service = WorkflowsService( + workflows_dao=workflows_dao, + platform_catalog=PlatformWorkflowCatalog(), + ) + + with pytest.raises(ReservedWorkflowSlug): + await service.create_workflow( + project_id=uuid4(), + user_id=uuid4(), + workflow_create=WorkflowCreate( + slug=_PLATFORM_SLUG, + flags=WorkflowFlags(is_skill=True, is_evaluator=False), + ), + ) + + workflows_dao.create_artifact.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_create_workflow_allows_normal_slug(): + workflows_dao = AsyncMock() + service = WorkflowsService( + workflows_dao=workflows_dao, + platform_catalog=PlatformWorkflowCatalog(), + ) + workflows_dao.create_artifact.return_value = Workflow(id=uuid4(), slug="my-skill") + + workflow = await service.create_workflow( + project_id=uuid4(), + user_id=uuid4(), + workflow_create=WorkflowCreate(slug="my-skill"), + ) + + assert workflow is not None + workflows_dao.create_artifact.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# is_platform is server-owned (forgery prevention) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_workflow_scrubs_forged_is_platform_flag(): + """A user-supplied is_platform=true must never reach the DB; it is coerced to false.""" + workflows_dao = AsyncMock() + service = WorkflowsService(workflows_dao=workflows_dao) + workflows_dao.create_artifact.return_value = Workflow(id=uuid4(), slug="my-skill") + + await service.create_workflow( + project_id=uuid4(), + user_id=uuid4(), + workflow_create=WorkflowCreate( + slug="my-skill", + flags=WorkflowFlags(is_platform=True, is_skill=True, is_evaluator=False), + ), + ) + + artifact_create = workflows_dao.create_artifact.await_args.kwargs["artifact_create"] + # The forged flag is dropped (absent == false), so it can never round-trip through the DB. + assert "is_platform" not in (artifact_create.flags or {}) + + +@pytest.mark.asyncio +async def test_edit_workflow_scrubs_forged_is_platform_flag(): + workflows_dao = AsyncMock() + service = WorkflowsService(workflows_dao=workflows_dao) + workflows_dao.edit_artifact.return_value = Workflow(id=uuid4(), slug="my-skill") + workflows_dao.fetch_artifact.return_value = Workflow(id=uuid4(), slug="my-skill") + + await service.edit_workflow( + project_id=uuid4(), + user_id=uuid4(), + workflow_edit=WorkflowEdit( + id=uuid4(), + flags=WorkflowFlags(is_platform=True, is_application=True), + ), + ) + + artifact_edit = workflows_dao.edit_artifact.await_args.kwargs["artifact_edit"] + assert "is_platform" not in (artifact_edit.flags or {}) + + +# --------------------------------------------------------------------------- +# Fail-open prevention: a service built WITHOUT a catalogue still guards the namespace +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_no_catalogue_still_rejects_reserved_slug_create(): + """The reserved-slug guard must hold even when no catalogue is injected (evaluators, + migrations, the worker construct WorkflowsService without one).""" + workflows_dao = AsyncMock() + service = WorkflowsService(workflows_dao=workflows_dao) # no platform_catalog + assert service.platform_catalog is None + + with pytest.raises(ReservedWorkflowSlug): + await service.create_workflow( + project_id=uuid4(), + user_id=uuid4(), + workflow_create=WorkflowCreate(slug=_PLATFORM_SLUG), + ) + + workflows_dao.create_artifact.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_no_catalogue_reserved_fetch_returns_none_without_db(): + """A reserved-slug fetch must short-circuit to None and never hit Postgres, even with no + catalogue to serve content.""" + workflows_dao = AsyncMock() + service = WorkflowsService(workflows_dao=workflows_dao) # no platform_catalog + + revision = await service.fetch_workflow_revision( + project_id=uuid4(), + workflow_ref=Reference(slug=_PLATFORM_SLUG), + ) + + assert revision is None + workflows_dao.fetch_revision.assert_not_awaited() + workflows_dao.fetch_artifact.assert_not_awaited() + + +def test_is_reserved_workflow_slug_pure_function(): + assert is_reserved_workflow_slug(_PLATFORM_SLUG) is True + assert is_reserved_workflow_slug(RESERVED_SLUG_PREFIX + "x") is True + assert is_reserved_workflow_slug("agenta-getting-started") is False + assert is_reserved_workflow_slug(None) is False + + +# --------------------------------------------------------------------------- +# Reserved-slug rejection on every slug-bearing write path +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_variant_rejects_reserved_slug(): + workflows_dao = AsyncMock() + service = WorkflowsService(workflows_dao=workflows_dao) + + with pytest.raises(ReservedWorkflowSlug): + await service.create_workflow_variant( + project_id=uuid4(), + user_id=uuid4(), + workflow_variant_create=WorkflowVariantCreate( + workflow_id=uuid4(), + slug=_PLATFORM_SLUG, + ), + ) + + workflows_dao.create_variant.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_fork_variant_rejects_reserved_slug(): + workflows_dao = AsyncMock() + service = WorkflowsService(workflows_dao=workflows_dao) + + with pytest.raises(ReservedWorkflowSlug): + await service.fork_workflow_variant( + project_id=uuid4(), + user_id=uuid4(), + workflow_variant_fork=WorkflowVariantFork(slug=_PLATFORM_SLUG), + workflow_variant_ref=Reference(id=uuid4()), + ) + + workflows_dao.fork_variant.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_create_revision_rejects_reserved_slug(): + workflows_dao = AsyncMock() + service = WorkflowsService(workflows_dao=workflows_dao) + + with pytest.raises(ReservedWorkflowSlug): + await service.create_workflow_revision( + project_id=uuid4(), + user_id=uuid4(), + workflow_revision_create=WorkflowRevisionCreate( + workflow_id=uuid4(), + workflow_variant_id=uuid4(), + slug=_PLATFORM_SLUG, + ), + ) + + workflows_dao.create_revision.assert_not_awaited() + + +# --------------------------------------------------------------------------- +# Reserved resolution honors ref consistency (no silently-ignored sibling ref) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_reserved_slug_with_unrelated_variant_ref_returns_none_without_db(): + """A platform reference carrying a non-matching variant id must not be served as if the extra + ref were absent — it resolves to None and never touches the DB.""" + workflows_dao = AsyncMock() + service = WorkflowsService( + workflows_dao=workflows_dao, + platform_catalog=PlatformWorkflowCatalog(), + ) + + revision = await service.fetch_workflow_revision( + project_id=uuid4(), + workflow_ref=Reference(slug=_PLATFORM_SLUG), + workflow_variant_ref=Reference(id=uuid4()), # unrelated + ) + + assert revision is None + workflows_dao.fetch_revision.assert_not_awaited() + workflows_dao.fetch_artifact.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_reserved_slug_with_matching_variant_ref_resolves(): + catalog = PlatformWorkflowCatalog() + expected = catalog.get_revision(slug=_PLATFORM_SLUG) + + workflows_dao = AsyncMock() + service = WorkflowsService(workflows_dao=workflows_dao, platform_catalog=catalog) + + revision = await service.fetch_workflow_revision( + project_id=uuid4(), + workflow_ref=Reference(slug=_PLATFORM_SLUG), + workflow_variant_ref=Reference(id=expected.workflow_variant_id), + ) + + assert revision is not None + assert revision.id == expected.id + workflows_dao.fetch_revision.assert_not_awaited() + + +# --------------------------------------------------------------------------- +# id-only platform references resolve via the catalogue, never Postgres +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_id_only_platform_artifact_ref_resolves_without_db(): + catalog = PlatformWorkflowCatalog() + expected = catalog.get_revision(slug=_PLATFORM_SLUG) + + workflows_dao = AsyncMock() + service = WorkflowsService(workflows_dao=workflows_dao, platform_catalog=catalog) + + revision = await service.fetch_workflow_revision( + project_id=uuid4(), + workflow_ref=Reference(id=expected.workflow_id), # id-only, no slug + ) + + assert revision is not None + assert revision.id == expected.id + assert revision.flags.is_platform is True + workflows_dao.fetch_revision.assert_not_awaited() + workflows_dao.fetch_artifact.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_id_only_platform_revision_ref_resolves_without_db(): + catalog = PlatformWorkflowCatalog() + expected = catalog.get_revision(slug=_PLATFORM_SLUG) + + workflows_dao = AsyncMock() + service = WorkflowsService(workflows_dao=workflows_dao, platform_catalog=catalog) + + revision = await service.fetch_workflow_revision( + project_id=uuid4(), + workflow_revision_ref=Reference(id=expected.id), # id-only + ) + + assert revision is not None + assert revision.id == expected.id + workflows_dao.fetch_revision.assert_not_awaited() + + +def test_get_revision_by_id_returns_none_for_unknown_id(): + catalog = PlatformWorkflowCatalog() + assert catalog.get_revision_by_id(entity_id=uuid4()) is None + assert catalog.is_reserved_id(uuid4()) is False + + +# --------------------------------------------------------------------------- +# Deterministic id scoping (artifact / variant stable across versions; revision version-scoped) +# --------------------------------------------------------------------------- + + +def test_artifact_and_variant_ids_stable_across_versions(): + catalog = PlatformWorkflowCatalog(catalog=_MULTI_VERSION_CATALOG) + + v1 = catalog.get_revision(slug=_PLATFORM_SLUG, version="v1") + v2 = catalog.get_revision(slug=_PLATFORM_SLUG, version="v2") + + # The workflow identity (artifact + variant) is one entity across versions. + assert v1.workflow_id == v2.workflow_id + assert v1.workflow_variant_id == v2.workflow_variant_id + # The revision id is version-scoped, so it differs per version. + assert v1.id != v2.id + + +def test_id_index_maps_artifact_and_variant_to_current_across_versions(): + catalog = PlatformWorkflowCatalog(catalog=_MULTI_VERSION_CATALOG) + current = catalog.get_revision(slug=_PLATFORM_SLUG) # v2 + + # Artifact / variant ids resolve to current; the v1 revision id pins v1. + assert catalog.get_revision_by_id(entity_id=current.workflow_id).version == "v2" + assert ( + catalog.get_revision_by_id(entity_id=current.workflow_variant_id).version + == "v2" + ) + v1_rev_id = catalog.get_revision(slug=_PLATFORM_SLUG, version="v1").id + assert catalog.get_revision_by_id(entity_id=v1_rev_id).version == "v1" + + +# --------------------------------------------------------------------------- +# Query regression: is_platform must not exclude pre-existing key-missing rows +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_query_without_is_platform_does_not_filter_on_it(): + """A default query (is_platform unset) must not add an is_platform filter, so pre-existing + rows whose JSONB flags lack the key are still returned.""" + workflows_dao = AsyncMock() + service = WorkflowsService(workflows_dao=workflows_dao) + workflows_dao.query_artifacts.return_value = [] + + await service.query_workflows( + project_id=uuid4(), + workflow_query=WorkflowQuery( + flags=WorkflowArtifactQueryFlags(is_application=True), + ), + ) + + artifact_query = workflows_dao.query_artifacts.await_args.kwargs["artifact_query"] + # Only the explicitly-requested flag is filtered; is_platform is absent (not False). + assert artifact_query.flags == {"is_application": True} + assert "is_platform" not in artifact_query.flags + + +@pytest.mark.asyncio +async def test_query_with_explicit_is_platform_filters_on_it(): + workflows_dao = AsyncMock() + service = WorkflowsService(workflows_dao=workflows_dao) + workflows_dao.query_artifacts.return_value = [] + + await service.query_workflows( + project_id=uuid4(), + workflow_query=WorkflowQuery( + flags=WorkflowArtifactQueryFlags(is_platform=True), + ), + ) + + artifact_query = workflows_dao.query_artifacts.await_args.kwargs["artifact_query"] + assert artifact_query.flags == {"is_platform": True} diff --git a/docs/design/agent-workflows/README.md b/docs/design/agent-workflows/README.md index 9c479d1620..6de4127d45 100644 --- a/docs/design/agent-workflows/README.md +++ b/docs/design/agent-workflows/README.md @@ -1,69 +1,65 @@ # Agent Workflows -This workspace documents the active agent-workflows PR stack and the work still needed to -make it production-ready. +This workspace documents the agent-workflows feature: running a coding harness as an +Agenta workflow. It is organized into four layers so the living design docs stay separate +from in-flight project notes and historical archaeology. -The source of truth is the code listed in [Ground Truth](ground-truth.md). Design pages at -this level describe the active-stack implementation unless they explicitly say "planned", -"blocked", or "not implemented". The docs PR commit itself is docs-only and does not -contain every referenced code file. Use [PR Stack](pr-stack.md) to map each code reference -to the sibling PR that carries it. Historical work-package notes and old RFCs live in -[trash/](trash/). +## Layout -## Read In This Order +- **[documentation/](documentation/)** — the living design docs, kept current with the + code. Start here. +- **[projects/](projects/)** — active, self-contained workstreams. Each has its own + `README.md`/`status.md`. These graduate into `documentation/` or fold into the code as + they land. +- **[scratch/](scratch/)** — transient coordination: status, open issues, PR/branch + cleanup reports. These drop off and move to `archive/` over time. +- **[archive/](archive/)** — superseded notes, old RFCs, and finished work-package + spikes. Kept for archaeology only; not design truth. +- **trash/** — truly disposable items, safe to delete. -1. [Ground Truth](ground-truth.md): what the active-stack code does, what is wired, and - what is still missing. -2. [Status](status.md): active-stack cleanup state, decisions, blockers, and next steps. -3. [Meeting Alignment](meeting-alignment.md): where the active work matches the June 18 - design discussion, where it diverges, and what still needs to be done. -4. [Architecture](architecture.md): the service, agent runner sidecar, harnesses, and - sandboxes. -5. [Protocol](protocol.md): `/invoke`, `/messages`, `/load-session`, and the runner `/run` - wire contract. -6. [Ports and Adapters](ports-and-adapters.md): the SDK runtime ports, backend adapters, - harness adapters, and browser protocol adapter. -7. [Agent Template](agent-template.md): the intended split between generic agent identity, - harness-specific config, and runtime infrastructure. -8. [Sessions](sessions.md): cold replay, streaming, session ids, and the missing session - store. -9. [Triggers](triggers.md): planned trigger/event integration and the missing Compose.io - POC. -10. [Pi Adapter](adapters/pi.md): Pi-specific tool delivery, prompt layers, tracing, and - usage writeback. -11. [Claude Code Adapter](adapters/claude-code.md): Claude over ACP, MCP tool delivery, - permissions, tracing, and usage. -12. [Agenta Harness](adapters/agenta.md): the experimental Agenta-flavored Pi harness. -13. [SDK Local Tools](sdk-local-tools/): planned and partly implemented work for standalone - SDK tool resolution. This remains blocked by `LocalBackend`. - - [Provider, Model, and Auth](provider-model-auth/): research and design for how a harness - selects its provider/model and gets the right credential injected (provider concept, - multi-account connections, OAuth/sidecar auth, least-privilege secret injection). -14. [PR Stack](pr-stack.md): functional breakpoints for reviewable stacked PRs. -15. [Implementation Review](implementation-review.md): high-level cleanup risks and PR - slicing notes. -16. [Open Issues](open-issues.md): deferred decisions that need ownership. - -## Active-Stack State +## documentation/ (read in this order) -The agent workflow runs a coding harness as an Agenta workflow. It supports: +1. [Ground Truth](documentation/ground-truth.md): what the code does, what is wired, and + what is still missing. +2. [Architecture](documentation/architecture.md): the service, agent runner sidecar, + harnesses, and sandboxes. +3. [Protocol](documentation/protocol.md): `/invoke`, `/messages`, `/load-session`, and the + runner `/run` wire contract. +4. [Ports and Adapters](documentation/ports-and-adapters.md): the SDK runtime ports, + backend adapters, harness adapters, and browser protocol adapter. +5. [Agent Template](documentation/agent-template.md): the split between generic agent + identity, harness-specific config, and runtime infrastructure. +6. [Sessions](documentation/sessions.md): cold replay, streaming, session ids, and the + missing session store. +7. [Triggers](documentation/triggers.md): planned trigger/event integration. +8. [Tools](documentation/tools.md): the tool taxonomy and executor model. +9. Adapters: [Pi](documentation/adapters/pi.md), + [Claude Code](documentation/adapters/claude-code.md), + [Agenta](documentation/adapters/agenta.md). +10. [Skills](documentation/skills.md): the development-workflow skills (plan, implement, + debug, test, document, branch) and how they chain across a feature's life. -- A batch `/invoke` path that returns the final assistant message. -- An agent-only `/messages` path that accepts Vercel `UIMessage` input and can stream a - Vercel UI Message Stream over SSE. -- A `/load-session` route with the right contract but no durable storage by default. -- Pi and Claude harnesses through the sandbox-agent runner. -- Pi and the experimental `agenta` harness through the in-process Pi backend. -- Server-resolved tool specs, code tool execution, callback tools, and MCP plumbing behind - a feature flag. +## projects/ -The main missing pieces are durable server-owned sessions, future session snapshot -interfaces, the agent template/config split, trigger integration, a working standalone -`LocalBackend`, production Agenta harness content, first-class built-in workflow -registration, and the final cleanup of historical work-package names in comments and docs. +- [code-tool-sandbox](projects/code-tool-sandbox/) — sandboxed code-tool execution. +- [harness-capabilities](projects/harness-capabilities/) — per-harness capability model. +- [model-config](projects/model-config/) — model selection config. +- [provider-model-auth](projects/provider-model-auth/) — provider/model/credential + injection. +- [qa](projects/qa/) — manual QA matrix, findings, and regression-test skills. +- [runner-interface](projects/runner-interface/) — runner `/run` interface notes. +- [sdk-local-tools](projects/sdk-local-tools/) — standalone SDK tool resolution. +- [sidecar-deployment-proposal](projects/sidecar-deployment-proposal/) — sidecar to + k8s/Helm + prod compose + Railway. +- [skills-config](projects/skills-config/) — skills configuration. +- [tool-resolution-layering](projects/tool-resolution-layering/) — SDK tool-resolution + layering. +- [typescript-structure](projects/typescript-structure/) — TS runner structure and tests. +- [sandbox-agent-refactor](projects/sandbox-agent-refactor/) — sandbox-agent runner + refactor plan. +- [research](projects/research/) — external-architecture research (e.g. OpenCode). -## Trash +## scratch/ -[trash/](trash/) holds old work-package notes, research spikes, and superseded RFCs. It is -kept for archaeology only. Do not treat it as design truth unless a current page links to a -specific note as background. +Status, open issues, PR-stack and branch-cleanup reports, meeting-alignment, the +implementation review, and the feature-matrix test report. Transient by design. diff --git a/docs/design/agent-workflows/architecture.md b/docs/design/agent-workflows/architecture.md deleted file mode 100644 index ad6c149e07..0000000000 --- a/docs/design/agent-workflows/architecture.md +++ /dev/null @@ -1,144 +0,0 @@ -# Architecture - -This page explains how the active-stack agent workflow runs. It describes the code carried -by the sibling implementation PRs, not only the docs PR commit and not the older -work-package plans in [trash/](trash/). - -## The Model - -Agenta already runs prompt workflows that call a model once and return one answer. An -agent workflow runs a coding harness instead. The harness reads instructions, calls a -model, calls tools, observes the results, and loops until it has an answer. - -The implementation keeps two choices configurable: - -- **Harness:** which agent runs. Supported values are `pi`, `claude`, and experimental - `agenta`. -- **Sandbox:** where the run happens. Supported values are `local` and `daytona` on the - sandbox-agent path. The in-process Pi path is local only. - -The platform still exposes the agent through normal workflow routing. `/invoke` remains the -batch contract. Agent routes also register `/messages` and `/load-session` for the browser -chat protocol. - -## Runtime Shape - -The deployed local stack uses two containers. - -``` -browser / playground - | - | POST /invoke or POST /messages - v -services container - Python workflow handler - services/oss/src/agent/app.py - | - | POST /run, or spawn the runner CLI in local checkout mode - v -agent runner sidecar - compose service: sandbox-agent - Node HTTP server - services/agent/src/server.ts - | - +-- in-process Pi engine - | services/agent/src/engines/pi.ts - | - +-- sandbox-agent engine - services/agent/src/engines/sandbox_agent.ts - | - +-- sandbox-agent daemon - | - +-- ACP adapter: pi-acp or claude-agent-acp - | - +-- harness CLI: pi or claude -``` - -The `services` container owns Agenta concerns: workflow routing, config parsing, provider -secret resolution, tool resolution, and trace context. The agent runner sidecar owns the -agent run: it drives Pi directly or drives a harness over ACP through sandbox-agent. In Docker -Compose this service is still named `sandbox-agent`, and the service reaches it through -`AGENTA_AGENT_RUNNER_URL`. - -The sidecar deliberately does not inherit the full stack environment. Provider keys and -tool credentials are resolved by the service and passed only in the scoped run payloads -that need them. - -## Backends - -The SDK runtime models engines as `Backend` adapters. - -| Backend | Status | Harnesses | Sandbox support | Notes | -| --- | --- | --- | --- | --- | -| `InProcessPiBackend` | Implemented | `pi`, `agenta` | `local` only | Drives `services/agent/src/engines/pi.ts`. This is the simple local Pi path. | -| `SandboxAgentBackend` | Implemented | `pi`, `claude` | `local`, `daytona` | Drives `services/agent/src/engines/sandbox_agent.ts`, which starts `sandbox-agent` and an ACP adapter. | -| `LocalBackend` | Not implemented | Intended: `pi`, `claude` | Local machine | Public class exists, but `create_sandbox` and `create_session` raise `NotImplementedError`. | - -`services/oss/src/agent/app.py` uses `SandboxAgentBackend` for the deployed service path. -`AGENTA_AGENT_RUNNER_URL` selects the HTTP runner transport when set; otherwise a source -checkout uses the local TypeScript runner CLI. `InProcessPiBackend` remains a local/example -contrast path. - -## Harnesses - -The SDK runtime models agent-specific behavior as `Harness` adapters. - -| Harness | Status | Backend path | Notes | -| --- | --- | --- | --- | -| `PiHarness` | Implemented | In-process Pi or sandbox-agent | Native Pi tools, Pi prompt overrides, Pi tracing extension. | -| `ClaudeHarness` | Implemented | sandbox-agent only | MCP tools, permission policy, runner-built tracing. | -| `AgentaHarness` | Experimental | In-process Pi only | Pi with forced tools, forced skill names, and placeholder Agenta prompt layers. | - -`AgentaHarness` with `daytona` or any sandbox-agent path is intentionally unsupported today. It -raises through the normal harness/backend compatibility check instead of silently running -without its forced skills. - -## Request Flow - -Batch `/invoke` follows this path: - -1. The workflow route calls `_agent` in `services/oss/src/agent/app.py`. -2. `_agent` parses `AgentConfig` and `RunSelection` from request parameters. -3. The service resolves provider keys, tools, and MCP servers. MCP resolution is gated by - `AGENTA_AGENT_ENABLE_MCP`. -4. The service builds `SessionConfig` and creates a harness over an environment and backend. -5. The harness opens a cold session, sends one `/run` request to the TypeScript runner, and - destroys the session. -6. The service records usage on the workflow span and returns one assistant message. - -Agent `/messages` follows the same runtime path after a browser-protocol adapter step: - -1. `sdks/python/agenta/sdk/agents/adapters/vercel/routing.py` validates or mints - `session_id`. -2. It converts Vercel `UIMessage` parts into neutral agent `Message` objects. -3. It sets `data.stream` from the `Accept` header. -4. `_agent` either returns a batch message or streams an `AgentRun`. -5. The Vercel adapter converts live `AgentEvent` objects into Vercel UI Message Stream - parts and the routing layer frames them as SSE. - -`/load-session` is registered for agent routes, but the default store is -`NoopSessionStore`. It returns an empty message list unless a real `SessionStore` is -injected. - -## Lifecycle - -The runtime is still cold. Each turn creates a fresh session and tears it down after the -turn. Multi-turn context comes from replaying message history, not from a warm daemon or a -persisted model session. - -This cold model keeps isolation simple and makes `/invoke` and `/messages` share the same -runtime. It also means durable server-owned history and warm `session/load` are still future -work. - -## Active-Stack Gaps - -- `LocalBackend` is a public adapter shape but does not run anything yet. -- `/load-session` has the route contract but no default persistent store and no write path - from completed turns. -- `AgentaHarness` uses placeholder preamble, persona, and skill content. -- `AgentaHarness` is local in-process only. -- Pi system prompt overrides are not delivered on the sandbox-agent ACP path. -- The agent is still registered as a custom workflow handler, not as a first-class builtin - URI such as `agenta:builtin:agent:v0`. -- Historical work-package labels remain in several sibling code comments. They should be - cleaned in a documentation and comment hygiene PR. diff --git a/docs/design/agent-workflows/trash/README.md b/docs/design/agent-workflows/archive/README.md similarity index 100% rename from docs/design/agent-workflows/trash/README.md rename to docs/design/agent-workflows/archive/README.md diff --git a/docs/design/agent-workflows/trash/harness-port-redesign/README.md b/docs/design/agent-workflows/archive/harness-port-redesign/README.md similarity index 100% rename from docs/design/agent-workflows/trash/harness-port-redesign/README.md rename to docs/design/agent-workflows/archive/harness-port-redesign/README.md diff --git a/docs/design/agent-workflows/trash/harness-port-redesign/implementation.md b/docs/design/agent-workflows/archive/harness-port-redesign/implementation.md similarity index 100% rename from docs/design/agent-workflows/trash/harness-port-redesign/implementation.md rename to docs/design/agent-workflows/archive/harness-port-redesign/implementation.md diff --git a/docs/design/agent-workflows/trash/harness-port-redesign/plan.md b/docs/design/agent-workflows/archive/harness-port-redesign/plan.md similarity index 100% rename from docs/design/agent-workflows/trash/harness-port-redesign/plan.md rename to docs/design/agent-workflows/archive/harness-port-redesign/plan.md diff --git a/docs/design/agent-workflows/trash/harness-port-redesign/proposal.md b/docs/design/agent-workflows/archive/harness-port-redesign/proposal.md similarity index 100% rename from docs/design/agent-workflows/trash/harness-port-redesign/proposal.md rename to docs/design/agent-workflows/archive/harness-port-redesign/proposal.md diff --git a/docs/design/agent-workflows/trash/harness-port-redesign/research.md b/docs/design/agent-workflows/archive/harness-port-redesign/research.md similarity index 100% rename from docs/design/agent-workflows/trash/harness-port-redesign/research.md rename to docs/design/agent-workflows/archive/harness-port-redesign/research.md diff --git a/docs/design/agent-workflows/trash/harness-port-redesign/status.md b/docs/design/agent-workflows/archive/harness-port-redesign/status.md similarity index 100% rename from docs/design/agent-workflows/trash/harness-port-redesign/status.md rename to docs/design/agent-workflows/archive/harness-port-redesign/status.md diff --git a/docs/design/agent-workflows/trash/old-rfcs/agent-protocol-rfc.md b/docs/design/agent-workflows/archive/old-rfcs/agent-protocol-rfc.md similarity index 100% rename from docs/design/agent-workflows/trash/old-rfcs/agent-protocol-rfc.md rename to docs/design/agent-workflows/archive/old-rfcs/agent-protocol-rfc.md diff --git a/docs/design/agent-workflows/trash/old-rfcs/streaming-and-sessions.md b/docs/design/agent-workflows/archive/old-rfcs/streaming-and-sessions.md similarity index 100% rename from docs/design/agent-workflows/trash/old-rfcs/streaming-and-sessions.md rename to docs/design/agent-workflows/archive/old-rfcs/streaming-and-sessions.md diff --git a/docs/design/agent-workflows/trash/research/auth-secrets.md b/docs/design/agent-workflows/archive/research/auth-secrets.md similarity index 100% rename from docs/design/agent-workflows/trash/research/auth-secrets.md rename to docs/design/agent-workflows/archive/research/auth-secrets.md diff --git a/docs/design/agent-workflows/trash/research/daytona-sandbox.md b/docs/design/agent-workflows/archive/research/daytona-sandbox.md similarity index 100% rename from docs/design/agent-workflows/trash/research/daytona-sandbox.md rename to docs/design/agent-workflows/archive/research/daytona-sandbox.md diff --git a/docs/design/agent-workflows/trash/research/diskless-in-memory-config.md b/docs/design/agent-workflows/archive/research/diskless-in-memory-config.md similarity index 100% rename from docs/design/agent-workflows/trash/research/diskless-in-memory-config.md rename to docs/design/agent-workflows/archive/research/diskless-in-memory-config.md diff --git a/docs/design/agent-workflows/trash/research/open-questions.md b/docs/design/agent-workflows/archive/research/open-questions.md similarity index 100% rename from docs/design/agent-workflows/trash/research/open-questions.md rename to docs/design/agent-workflows/archive/research/open-questions.md diff --git a/docs/design/agent-workflows/trash/research/otel-instrumentation.md b/docs/design/agent-workflows/archive/research/otel-instrumentation.md similarity index 100% rename from docs/design/agent-workflows/trash/research/otel-instrumentation.md rename to docs/design/agent-workflows/archive/research/otel-instrumentation.md diff --git a/docs/design/agent-workflows/trash/research/pi-interaction.md b/docs/design/agent-workflows/archive/research/pi-interaction.md similarity index 100% rename from docs/design/agent-workflows/trash/research/pi-interaction.md rename to docs/design/agent-workflows/archive/research/pi-interaction.md diff --git a/docs/design/agent-workflows/trash/research/sandbox-sharing.md b/docs/design/agent-workflows/archive/research/sandbox-sharing.md similarity index 100% rename from docs/design/agent-workflows/trash/research/sandbox-sharing.md rename to docs/design/agent-workflows/archive/research/sandbox-sharing.md diff --git a/docs/design/agent-workflows/trash/sdk-local-backend/status.md b/docs/design/agent-workflows/archive/sdk-local-backend/status.md similarity index 100% rename from docs/design/agent-workflows/trash/sdk-local-backend/status.md rename to docs/design/agent-workflows/archive/sdk-local-backend/status.md diff --git a/docs/design/agent-workflows/trash/wp-1-pi-tracing/README.md b/docs/design/agent-workflows/archive/wp-1-pi-tracing/README.md similarity index 100% rename from docs/design/agent-workflows/trash/wp-1-pi-tracing/README.md rename to docs/design/agent-workflows/archive/wp-1-pi-tracing/README.md diff --git a/docs/design/agent-workflows/trash/wp-1-pi-tracing/integrating-the-tracing-extension.md b/docs/design/agent-workflows/archive/wp-1-pi-tracing/integrating-the-tracing-extension.md similarity index 100% rename from docs/design/agent-workflows/trash/wp-1-pi-tracing/integrating-the-tracing-extension.md rename to docs/design/agent-workflows/archive/wp-1-pi-tracing/integrating-the-tracing-extension.md diff --git a/docs/design/agent-workflows/trash/wp-1-pi-tracing/poc/.env.example b/docs/design/agent-workflows/archive/wp-1-pi-tracing/poc/.env.example similarity index 100% rename from docs/design/agent-workflows/trash/wp-1-pi-tracing/poc/.env.example rename to docs/design/agent-workflows/archive/wp-1-pi-tracing/poc/.env.example diff --git a/docs/design/agent-workflows/trash/wp-1-pi-tracing/poc/README.md b/docs/design/agent-workflows/archive/wp-1-pi-tracing/poc/README.md similarity index 100% rename from docs/design/agent-workflows/trash/wp-1-pi-tracing/poc/README.md rename to docs/design/agent-workflows/archive/wp-1-pi-tracing/poc/README.md diff --git a/docs/design/agent-workflows/trash/wp-1-pi-tracing/poc/agenta-otel.ts b/docs/design/agent-workflows/archive/wp-1-pi-tracing/poc/agenta-otel.ts similarity index 100% rename from docs/design/agent-workflows/trash/wp-1-pi-tracing/poc/agenta-otel.ts rename to docs/design/agent-workflows/archive/wp-1-pi-tracing/poc/agenta-otel.ts diff --git a/docs/design/agent-workflows/trash/wp-1-pi-tracing/poc/package.json b/docs/design/agent-workflows/archive/wp-1-pi-tracing/poc/package.json similarity index 100% rename from docs/design/agent-workflows/trash/wp-1-pi-tracing/poc/package.json rename to docs/design/agent-workflows/archive/wp-1-pi-tracing/poc/package.json diff --git a/docs/design/agent-workflows/trash/wp-1-pi-tracing/poc/pnpm-lock.yaml b/docs/design/agent-workflows/archive/wp-1-pi-tracing/poc/pnpm-lock.yaml similarity index 100% rename from docs/design/agent-workflows/trash/wp-1-pi-tracing/poc/pnpm-lock.yaml rename to docs/design/agent-workflows/archive/wp-1-pi-tracing/poc/pnpm-lock.yaml diff --git a/docs/design/agent-workflows/trash/wp-1-pi-tracing/poc/run.ts b/docs/design/agent-workflows/archive/wp-1-pi-tracing/poc/run.ts similarity index 100% rename from docs/design/agent-workflows/trash/wp-1-pi-tracing/poc/run.ts rename to docs/design/agent-workflows/archive/wp-1-pi-tracing/poc/run.ts diff --git a/docs/design/agent-workflows/trash/wp-1-pi-tracing/tracing-in-the-agent-service.md b/docs/design/agent-workflows/archive/wp-1-pi-tracing/tracing-in-the-agent-service.md similarity index 100% rename from docs/design/agent-workflows/trash/wp-1-pi-tracing/tracing-in-the-agent-service.md rename to docs/design/agent-workflows/archive/wp-1-pi-tracing/tracing-in-the-agent-service.md diff --git a/docs/design/agent-workflows/trash/wp-2-agent-service/README.md b/docs/design/agent-workflows/archive/wp-2-agent-service/README.md similarity index 100% rename from docs/design/agent-workflows/trash/wp-2-agent-service/README.md rename to docs/design/agent-workflows/archive/wp-2-agent-service/README.md diff --git a/docs/design/agent-workflows/trash/wp-2-agent-service/implementation-plan.md b/docs/design/agent-workflows/archive/wp-2-agent-service/implementation-plan.md similarity index 100% rename from docs/design/agent-workflows/trash/wp-2-agent-service/implementation-plan.md rename to docs/design/agent-workflows/archive/wp-2-agent-service/implementation-plan.md diff --git a/docs/design/agent-workflows/trash/wp-2-agent-service/qa.md b/docs/design/agent-workflows/archive/wp-2-agent-service/qa.md similarity index 100% rename from docs/design/agent-workflows/trash/wp-2-agent-service/qa.md rename to docs/design/agent-workflows/archive/wp-2-agent-service/qa.md diff --git a/docs/design/agent-workflows/trash/wp-3-daytona-sandbox/README.md b/docs/design/agent-workflows/archive/wp-3-daytona-sandbox/README.md similarity index 100% rename from docs/design/agent-workflows/trash/wp-3-daytona-sandbox/README.md rename to docs/design/agent-workflows/archive/wp-3-daytona-sandbox/README.md diff --git a/docs/design/agent-workflows/trash/wp-3-daytona-sandbox/poc/README.md b/docs/design/agent-workflows/archive/wp-3-daytona-sandbox/poc/README.md similarity index 100% rename from docs/design/agent-workflows/trash/wp-3-daytona-sandbox/poc/README.md rename to docs/design/agent-workflows/archive/wp-3-daytona-sandbox/poc/README.md diff --git a/docs/design/agent-workflows/trash/wp-3-daytona-sandbox/poc/bench_coldstart.py b/docs/design/agent-workflows/archive/wp-3-daytona-sandbox/poc/bench_coldstart.py similarity index 100% rename from docs/design/agent-workflows/trash/wp-3-daytona-sandbox/poc/bench_coldstart.py rename to docs/design/agent-workflows/archive/wp-3-daytona-sandbox/poc/bench_coldstart.py diff --git a/docs/design/agent-workflows/trash/wp-3-daytona-sandbox/poc/build_snapshot.py b/docs/design/agent-workflows/archive/wp-3-daytona-sandbox/poc/build_snapshot.py similarity index 100% rename from docs/design/agent-workflows/trash/wp-3-daytona-sandbox/poc/build_snapshot.py rename to docs/design/agent-workflows/archive/wp-3-daytona-sandbox/poc/build_snapshot.py diff --git a/docs/design/agent-workflows/trash/wp-3-daytona-sandbox/poc/cleanup.py b/docs/design/agent-workflows/archive/wp-3-daytona-sandbox/poc/cleanup.py similarity index 100% rename from docs/design/agent-workflows/trash/wp-3-daytona-sandbox/poc/cleanup.py rename to docs/design/agent-workflows/archive/wp-3-daytona-sandbox/poc/cleanup.py diff --git a/docs/design/agent-workflows/trash/wp-3-daytona-sandbox/poc/run_agent.py b/docs/design/agent-workflows/archive/wp-3-daytona-sandbox/poc/run_agent.py similarity index 100% rename from docs/design/agent-workflows/trash/wp-3-daytona-sandbox/poc/run_agent.py rename to docs/design/agent-workflows/archive/wp-3-daytona-sandbox/poc/run_agent.py diff --git a/docs/design/agent-workflows/trash/wp-4-multi-message-output/README.md b/docs/design/agent-workflows/archive/wp-4-multi-message-output/README.md similarity index 100% rename from docs/design/agent-workflows/trash/wp-4-multi-message-output/README.md rename to docs/design/agent-workflows/archive/wp-4-multi-message-output/README.md diff --git a/docs/design/agent-workflows/trash/wp-5-chat-vs-completion/README.md b/docs/design/agent-workflows/archive/wp-5-chat-vs-completion/README.md similarity index 100% rename from docs/design/agent-workflows/trash/wp-5-chat-vs-completion/README.md rename to docs/design/agent-workflows/archive/wp-5-chat-vs-completion/README.md diff --git a/docs/design/agent-workflows/trash/wp-6-workflow-type-and-template/README.md b/docs/design/agent-workflows/archive/wp-6-workflow-type-and-template/README.md similarity index 100% rename from docs/design/agent-workflows/trash/wp-6-workflow-type-and-template/README.md rename to docs/design/agent-workflows/archive/wp-6-workflow-type-and-template/README.md diff --git a/docs/design/agent-workflows/trash/wp-7-tools/README.md b/docs/design/agent-workflows/archive/wp-7-tools/README.md similarity index 100% rename from docs/design/agent-workflows/trash/wp-7-tools/README.md rename to docs/design/agent-workflows/archive/wp-7-tools/README.md diff --git a/docs/design/agent-workflows/trash/wp-8-rivet-acp-runtime/README.md b/docs/design/agent-workflows/archive/wp-8-rivet-acp-runtime/README.md similarity index 100% rename from docs/design/agent-workflows/trash/wp-8-rivet-acp-runtime/README.md rename to docs/design/agent-workflows/archive/wp-8-rivet-acp-runtime/README.md diff --git a/docs/design/agent-workflows/trash/wp-8-rivet-acp-runtime/architecture.md b/docs/design/agent-workflows/archive/wp-8-rivet-acp-runtime/architecture.md similarity index 100% rename from docs/design/agent-workflows/trash/wp-8-rivet-acp-runtime/architecture.md rename to docs/design/agent-workflows/archive/wp-8-rivet-acp-runtime/architecture.md diff --git a/docs/design/agent-workflows/trash/wp-8-rivet-acp-runtime/context.md b/docs/design/agent-workflows/archive/wp-8-rivet-acp-runtime/context.md similarity index 100% rename from docs/design/agent-workflows/trash/wp-8-rivet-acp-runtime/context.md rename to docs/design/agent-workflows/archive/wp-8-rivet-acp-runtime/context.md diff --git a/docs/design/agent-workflows/trash/wp-8-rivet-acp-runtime/isolation-and-fork.md b/docs/design/agent-workflows/archive/wp-8-rivet-acp-runtime/isolation-and-fork.md similarity index 100% rename from docs/design/agent-workflows/trash/wp-8-rivet-acp-runtime/isolation-and-fork.md rename to docs/design/agent-workflows/archive/wp-8-rivet-acp-runtime/isolation-and-fork.md diff --git a/docs/design/agent-workflows/trash/wp-8-rivet-acp-runtime/plan.md b/docs/design/agent-workflows/archive/wp-8-rivet-acp-runtime/plan.md similarity index 100% rename from docs/design/agent-workflows/trash/wp-8-rivet-acp-runtime/plan.md rename to docs/design/agent-workflows/archive/wp-8-rivet-acp-runtime/plan.md diff --git a/docs/design/agent-workflows/trash/wp-8-rivet-acp-runtime/poc/build_rivet_snapshot.py b/docs/design/agent-workflows/archive/wp-8-rivet-acp-runtime/poc/build_rivet_snapshot.py similarity index 100% rename from docs/design/agent-workflows/trash/wp-8-rivet-acp-runtime/poc/build_rivet_snapshot.py rename to docs/design/agent-workflows/archive/wp-8-rivet-acp-runtime/poc/build_rivet_snapshot.py diff --git a/docs/design/agent-workflows/trash/wp-8-rivet-acp-runtime/poc/commit_agent_config.py b/docs/design/agent-workflows/archive/wp-8-rivet-acp-runtime/poc/commit_agent_config.py similarity index 100% rename from docs/design/agent-workflows/trash/wp-8-rivet-acp-runtime/poc/commit_agent_config.py rename to docs/design/agent-workflows/archive/wp-8-rivet-acp-runtime/poc/commit_agent_config.py diff --git a/docs/design/agent-workflows/trash/wp-8-rivet-acp-runtime/poc/debug-events.ts b/docs/design/agent-workflows/archive/wp-8-rivet-acp-runtime/poc/debug-events.ts similarity index 100% rename from docs/design/agent-workflows/trash/wp-8-rivet-acp-runtime/poc/debug-events.ts rename to docs/design/agent-workflows/archive/wp-8-rivet-acp-runtime/poc/debug-events.ts diff --git a/docs/design/agent-workflows/trash/wp-8-rivet-acp-runtime/poc/dump-full.ts b/docs/design/agent-workflows/archive/wp-8-rivet-acp-runtime/poc/dump-full.ts similarity index 100% rename from docs/design/agent-workflows/trash/wp-8-rivet-acp-runtime/poc/dump-full.ts rename to docs/design/agent-workflows/archive/wp-8-rivet-acp-runtime/poc/dump-full.ts diff --git a/docs/design/agent-workflows/trash/wp-8-rivet-acp-runtime/poc/package.json b/docs/design/agent-workflows/archive/wp-8-rivet-acp-runtime/poc/package.json similarity index 100% rename from docs/design/agent-workflows/trash/wp-8-rivet-acp-runtime/poc/package.json rename to docs/design/agent-workflows/archive/wp-8-rivet-acp-runtime/poc/package.json diff --git a/docs/design/agent-workflows/trash/wp-8-rivet-acp-runtime/poc/spike.ts b/docs/design/agent-workflows/archive/wp-8-rivet-acp-runtime/poc/spike.ts similarity index 100% rename from docs/design/agent-workflows/trash/wp-8-rivet-acp-runtime/poc/spike.ts rename to docs/design/agent-workflows/archive/wp-8-rivet-acp-runtime/poc/spike.ts diff --git a/docs/design/agent-workflows/trash/wp-8-rivet-acp-runtime/research.md b/docs/design/agent-workflows/archive/wp-8-rivet-acp-runtime/research.md similarity index 100% rename from docs/design/agent-workflows/trash/wp-8-rivet-acp-runtime/research.md rename to docs/design/agent-workflows/archive/wp-8-rivet-acp-runtime/research.md diff --git a/docs/design/agent-workflows/trash/wp-8-rivet-acp-runtime/status.md b/docs/design/agent-workflows/archive/wp-8-rivet-acp-runtime/status.md similarity index 100% rename from docs/design/agent-workflows/trash/wp-8-rivet-acp-runtime/status.md rename to docs/design/agent-workflows/archive/wp-8-rivet-acp-runtime/status.md diff --git a/docs/design/agent-workflows/adapters/agenta.md b/docs/design/agent-workflows/documentation/adapters/agenta.md similarity index 83% rename from docs/design/agent-workflows/adapters/agenta.md rename to docs/design/agent-workflows/documentation/adapters/agenta.md index 71e6e2220e..c4975eeead 100644 --- a/docs/design/agent-workflows/adapters/agenta.md +++ b/docs/design/agent-workflows/documentation/adapters/agenta.md @@ -5,12 +5,12 @@ adapter](pi.md) and produces a Pi-shaped config, so it inherits everything Pi do tools, the system-prompt layers, tracing). What it adds is a fixed set of Agenta-shipped extras that the agent author cannot turn off: -- **Forced tools** — always unioned into the agent's resolved tools. At minimum `read` +- **Forced tools**: always unioned into the agent's resolved tools. At minimum `read` (Pi only renders the skills section when `read` is enabled) and `bash` (so skills can run their helper scripts). -- **Forced skills** — Agenta-shipped Pi skills loaded on every run. -- **A base AGENTS.md preamble** — the author's `instructions` are appended after it. -- **A base persona** — forced onto Pi's `append_system`, with any author-supplied +- **Forced skills**: Agenta-shipped Pi skills loaded on every run. +- **A base AGENTS.md preamble**: the author's `instructions` are appended after it. +- **A base persona**: forced onto Pi's `append_system`, with any author-supplied `append_system` appended after it. Read the [architecture](../architecture.md), [ports and adapters](../ports-and-adapters.md), @@ -30,8 +30,17 @@ disk because they reference relative scripts and assets, so they cannot ride the text. The contract between the two halves is the skill **name**: `AGENTA_FORCED_SKILLS` lists names, and each must match a committed directory under the runner's skills root. +Because the Agenta harness IS Pi, its tools are delivered the Pi-native way (through the +extension on the ACP path, through `buildCustomTools` in process), never over MCP. The forced +`read` and `bash` tools are Pi built-ins, so they ride the wire as built-in names, not resolved +specs. + ## How a skill reaches the model +The flow below is for the in-process engine. The deployed path (sandbox-agent over ACP) reaches the +same end state by a different mechanism, described in +[On the sandbox-agent (ACP) path](#on-the-sandbox-agent-acp-path) below. + 1. `AgentaHarness._to_harness_config` puts the forced skill names on the `skills` field of the `/run` request (`AgentaAgentConfig.wire_tools`). 2. The in-process Pi engine (`engines/pi.ts`) resolves each name against its bundled @@ -53,13 +62,12 @@ instructions are the `AGENTS.md`. An author's own `system` / `append_system` (vi `agenta` is a harness option alongside `pi` and `claude` (the playground dropdown, the `harness` field). The deployed service path routes it through `SandboxAgentBackend`, which -drives Pi over ACP and layers the Agenta persona and tools on top. `InProcessPiBackend` -remains available for local/example contrast runs. +drives Pi over ACP and layers the Agenta persona and tools on top. ## On the sandbox-agent (ACP) path `SandboxAgentBackend` also lists `HarnessType.AGENTA` as supported, so `agenta` runs over ACP through -the sandbox-agent daemon as well — this is what lets it use the Daytona sandbox. The Agenta harness is +the sandbox-agent daemon as well. This is what lets it use the Daytona sandbox. The Agenta harness is Pi with an opinion, and the sandbox-agent daemon only knows real agents (`pi`, `claude`, …), so the runner maps `agenta` onto the `pi` ACP agent (`acpAgent` in `engines/sandbox_agent.ts`) and treats it as Pi for capabilities, model resolution, and tracing. @@ -68,7 +76,7 @@ The forced *skills* cannot ride the `/run` wire as text (a skill is a directory reference relative scripts and assets), so the wire carries only the skill **names** and the runner lays the bundled directories into the Pi **agent dir**'s `skills/` (user scope). `runSandboxAgent` resolves the names against the bundled `skills/` root (`engines/skills.ts`, shared -with the in-process engine). The agent dir is deliberate — Pi auto-discovers and enables +with the in-process engine). The agent dir is deliberate. Pi auto-discovers and enables user-scope skills (`/skills/`) on every run, whereas project skills (`/.pi/skills/`) are trust-gated and would not load in this headless run. diff --git a/docs/design/agent-workflows/adapters/claude-code.md b/docs/design/agent-workflows/documentation/adapters/claude-code.md similarity index 69% rename from docs/design/agent-workflows/adapters/claude-code.md rename to docs/design/agent-workflows/documentation/adapters/claude-code.md index 3f911cb70e..6a915f220f 100644 --- a/docs/design/agent-workflows/adapters/claude-code.md +++ b/docs/design/agent-workflows/documentation/adapters/claude-code.md @@ -24,17 +24,28 @@ Anthropic key" rather than a stack trace. ## Tools over MCP -Claude advertises the `mcpTools` capability, so the runner delivers tools to Claude the -standard ACP way, over MCP. This is the branch that the [capability probe](../ports-and-adapters.md) -chooses: deliver over MCP when the harness reports `mcpTools`, not when the harness name is -something in particular. - -The mechanism is a small stdio MCP server (`tools/mcp-server.ts`) that the daemon launches -and attaches to the session. Its tool bodies POST back to Agenta's `/tools/call` with the -same callback-tool envelope the Pi path uses. The resolved specs and the callback endpoint reach the -MCP server through its environment, so nothing tool-specific is written to a file the agent -can read. The safety property is identical to Pi's: the provider key and the connection auth -stay server-side, and the agent only ever asks Agenta to run a named tool. +Claude reports the `mcpTools` capability, so the runner delivers tools to Claude the standard +ACP way, over MCP. This is the branch that `buildSessionMcpServers` +(`engines/sandbox_agent/mcp.ts`) chooses: deliver over MCP when the harness reports `mcpTools`, +not when the harness name is something in particular. In practice the capability comes from the +static per-harness fallback (`engines/sandbox_agent/capabilities.ts`): the daemon rarely fills +a real `info.capabilities`, so the runner uses `mcpTools: true` for any non-Pi harness. + +The mechanism is a small stdio MCP server named `agenta-tools` (`tools/mcp-server.ts`, launched +by `tools/mcp-bridge.ts`) that the daemon attaches to the session. This is an Agenta tool +DELIVERY vehicle, not a user-declared MCP server: it carries the same gateway and code specs +the Pi extension would register, just exposed over MCP because Claude cannot take a native +tool. Its env carries only public metadata (names, descriptions, schemas) and a relay +directory; the `call_ref`, the code, the scoped secrets, and the callback auth never reach it. +When the model calls a tool, the server relays the request back to the runner over the file +relay (`tools/relay.ts`), and the runner runs the private spec from memory and POSTs to +`/tools/call`. The safety property is identical to Pi's: the provider key and the connection +auth stay server-side, and the agent only ever asks Agenta to run a named tool. + +User-declared `mcp_servers` are a separate thing and effectively off today. They would reach +Claude through `toAcpMcpServers` as additional ACP stdio servers, but only when +`AGENTA_AGENT_ENABLE_MCP` is set (off by default), so in practice no user MCP server is +attached. See [tools.md](../tools.md#status-and-known-gaps). ## Permissions @@ -91,5 +102,5 @@ same `SandboxAgentBackend` drives it. It also exercises the capability-driven br built on: tools over MCP because it reports `mcpTools`, a permission answer because it gates tools, and event-stream tracing because it does not self-instrument. A future harness that sandbox-agent can drive would reuse this exact path. A future harness that sandbox-agent cannot drive would -instead get its own backend beside `SandboxAgentBackend` and `InProcessPiBackend`, behind the same +instead get its own backend beside `SandboxAgentBackend`, behind the same `/run` contract. diff --git a/docs/design/agent-workflows/adapters/pi.md b/docs/design/agent-workflows/documentation/adapters/pi.md similarity index 85% rename from docs/design/agent-workflows/adapters/pi.md rename to docs/design/agent-workflows/documentation/adapters/pi.md index 00c6641062..8579f4d101 100644 --- a/docs/design/agent-workflows/adapters/pi.md +++ b/docs/design/agent-workflows/documentation/adapters/pi.md @@ -33,9 +33,15 @@ variables, so the extension stays inert when none are set and is safe to install ## Tools, the Pi-native way -Pi 0.79.4 does not support MCP. So we do not deliver tools over MCP to Pi. Instead the -extension reads the resolved tool specs from `AGENTA_TOOL_SPECS` and registers each one with -Pi directly through `pi.registerTool`. Pi then sees them as native tools and runs the loop. +Pi 0.79.4 does not support MCP, and `pi-acp` does not forward MCP servers either. So we do not +deliver anything over MCP to Pi: the runner's tool-delivery fork +(`buildSessionMcpServers` in `engines/sandbox_agent/mcp.ts`) returns an empty MCP list for Pi, +and tools come through the extension instead. The extension reads the resolved tool specs from +`AGENTA_TOOL_PUBLIC_SPECS` (public metadata only: name, description, input schema) and +registers each one with Pi directly through `pi.registerTool`. Pi then sees them as native +tools and runs the loop. The private parts of each spec (the `call_ref`, the code, the scoped +secrets, the callback auth) never reach the extension; they stay in runner memory and the +extension relays every call back. Each registered tool's body does one thing: it POSTs the call back to Agenta's `/tools/call` with the tool's `callRef` (the callback-tool envelope). The model picks the tool and supplies the @@ -155,12 +161,17 @@ And auth comes from the provider key in the sandbox env when present, or from an ## The in-process engine -The in-process Pi engine (`engines/pi.ts`, selected by the `InProcessPiBackend`) skips sandbox-agent +The in-process Pi engine (`engines/pi.ts`, reached with `backend: "pi"`) skips sandbox-agent entirely. It drives Pi's `createAgentSession` directly, with everything in memory: AGENTS.md injected through the resource loader, the session and settings managers in memory, and a -throwaway working directory. It registers the same tools as Pi `customTools` (the same -POST-back-to-`/tools/call` body) and traces with the same extension logic, just wired in -process rather than loaded from disk. +throwaway working directory. It registers the same tools as Pi `customTools` through +`buildCustomTools`, and traces with the same extension logic, just wired in process rather than +loaded from disk. One difference from the ACP path: there is no file relay. Because the engine +runs in the same process as the runner, each tool body executes directly through +`runResolvedTool` (a gateway tool POSTs to `/tools/call`, a code tool spawns a local +subprocess). The relay only exists on the ACP path, where a separate Pi process or a Daytona +sandbox cannot reach Agenta or hold the private spec. The in-process engine also ignores +`mcp_servers` entirely (`PI_CAPABILITIES.mcpTools` is false). It returns the same `/run` result as the sandbox-agent path, which is the whole point of the ports: the workflow author cannot tell which engine ran. It exists for the simplest local case and diff --git a/docs/design/agent-workflows/documentation/agent-configuration.md b/docs/design/agent-workflows/documentation/agent-configuration.md new file mode 100644 index 0000000000..edccc1cbc6 --- /dev/null +++ b/docs/design/agent-workflows/documentation/agent-configuration.md @@ -0,0 +1,249 @@ +# Agent Configuration + +This page documents how an agent workflow is configured today, end to end. It traces one +config object from the playground form, through the catalog type and SDK interface, down to +what the runtime actually reads. It marks what is enforced, what is loose, what is wired, and +what is decorative. + +All file:line citations were verified against the code on 2026-06-23. + +## The one-sentence version + +The playground renders a single composite `agent_config` control. The field list for that +control is not hardcoded in the frontend. It is fetched from the backend catalog type +`agent_config`, which the SDK defines once as `AgentConfigSchema`. The runtime then re-parses +the same payload into a permissive `AgentConfig` plus a `RunSelection`, resolves tools and +secrets server-side, and hands a final wire request to the Node runner. + +## Three objects share the name "AgentConfig" + +Keep these separate. They look alike but do different jobs. + +| Object | File | Role | +| --- | --- | --- | +| `AgentConfigSchema` | `sdks/python/agenta/sdk/utils/types.py:1065` | Strict schema. Emits the JSON Schema that becomes the catalog type and drives the playground form. It describes the config. | +| `AgentConfig` (neutral runtime) | `sdks/python/agenta/sdk/agents/dtos.py:308` | Runtime parser. Coerces the loose payload the playground sends. It consumes the config. | +| `AgentConfig` (file-default dataclass) | `services/oss/src/agent/config.py:30` | Loose file-default loader. Holds the service's built-in defaults with `tools: List[Any]`. | + +## The full path + +``` +Playground form + → AgentConfigControl (FE) reads schema.properties from the catalog type + → GET /workflows/catalog/types/agent_config resolves x-ag-type-ref to the full schema + → AgentConfigSchema (SDK) the strict schema, registered in CATALOG_TYPES + → AgentConfig.from_params + RunSelection (SDK runtime) re-parse the saved payload + → SessionConfig tools + secrets resolved server-side + → AgentRunRequest (TS wire contract) the final shape the Node runner receives +``` + +## Layer 1: the frontend playground form + +The form is fully schema-driven. There is no hand-built agent form. A single marker on the +workflow's parameters schema mounts one composite control. + +The marker is `x-ag-type-ref: "agent_config"`. The schema renderer detects it at +`web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SchemaPropertyRenderer.tsx:130` +and dispatches to `AgentConfigControl` at the same file's `case "agent_config"` (around line +430). + +`AgentConfigControl` +(`web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentConfigControl.tsx`) does +not invent widgets. It reads `schema.properties` (line 78) and renders each sub-field with an +existing control: + +- `agents_md` renders as a multiline text input labeled "Instructions". It falls back to a + legacy `instructions` value when `agents_md` is missing. +- `model` renders as a grouped choice control. +- `tools` renders as a flat array. Each entry uses `ToolItemControl`, the same tool object + shape the prompt control uses. +- `mcp_servers` renders as a flat array. Each entry uses `McpServerItemControl`, which is a + JSON editor for one server entry. +- `harness`, `sandbox`, and `permission_policy` each render as an enum select. + +So the object the form produces is: + +``` +{ agents_md, model, tools[], mcp_servers[], harness, sandbox, permission_policy } +``` + +The field set comes from the backend at runtime. The frontend fetches the catalog type with +`GET /workflows/catalog/types/{agType}` +(`web/packages/agenta-entities/src/workflow/api/api.ts`, around line 1291) and merges its +`properties` into the stored schema +(`web/packages/agenta-entities/src/workflow/state/molecule.ts`, around line 520). If the +backend schema changes, the form changes with no frontend edit. + +There is no `persona` control. The form never renders one. See the persona note below. + +## Layer 2: the catalog type and service schema + +`AgentConfigSchema` is the single source of the field list +(`sdks/python/agenta/sdk/utils/types.py:1065`). It is a strict model with no `extra="allow"`. +Its fields and defaults: + +| Field | Type | Default | Notes | +| --- | --- | --- | --- | +| `agents_md` | `str` | a hello-world prompt | `x-ag-type: textarea` | +| `model` | `str` | `"gpt-5.5"` | `x-parameter: grouped_choice`, plain string | +| `tools` | `List[ToolConfig]` | empty list | typed discriminated union | +| `mcp_servers` | `List[MCPServerConfig]` | empty list | typed | +| `harness` | `Literal["pi","claude","agenta"]` | `"pi"` | enum | +| `sandbox` | `Literal["local","daytona"]` | `"local"` | enum | +| `permission_policy` | `Literal["auto","deny"]` | `"auto"` | enum | + +The schema is registered in `CATALOG_TYPES` under the key `"agent_config"` +(`sdks/python/agenta/sdk/utils/types.py:1132`). The API catalog imports `CATALOG_TYPES` from +the SDK and re-serves it (`api/oss/src/resources/workflows/catalog.py:10`). The API does not +define any agent fields itself. A grep for `AgentConfigSchema` across `api/` returns nothing. + +The agent workflow service advertises this type by reference, not by value. Its `/inspect` +schema carries a thin pointer plus a pre-fill default +(`services/oss/src/agent/schemas.py:55`): + +```python +AGENT_CONFIG_SCHEMA = { + "type": "object", + "x-ag-type-ref": "agent_config", + "default": _DEFAULT_AGENT_CONFIG, +} +``` + +The SDK builtin interface `agent_v0_interface` carries the same reference on its `agent` +parameter (`sdks/python/agenta/sdk/engines/running/interfaces.py:527`). + +The schema's own docstring states the design split. The runtime config stays permissive +because its job is to coerce sloppy input. This schema is strict because its job is to +describe the shape (`sdks/python/agenta/sdk/utils/types.py:1065`). + +## Layer 3: the SDK runtime config + +The neutral runtime `AgentConfig` lives at +`sdks/python/agenta/sdk/agents/dtos.py:308`. Its fields: + +```python +class AgentConfig(BaseModel): + model_config = ConfigDict(populate_by_name=True) # NOT extra="allow" + instructions: Optional[str] = None # becomes AGENTS.md + model: Optional[str] = None + tools: List[ToolConfig] = Field(default_factory=list) + mcp_servers: List[MCPServerConfig] = Field(default_factory=list) + harness_options: Dict[str, Dict[str, Any]] = Field(default_factory=dict) +``` + +One correction to a common belief. This model is not `extra="allow"`. Its looseness comes +from before-validators that coerce messy input, not from accepting arbitrary keys: + +- `_coerce_tools` accepts strings, dicts, and legacy shapes. +- `_coerce_mcp_servers` parses loose server shapes. +- `from_params()` accepts three payload shapes: the `agent` element, a prompt-template + prompt, or a flat `{model, agents_md, tools}` object. + +The genuinely loose object is the file-default dataclass at +`services/oss/src/agent/config.py:30`, which holds `tools: List[Any]`. That is the service's +built-in default, not user input. + +Two fields the schema lists are not on this neutral config. `harness`, `sandbox`, and +`permission_policy` live on a separate `RunSelection` object +(`sdks/python/agenta/sdk/agents/dtos.py:364`). The SDK splits "what the agent is" from "where +and how it runs." The composite schema flattens both into one control for the playground. + +Tool entries are strict even though the list is lenient. Each tool subclass is `extra="forbid"` +(`sdks/python/agenta/sdk/agents/tools/models.py`). `MCPServerConfig` is also `extra="forbid"` +with a transport validator (`sdks/python/agenta/sdk/agents/mcp/models.py`). + +There is no `ModelRef` type. `model` is a plain string everywhere. There is no provider field. +The rich model picker is built only for the UI by `_model_catalog_type()` +(`sdks/python/agenta/sdk/utils/types.py:1045`). + +## Layer 4: what the runtime actually reads + +The Python `/invoke` handler is at `services/oss/src/agent/app.py`. It parses the request +into two objects (around line 72): + +```python +agent_config = AgentConfig.from_params(params, defaults=_default_agent_config()) +selection = RunSelection.from_params(params) +``` + +It then resolves tools, MCP servers, and secrets server-side (`app.py`, lines 78 to 83), +bundles everything into a `SessionConfig` (`dtos.py:554`), picks a backend from the selection +(`select_backend`, `app.py:49`), and runs one turn through a harness. + +`sandbox` is deliberately absent from `SessionConfig`. It is a backend concern. The handler +passes it to `SandboxAgentBackend(sandbox=...)` instead (`app.py:56`). + +The final wire shape the Node runner receives is `AgentRunRequest` in +`services/agent/src/protocol.ts` (around line 185). That is the true wired surface: +`harness`, `sandbox`, `agentsMd`, `systemPrompt`/`appendSystemPrompt`, `model`, `tools` +(builtin names), `skills`, `customTools`, `mcpServers`, `toolCallback`, `permissionPolicy`. + +## Field-by-field: enforced vs loose, wired vs decorative + +Legend: (a) catalog/schema, (b) SDK neutral config, (c) runtime. + +| Field | (a) schema | (b) SDK config | (c) runtime | Status | +| --- | --- | --- | --- | --- | +| model / provider | yes, `model: str` | yes, `Optional[str]` | wired to the runner | Loose string. No `ModelRef`, no provider enum. There is no separate provider field. | +| tools | yes, strict list | yes, lenient coercion | wired, resolved to builtin names + tool specs | Entries strict, list lenient. | +| mcp_servers | yes, strict list | yes | wired, resolved to runner mcp servers | Strict per entry. Gated by `AGENTA_AGENT_ENABLE_MCP` at the service. | +| skills | no | no | wired but forced only | Not author-settable. Only the Agenta harness injects forced skills. See below. | +| persona | no | no | wired but forced only | Not a config field. The Agenta harness hardcodes an append-system preamble. See below. | +| agents_md | yes, `agents_md: str` | yes, as `instructions` | wired to `agentsMd` | The schema names it `agents_md`. The neutral config names it `instructions`. | +| harness | yes, enum | no, on `RunSelection` | wired, picks the harness class | Enum-enforced. The runtime validates via `make_harness`. | +| sandbox | yes, enum | no, on `RunSelection` | wired to the backend, absent from `SessionConfig` | Backend concern, not agent identity. | +| permission_policy | yes, enum | no, on `RunSelection` | wired to `SessionConfig` | Only the Claude harness reads it. Pi ignores it, so it is decorative for pi and agenta. | + +## Notable gaps and quirks + +`skills` and `persona` are not author config. They are runtime injections of the Agenta +harness only. `skills` is a `List[str]` on `AgentaAgentConfig`, force-populated from a fixed +list. `persona` is a forced append-system string. Neither appears in any schema, neither +appears on the neutral config, and the playground renders no control for either. Pi and +Claude harnesses get no forced skills or persona. + +Per-harness divergence is real. `permission_policy` is wired only for Claude. Builtin tool +names are dropped for Claude with a warning, because builtins are Pi-only. Skills and persona +are Agenta-only. Pi's `system` and `append_system` overrides come through the +`harness_options` escape hatch on the neutral config, which is itself absent from the schema. + +The schema is the only place where harness, sandbox, and permission policy sit next to the +agent definition. The SDK keeps them apart. The composite schema re-flattens them so the +playground can show one control. + +## A concrete example config + +This is what the playground saves and the runtime reads: + +```json +{ + "agents_md": "You are a helpful research assistant. Cite your sources.", + "model": "gpt-5.5", + "tools": [ + { "type": "builtin", "name": "web_search" } + ], + "mcp_servers": [ + { + "name": "github", + "transport": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"] + } + ], + "harness": "pi", + "sandbox": "local", + "permission_policy": "auto" +} +``` + +With this config, the runtime reads `agents_md`, `model`, `tools`, and `mcp_servers` through +the neutral `AgentConfig`, reads `harness`, `sandbox`, and `permission_policy` through +`RunSelection`, resolves the tools and MCP servers server-side, and runs one turn on the Pi +harness in a local sandbox. The `permission_policy` value is ignored because the harness is +Pi, not Claude. + +## See also + +- `agent-template.md` for the intended long-term template shape and what is still missing. +- `tools.md` for the tool taxonomy and resolution path. +- `running-the-agent.md` for how the service and the runner sidecar are actually started. diff --git a/docs/design/agent-workflows/agent-template.md b/docs/design/agent-workflows/documentation/agent-template.md similarity index 90% rename from docs/design/agent-workflows/agent-template.md rename to docs/design/agent-workflows/documentation/agent-template.md index e62b416606..4ee59e67d8 100644 --- a/docs/design/agent-workflows/agent-template.md +++ b/docs/design/agent-workflows/documentation/agent-template.md @@ -57,3 +57,9 @@ experimental and not a general template system. Hooks, assets, extra code snippets, and a generic permissions overlay are deferred. The POC should leave space for them without pretending they are supported. +## See also + +For the live config contract today, from the playground form through the catalog type and +SDK interface down to what the runtime reads, see `agent-configuration.md`. This page is the +intended shape; that page is the current reality, field by field. + diff --git a/docs/design/agent-workflows/documentation/architecture.md b/docs/design/agent-workflows/documentation/architecture.md new file mode 100644 index 0000000000..a563bf293e --- /dev/null +++ b/docs/design/agent-workflows/documentation/architecture.md @@ -0,0 +1,227 @@ +# Architecture + +This page explains how an agent workflow runs today. It describes the code on disk, verified +against the files cited. Where the doc states a future intent, it says so plainly. + +## The Model + +Agenta already runs prompt workflows that call a model once and return one answer. An agent +workflow runs a coding harness instead. The harness reads instructions, calls a model, calls +tools, observes the results, and loops until it has an answer. + +The runtime keeps two run choices configurable +(`sdks/python/agenta/sdk/agents/dtos.py:364`, `RunSelection`): + +- **Harness:** which agent runs. Supported values are `pi`, `claude`, and experimental + `agenta`. Default `pi`. +- **Sandbox:** where the run happens. Supported values are `local` and `daytona`. Default + `local`. + +The platform exposes the agent through normal workflow routing. `/invoke` is the batch +contract. Agent routes also register `/messages` and `/load-session` for the browser chat +protocol. + +## Runtime Shape + +The deployed stack uses two containers: the Python services container and the Node agent +runner sidecar. + +``` +browser / playground + | + | POST /invoke or POST /messages + v +services container (Python) + agent workflow handler + services/oss/src/agent/app.py + | + | POST /run over HTTP (AGENTA_AGENT_RUNNER_URL set) + | or spawn the runner CLI in a source checkout + v +agent runner sidecar (Node) + compose service: sandbox-agent + HTTP server on :8765 + services/agent/src/server.ts + | + +-- pi engine (in-process Pi) + | services/agent/src/engines/pi.ts + | + +-- sandbox-agent engine (default) + services/agent/src/engines/sandbox_agent.ts + | + +-- sandbox-agent daemon + | + +-- ACP adapter: pi or claude + | + +-- harness CLI: Pi or Claude Code +``` + +The services container owns Agenta concerns: workflow routing, config parsing, provider +secret resolution, tool resolution, and trace context. The sidecar owns the agent run. It +drives Pi in-process or drives a harness over ACP through the sandbox-agent daemon. In Docker +Compose the sidecar is named `sandbox-agent`, and the service reaches it through +`AGENTA_AGENT_RUNNER_URL` (`services/oss/src/agent/config.py:46`). + +The sidecar does not inherit the full stack environment. The service resolves provider keys +and tool credentials and passes them only in the scoped `/run` payloads that need them. + +## What The Deployed Service Actually Runs + +The deployed handler always uses `SandboxAgentBackend`. `select_backend` in +`services/oss/src/agent/app.py:49` constructs `SandboxAgentBackend` for every run, regardless +of harness. So `pi`, `claude`, and `agenta` all run through the sandbox-agent daemon over ACP +on the deployed path. + +The sidecar still has an in-process `pi` engine (`services/agent/src/engines/pi.ts`): a +`/run` request with `backend: "pi"` runs Pi in-process inside the sidecar without the daemon. +The deployed Python service never sends that. The SDK used to ship an `InProcessPiBackend` +adapter that drove this engine, presented as a "reference backend", but it was a confusing +POC and was removed. A test-only helper +(`sdks/python/oss/tests/pytest/integration/agents/_in_process_backend.py`) still drives the +`pi` engine in the transport round-trip test. + +This split matters when reading the code. There are two `pi` paths: + +- The `pi` engine in the sidecar (`engines/pi.ts`), reached only with `backend: "pi"`. +- The `pi` harness over the sandbox-agent daemon (`engines/sandbox_agent.ts` with `harness: + "pi"`), which is what the deployed service sends. + +## Backends + +The SDK runtime models engines as `Backend` adapters +(`sdks/python/agenta/sdk/agents/interfaces.py:133`). + +| Backend | Status | Harnesses | Sandbox support | Notes | +| --- | --- | --- | --- | --- | +| `SandboxAgentBackend` | Implemented | `pi`, `claude`, `agenta` | `local`, `daytona` | The deployed path. Drives `engines/sandbox_agent.ts`: starts the sandbox-agent daemon and an ACP adapter. `supported_harnesses` is `{pi, claude, agenta}` (`adapters/sandbox_agent.py:121`). | +| `LocalBackend` | Not implemented | Intended: `pi`, `claude` | Local machine | Public class exists; `create_sandbox` and `create_session` raise `NotImplementedError` (`adapters/local.py:34`). | + +The sidecar's in-process `pi` engine (`engines/pi.ts`) is still reachable with +`backend: "pi"`, but the SDK no longer ships a backend adapter for it. A test-only helper +drives it in the transport round-trip test. + +## Harnesses + +The SDK runtime models agent-specific behavior as `Harness` adapters +(`sdks/python/agenta/sdk/agents/adapters/harnesses.py`). + +| Harness | Status | Where it runs | Notes | +| --- | --- | --- | --- | +| `PiHarness` | Implemented | sandbox-agent (deployed) or in-process Pi | Native Pi tools, Pi prompt overrides, Pi tracing extension. | +| `ClaudeHarness` | Implemented | sandbox-agent only | MCP-delivered tools, permission policy, runner-built tracing. No Pi built-in tools. | +| `AgentaHarness` | Experimental | sandbox-agent (`local` and `daytona`) or in-process Pi | Pi with forced tools, forced skills, a base AGENTS.md preamble, and a persona. The harness maps to the `pi` ACP agent plus forced extras. Content is still placeholder. | + +The `agenta` harness runs on the sandbox-agent path. The runner treats it as the `pi` ACP +agent and layers the forced skills and prompt extras on top +(`services/agent/src/engines/sandbox_agent/run-plan.ts:78`). The QA matrix verified it on +sandbox-agent local and Daytona (`projects/qa/findings.md`, F-002). An earlier claim that +`agenta` was in-process-only was stale. + +## Request Flow + +Batch `/invoke` follows this path: + +1. The workflow route calls `_agent` in `services/oss/src/agent/app.py:63`. +2. `_agent` parses `AgentConfig` and `RunSelection` from request parameters. +3. The service resolves three things independently: tools, MCP servers, and provider-key + secrets. MCP resolution is gated by `AGENTA_AGENT_ENABLE_MCP` + (`services/oss/src/agent/tools/resolver.py:23`, off by default). +4. The service builds `SessionConfig` and constructs a harness over an `Environment` and + `SandboxAgentBackend`. +5. The harness opens a cold session, sends one `/run` request to the sidecar, and tears the + session down. +6. The service records usage on the workflow span and returns one assistant message. + +Agent `/messages` follows the same runtime path after a browser-protocol adapter step: + +1. `sdks/python/agenta/sdk/agents/adapters/vercel/routing.py` validates or mints `session_id`. +2. It converts Vercel `UIMessage` parts into neutral agent `Message` objects. +3. It sets `data.stream` from the `Accept` header. +4. `_agent` returns a batch message or streams an `AgentRun`. +5. The Vercel adapter converts live `AgentEvent` objects into Vercel UI Message Stream parts + and the routing layer frames them as SSE. + +`/load-session` is registered for agent routes, but no durable store is wired. It returns an +empty message list. See [Sessions](sessions.md). + +## Lifecycle + +The runtime is cold. Each turn creates a fresh session and tears it down after the turn. +Multi-turn context comes from replaying message history, not from a warm daemon or a persisted +model session. The sandbox-agent engine does keep an in-process `InMemorySessionPersistDriver` +(`services/agent/src/engines/sandbox_agent.ts:150`), but it lives only for the duration of one +`/run` process, so it does not survive across turns. + +This cold model keeps isolation simple and lets `/invoke` and `/messages` share one runtime. +It also means durable server-owned history and warm session reload are still future work. See +[Sessions](sessions.md). + +## The Sidecar + +The sidecar is a standalone Node package under `services/agent/`. It is not part of the `web/` +pnpm workspace. It builds its own Docker image and runs through `tsx` with no app compile step. +The only build is the Pi extension bundle. + +The sidecar serves one contract on two entrypoints (`services/agent/README.md`): + +- `src/server.ts`: a long-lived HTTP server on `:8765` with `GET /health` and `POST /run`. + This is the dockerized sidecar the service calls over HTTP. +- `src/cli.ts`: one JSON request on stdin, one result on stdout. The SDK adapters use this + subprocess transport when `AGENTA_AGENT_RUNNER_URL` is unset (a source checkout). + +Both route to an engine by the request's `backend` field. The default is `sandbox-agent` +(`services/agent/src/server.ts:38`). + +### Licensing and images + +Two image files live under `services/agent/docker/` +(`services/agent/docker/README.md`): + +- `Dockerfile`: production. Source baked in, no watcher. +- `Dockerfile.dev`: dev. `tsx watch`, source bind-mounted, hot reload. + +The rule that shapes every image: ship build recipes, not Claude-containing images, and never +bake a credential into any image. + +- Pi (`@earendil-works/pi-coding-agent`, MIT) is baked via npm dependencies. +- Claude Code is proprietary. It is never baked into an image Agenta builds and distributes. + The sandbox-agent daemon installs it from Anthropic at runtime over HTTPS, which is why the + image installs `ca-certificates`. +- No credential is baked. Provider keys arrive as request secrets or `ANTHROPIC_API_KEY` / + `OPENAI_API_KEY`. OAuth subscription login is a self-host, mount-only opt-in, never for + multi-tenant serving. + +The production image also installs `python3`, because `code` tools with `runtime: "python"` +run in the sidecar by spawning `python3` (`services/agent/docker/Dockerfile:27`). + +### Daytona sandbox + +For the `daytona` sandbox, the runner starts a remote Daytona VM and pushes the harness login, +the Pi extension, AGENTS.md, skills, and any system-prompt files into it over the Daytona +filesystem API (`services/agent/src/engines/sandbox_agent/daytona.ts`). Agenta ships a build +recipe, not a built snapshot. The operator runs it in their own Daytona account +(`services/agent/sandbox-images/daytona/`). The runner reads `SANDBOX_AGENT_PROVIDER` and the +`SANDBOX_AGENT_DAYTONA_*` env vars to find the snapshot. + +## Tracing + +When the `/run` request carries a `trace` block, the run is exported to Agenta as +OpenTelemetry spans nested under the caller's `/invoke` span. The Pi path self-instruments via +the bundled Agenta extension. Other harnesses are traced by the runner from the ACP event +stream (`services/agent/src/tracing/otel.ts`). The Python `tracing` module +(`services/oss/src/agent/tracing.py`) fills the `trace` block from the live workflow span and +rolls run usage back onto it. + +## Gaps + +- `LocalBackend` is a public adapter shape but does not run anything yet. +- No durable session store is wired. `/load-session` returns empty history and completed turns + are not persisted. See [Sessions](sessions.md). +- `AgentaHarness` uses placeholder preamble, persona, and skill content. +- The agent is registered as a custom workflow handler, not as a first-class builtin URI such + as `agenta:builtin:agent:v0`. The builtin interface exists in the SDK, but the handler is + still bound directly (`services/oss/src/agent/app.py:138`). +- Per-request model override is not honored on the Pi-over-sandbox-agent ACP path; pi-acp + accepts only its default model (`projects/qa/findings.md`, F-007). +- For the full reconciliation of what is wired and what is missing, see + [Ground Truth](ground-truth.md). diff --git a/docs/design/agent-workflows/ground-truth.md b/docs/design/agent-workflows/documentation/ground-truth.md similarity index 69% rename from docs/design/agent-workflows/ground-truth.md rename to docs/design/agent-workflows/documentation/ground-truth.md index 0098d3e6e1..c85f513db2 100644 --- a/docs/design/agent-workflows/ground-truth.md +++ b/docs/design/agent-workflows/documentation/ground-truth.md @@ -1,20 +1,19 @@ # Ground Truth -This page maps the active agent-workflows PR stack. It describes the code after the -sibling code PRs are considered together. The docs PR commit itself is docs-only and does -not contain every file listed below. If another design page disagrees with this page, -treat this page and the referenced code as the source of truth. +This page maps what the agent-workflows code does, what is wired, and what is missing. It is +verified against the files it cites. If another design page disagrees with this page, treat +this page and the referenced code as the source of truth. ## Code Surface | Area | Files | Active-stack role | | --- | --- | --- | -| Agent service handler | `services/oss/src/agent/app.py` | Parses agent config, resolves secrets and tools, chooses a backend, runs batch or streaming turns. | +| Agent service handler | `services/oss/src/agent/app.py` | Parses agent config, resolves secrets and tools, builds `SandboxAgentBackend`, runs batch or streaming turns. | | Agent route wiring | `sdks/python/agenta/sdk/decorators/routing.py` | Registers `/invoke`, `/inspect`, and agent-only `/messages` plus `/load-session`. | | Browser protocol adapter | `sdks/python/agenta/sdk/agents/adapters/vercel/` | Converts Vercel `UIMessage` input and emits Vercel UI Message Stream parts. | | SDK runtime DTOs | `sdks/python/agenta/sdk/agents/dtos.py` | Defines `AgentConfig`, `RunSelection`, `SessionConfig`, messages, events, capabilities, and harness configs. | | SDK runtime ports | `sdks/python/agenta/sdk/agents/interfaces.py` | Defines `Backend`, `Environment`, `Sandbox`, `Session`, `Harness`, `SessionStore`, and `NoopSessionStore`. | -| Backend adapters | `sdks/python/agenta/sdk/agents/adapters/in_process.py`, `sandbox_agent.py`, `local.py` | Implement in-process Pi and sandbox-agent backends. `LocalBackend` is a stub. | +| Backend adapters | `sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py`, `local.py` | Implement the sandbox-agent backend. `LocalBackend` is a stub. | | Harness adapters | `sdks/python/agenta/sdk/agents/adapters/harnesses.py` | Maps neutral session config into Pi, Claude, and Agenta harness-specific config. | | Runner wire | `sdks/python/agenta/sdk/agents/utils/wire.py`, `services/agent/src/protocol.ts` | Keeps the Python and TypeScript `/run` payloads in sync. | | Runner transports | `sdks/python/agenta/sdk/agents/utils/ts_runner.py`, `services/agent/src/server.ts`, `services/agent/src/cli.ts` | Send one-shot JSON or live NDJSON records to and from the runner. | @@ -33,9 +32,17 @@ treat this page and the referenced code as the source of truth. runtime messages, and supports JSON or Vercel SSE based on `Accept`. - Streaming runs over a runner NDJSON stream internally. The browser edge projects those events into Vercel UI Message Stream parts and appends `[DONE]`. -- `InProcessPiBackend` supports `pi` and `agenta` on local. -- `SandboxAgentBackend` supports `pi` and `claude` on local or Daytona. +- The deployed service always uses `SandboxAgentBackend` (`services/oss/src/agent/app.py:49`). + It does not select a backend per harness. +- `SandboxAgentBackend` supports `pi`, `claude`, and `agenta` on local or Daytona. +- The sidecar's in-process `pi` engine (`engines/pi.ts`) is still reachable with + `backend: "pi"`, but the SDK no longer ships a backend adapter for it. A test-only helper + drives it in the transport round-trip test. - `PiHarness`, `ClaudeHarness`, and `AgentaHarness` exist and validate backend support. +- Pi `systemPrompt` and `appendSystemPrompt` overrides are delivered on both the in-process Pi + path and the sandbox-agent Pi path. The sandbox-agent engine writes `SYSTEM.md` / + `APPEND_SYSTEM.md` into the per-run Pi agent dir, local and Daytona + (`services/agent/src/engines/sandbox_agent/pi-assets.ts`). - The tool resolver package exists in the SDK. The service composes SDK tool and MCP resolvers with service-owned gateway and vault adapters. - Code tools execute in a subprocess with a minimal allowlisted environment plus scoped @@ -54,12 +61,13 @@ treat this page and the referenced code as the source of truth. - Harness session snapshots, such as sandbox-agent/ACP state save/load around cleanup/setup, are not represented by a production port yet. - Warm daemon sessions, ACP `session/load`, and session fork are not wired. -- `AgentaHarness` ships placeholder Agenta preamble, persona, and skill set. (It does run on - sandbox-agent local and Daytona, verified by the QA matrix; the earlier "does not run on sandbox-agent" note - was stale.) -- The agent is not registered as a first-class built-in workflow type. -- Pi `systemPrompt` and `appendSystemPrompt` are not delivered on the sandbox-agent ACP path. -- Remote MCP servers are skipped by the active-stack runner path. Local stdio MCP is the path +- `AgentaHarness` ships placeholder Agenta preamble, persona, and skill set. It does run on + sandbox-agent local and Daytona, verified by the QA matrix (`projects/qa/findings.md`, F-002). +- The agent is not registered as a first-class built-in workflow type. The builtin interface + exists in the SDK, but the handler is still bound directly (`services/oss/src/agent/app.py:138`). +- Per-request model override is not honored on the Pi-over-sandbox-agent ACP path. pi-acp + accepts only its default model and silently falls back (`projects/qa/findings.md`, F-007). +- Remote (`http`) MCP servers are skipped by the runner path. Local stdio MCP is the path represented by the bridge. - Trigger lifecycle, Compose.io trigger integration, and event-to-agent mapping are not implemented in the agent workflow code. @@ -68,16 +76,16 @@ treat this page and the referenced code as the source of truth. ## Planned Or Blocked Work -- [SDK Local Tools](sdk-local-tools/) is a planned and partly implemented workspace for - standalone SDK tool resolution. It remains blocked on `LocalBackend`. +- [SDK Local Tools](../projects/sdk-local-tools/) is a planned and partly implemented + workspace for standalone SDK tool resolution. It remains blocked on `LocalBackend`. - Durable server-owned sessions need a real `SessionStore`, a write path from completed turns, ownership checks, and a decision on platform versus local storage. - Stateful session resume needs research into sandbox-agent/ACP session representation and a future save/load snapshot interface separate from chat history. - Trigger integration needs a provider port, a Compose.io adapter, Agenta-owned trigger state, and event-to-agent mapping. -- The old streaming RFCs are archived in [trash/old-rfcs/](trash/old-rfcs/). They explain - why the protocol exists but no longer describe the exact active-stack state. +- The old streaming RFCs are archived in [../archive/old-rfcs/](../archive/old-rfcs/). They + explain why the protocol exists but no longer describe the exact current state. ## Verification Pointers @@ -85,4 +93,4 @@ treat this page and the referenced code as the source of truth. `sdks/python/oss/tests/pytest/utils/test_messages_endpoint.py`. - Agent service handler tests live in `services/oss/tests/pytest/unit/agent/`. - Wire-contract tests live in `sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py`. -- Runner tool tests live in `services/agent/test/`. +- Runner tests live in `services/agent/tests/unit/`. diff --git a/docs/design/agent-workflows/ports-and-adapters.md b/docs/design/agent-workflows/documentation/ports-and-adapters.md similarity index 83% rename from docs/design/agent-workflows/ports-and-adapters.md rename to docs/design/agent-workflows/documentation/ports-and-adapters.md index c41c7a24fb..676e60f187 100644 --- a/docs/design/agent-workflows/ports-and-adapters.md +++ b/docs/design/agent-workflows/documentation/ports-and-adapters.md @@ -11,7 +11,7 @@ The SDK runtime lives under `sdks/python/agenta/sdk/agents/`. | --- | --- | --- | | DTOs | `dtos.py` | `AgentConfig`, `RunSelection`, `SessionConfig`, messages, events, capabilities, and harness-specific config models. | | Ports | `interfaces.py` | `Backend`, `Environment`, `Sandbox`, `Session`, `Harness`, `SessionStore`. | -| Backend adapters | `adapters/in_process.py`, `adapters/sandbox_agent.py`, `adapters/local.py` | Engines that can run a harness. | +| Backend adapters | `adapters/sandbox_agent.py`, `adapters/local.py` | Engines that can run a harness. | | Harness adapters | `adapters/harnesses.py` | Per-harness mapping from neutral session config to harness-specific config. | | Browser adapter | `adapters/vercel/` | Vercel `UIMessage` input and Vercel UI Message Stream output. | | Runner plumbing | `utils/wire.py`, `utils/ts_runner.py` | `/run` serialization and runner transports. | @@ -28,10 +28,14 @@ sessions. It does not know how Pi or Claude wants tools shaped. Current backends: -- `InProcessPiBackend`: implemented, supports `pi` and `agenta`, local only. -- `SandboxAgentBackend`: implemented, supports `pi` and `claude`, local or Daytona. +- `SandboxAgentBackend`: implemented, supports `pi`, `claude`, and `agenta`, local or Daytona. + This is the backend the deployed service always uses (`services/oss/src/agent/app.py:49`). - `LocalBackend`: planned, public class exists, methods raise. +The sidecar's in-process `pi` engine (`engines/pi.ts`) is still reachable with `backend: "pi"`, +but the SDK no longer ships a backend adapter for it; a test-only helper drives it in the +transport round-trip test. + ### Environment `Environment` wraps a backend and owns sandbox policy. The default is one sandbox per @@ -45,11 +49,13 @@ turn. Current harnesses: -- `PiHarness` keeps built-in tool names, resolved tool specs, Pi prompt overrides, and Pi - native tool delivery. +- `PiHarness` keeps built-in tool names, resolved tool specs, Pi prompt overrides (`system` + and `append_system` from `harness_options.pi`), and Pi native tool delivery. - `ClaudeHarness` drops Pi built-ins, carries MCP-delivered specs, and carries the permission policy. -- `AgentaHarness` is Pi with forced Agenta policy layered on top. +- `AgentaHarness` is Pi with forced Agenta policy layered on top: a base AGENTS.md preamble, + a forced persona, forced tools, and forced skills (`adapters/agenta_builtins.py`). It runs + on `SandboxAgentBackend`. ### Session @@ -104,8 +110,10 @@ tools, resolved MCP servers, trace context, and the session id. 2. Resolve provider secrets. 3. Resolve tools and, when enabled, MCP servers. 4. Build `SessionConfig`. -5. Choose a backend. -6. Build the harness. +5. Build the backend. The service always builds `SandboxAgentBackend`, passing the run's + sandbox (`local` or `daytona`) and the runner transport. It does not branch on harness. +6. Build the harness over an `Environment` wrapping that backend. The harness validates that + the backend supports it. 7. Run `prompt` or `stream`. Tool and MCP resolution are split cleanly: @@ -147,7 +155,6 @@ result fields should update both sides and the wire tests in the same PR. - `SessionStore` has no production adapter and the current runtime does not call `save_turn` after completed `/messages` turns. - `AgentaHarness` policy content is placeholder product copy. -- `AgentaHarness` cannot run on sandbox-agent or Daytona. - MCP server resolution is disabled unless `AGENTA_AGENT_ENABLE_MCP` is truthy. -- The code still has historical WP labels in comments. Those labels should not guide new +- The code still has historical WP labels in some comments. Those labels should not guide new design decisions. diff --git a/docs/design/agent-workflows/protocol.md b/docs/design/agent-workflows/documentation/protocol.md similarity index 84% rename from docs/design/agent-workflows/protocol.md rename to docs/design/agent-workflows/documentation/protocol.md index 1859f9311f..24057371ca 100644 --- a/docs/design/agent-workflows/protocol.md +++ b/docs/design/agent-workflows/documentation/protocol.md @@ -123,13 +123,14 @@ Request fields include: | Field | Meaning | | --- | --- | -| `backend` | Runner engine: `pi` or `sandbox-agent`. | -| `harness` | Harness id: `pi`, `claude`, or `agenta` depending on backend support. | +| `backend` | Runner engine: `sandbox-agent` (default) or `pi` (in-process). The deployed service always sends `sandbox-agent`. | +| `harness` | Harness id: `pi`, `claude`, or `agenta`. On the sandbox-agent path `agenta` maps to the `pi` ACP agent plus forced skills and prompt extras. | | `sandbox` | Sandbox id, usually `local` or `daytona`. | -| `sessionId` | External conversation id. The runtime is still cold and receives history in `messages`. | +| `sessionId` | External conversation id. The runtime is cold and receives history in `messages`. | | `agentsMd` | Instructions that become `AGENTS.md`. | -| `systemPrompt`, `appendSystemPrompt` | Pi prompt overrides. Not delivered on the sandbox-agent Pi path yet. | -| `model` | Requested model id. | +| `systemPrompt`, `appendSystemPrompt` | Pi prompt overrides. Delivered on both the in-process Pi path and the sandbox-agent Pi path (the sandbox-agent engine writes `SYSTEM.md` / `APPEND_SYSTEM.md` into the per-run Pi agent dir, local and Daytona). | +| `skills` | Bundled skill directory names to force-load (the `agenta` harness, Pi only). | +| `model` | Requested model id. Not honored on the Pi-over-sandbox-agent path; pi-acp accepts only its default model (see Ground Truth). | | `messages` | Conversation history and current turn. | | `secrets` | Provider env vars resolved by the service. | | `tools`, `customTools`, `toolCallback`, `mcpServers` | Resolved tool delivery. | diff --git a/docs/design/agent-workflows/documentation/running-the-agent.md b/docs/design/agent-workflows/documentation/running-the-agent.md new file mode 100644 index 0000000000..48c69e1ad0 --- /dev/null +++ b/docs/design/agent-workflows/documentation/running-the-agent.md @@ -0,0 +1,205 @@ +# Running the Agent + +This page explains how the agent workflow runs in practice. There is no agent-specific +`run.sh`. The agent runs as a normal service in the Agenta stack, started by the shared +`hosting/docker-compose/run.sh`. This page covers that script, the agent pieces it starts, +the ports, the env vars, and the two ways to run the Node runner outside Docker. + +All file:line citations were verified against the code on 2026-06-23. + +## There are two agent processes + +The agent workflow is split across two services. Know which is which. + +1. The Python agent service. It lives in `services/oss/src/agent/`. It runs inside the shared + `services` container as a normal Agenta workflow. It decides what to run. It exposes + `/invoke` and `/inspect`, parses the config, resolves tools and secrets server-side, and + then calls the runner (`services/oss/src/agent/app.py`). + +2. The Node runner sidecar. It lives in `services/agent/`. Its compose service name is + `sandbox-agent`. It runs the agent loop with the real harnesses (Pi, Claude, the + `sandbox-agent` package). It listens on `:8765` and serves `GET /health` and `POST /run` + (`services/agent/src/server.ts`). The Python service calls it over HTTP. + +The Python service finds the runner through `AGENTA_AGENT_RUNNER_URL`, which defaults to +`http://sandbox-agent:8765` in every compose stage (for example +`hosting/docker-compose/ee/docker-compose.dev.yml:421`). + +## The script: hosting/docker-compose/run.sh + +`run.sh` is the single entrypoint for the whole stack. It picks the right compose file, +profiles, and env file, then builds or pulls images and runs `docker compose up -d`. The +agent comes up with everything else. You do not start it separately. + +Note: the `run-sh` skill describes an older flag set (`--stage`, `--gh`, `--ssl`, +`--web-domain`). The current script uses different flags. The accurate flag set is below, +read straight from `hosting/docker-compose/run.sh`. + +### Stage selection + +The script derives a stage from the image mode and a few flags: + +- `--dev` selects the `dev` stage. Code is bind-mounted and reloads live. +- `--gh` (the default) selects the `gh` stage. It uses prebuilt registry images. +- `--local` with `--gh` selects `gh.local`, which builds from local source but in gh layout. +- `--ssl` with `--gh` selects `gh.ssl`. + +The compose file resolves to +`hosting/docker-compose//docker-compose..yml`. If that file is missing, the +script exits with an error. + +### Key flags + +- `--oss` or `--ee` or `--license `. Default is `oss`. +- `--dev` or `--gh` or `--image `. Default is `gh`. +- `--local`. Build from local gh source. Requires `--gh`. +- `--build`. Build images before up. +- `--no-cache`. Build with no cache. Requires `--build`. +- `--pull` or `--no-pull`. Default is pull on gh, no pull on dev. +- `--no-web` or `--web-local` or `--web-mode `. Default is docker. +- `--web-url `. Override `AGENTA_WEB_URL`. +- `-e` or `--env` or `--env-file `. Use an explicit env file. Otherwise the stage + default applies. +- `--nuke`. Remove related volumes on shutdown. +- `--down`. Stop containers and exit, no up. +- `--ssl`. Use the SSL proxy stage. Requires `--gh`. +- `--nginx`. Use the nginx proxy instead of Traefik. +- `--help`. Print usage. + +### What it does, in order + +1. Parse and validate flags. Conflicting flags error out. +2. Pick the compose file from license and stage. +3. Resolve the env file. The default is `.env..` under + `hosting/docker-compose//`. `gh.local` reuses the `gh` env file. +4. Add profiles. `with-web` unless web mode is none. Then `with-traefik` or `with-nginx`. +5. Build, or build with no cache, or pull, depending on the flags and stage. +6. Run `docker compose down` to clear the old stack. Add `--volumes` when `--nuke`. +7. Run `docker compose up -d` with `AGENTA_WEB_URL` set. +8. If web mode is local, install web deps and run the web dev server on the host. + +The agent runs in step 7 like any other service. No agent flag exists. + +## The standard agent dev command + +From the main checked-out branch: + +```bash +./hosting/docker-compose/run.sh --build --license ee --dev --env-file .env.ee.dev.local +``` + +This is the dev default from `hosting/CLAUDE.md`. It brings up the full EE stack in dev mode, +including the `services` container (which hosts the Python agent service) and the +`sandbox-agent` container (the Node runner). + +From a git worktree, prefix a distinct project name and use a per-worktree env file so the +two stacks do not collide: + +```bash +COMPOSE_PROJECT_NAME=agenta-ee-dev-instance2 ./hosting/docker-compose/run.sh \ + --license ee --dev --env-file .env.ee.dev.instance2 +``` + +To stop the stack without removing volumes: + +```bash +./hosting/docker-compose/run.sh --license ee --dev --down +``` + +## What run.sh starts for the agent + +In the EE dev compose, the relevant services are: + +- `services`. Runs uvicorn on port `8080` inside the container + (`hosting/docker-compose/ee/docker-compose.dev.yml:383`). It hosts the Python agent + service. Traefik routes `/services/` to it. It sets `AGENTA_AGENT_RUNNER_URL` to + `http://sandbox-agent:8765` and `AGENTA_AGENT_ENABLE_MCP` to `false` by default (lines 421 + to 422). It depends on `sandbox-agent` being healthy (line 430). + +- `sandbox-agent`. The Node runner (lines 444 onward). In dev it runs + `tsx src/server.ts` after rebuilding the Pi extension. It listens on `8765`. Its health + check hits `http://127.0.0.1:8765/health` (line 492). It is not behind a compose profile, + so it always comes up. + +The `sandbox-agent` service ships in every stage. It is present in dev, gh, and gh.ssl for +both oss and ee. For example the gh stage defines it at +`hosting/docker-compose/ee/docker-compose.gh.yml:317` and +`hosting/docker-compose/oss/docker-compose.gh.yml:344`. In gh it uses a prebuilt ghcr image +instead of building from source. + +### The dev sandbox-agent command, explained + +The dev compose overrides the image CMD with a shell command (around line 455): + +```sh +mkdir -p /pi-agent && cp -a /pi-agent-ro/. /pi-agent/ 2>/dev/null || true; +node scripts/build-extension.mjs && +exec node_modules/.bin/tsx src/server.ts +``` + +It does three things. It copies the read-only mounted Pi login into a writable path so OAuth +refresh stays in the container. It rebuilds the Pi extension from the mounted `src`, because +`dist/` is not bind-mounted and a restart would otherwise keep a stale bundle and silently +drop custom tools. It then starts the server with `tsx`. + +## Ports + +- `8765`. The Node runner sidecar. `GET /health` and `POST /run`. Internal to the stack. +- `8080`. The Python `services` container's uvicorn. Internal. Traefik routes `/services/` + to it. +- Traefik. In dev the EE stack exposes Traefik on the host. The default mapping is + `8080:8080` in the example compose, but the live local env file + (`hosting/docker-compose/ee/.env.ee.dev.local`) sets `TRAEFIK_PORT=8280`, so the local box + serves the whole stack on `:8280`. + +The frontend talks to the agent through the gateway, not the runner. For example the local +env file points the chat slice at +`http://144.76.237.122:8280/services/agent/v0/messages` +(`NEXT_PUBLIC_AGENT_CHAT_API` in `.env.ee.dev.local`). + +## Agent env vars + +These are the agent-relevant variables. The example file lists them commented out +(`hosting/docker-compose/ee/env.ee.dev.example`, lines 119 onward). + +- `AGENTA_AGENT_RUNNER_URL`. Where the Python service finds the runner. Default + `http://sandbox-agent:8765`. When unset, the Python service spawns the runner CLI locally + instead (see `runner_url` and `select_backend` in `services/oss/src/agent/`). +- `AGENTA_AGENT_ENABLE_MCP`. Gates MCP server resolution. Default `false`. +- `SANDBOX_AGENT_PROVIDER`. `local` or `daytona`. Default `local`. +- `SANDBOX_AGENT_DAYTONA_API_KEY`, `_API_URL`, `_TARGET`, `_SNAPSHOT`, `_IMAGE`, + `_INSTALL_PI`. Daytona credentials the runner reads for the `daytona` sandbox provider. + +The `sandbox-agent` container deliberately has no `env_file`. The harness sandbox must not +inherit the stack's secrets. The compose block comments explain this +(`hosting/docker-compose/ee/docker-compose.dev.yml`, around line 459). Tools run server-side +in the Python service, so the sandbox only needs its own port, the Pi login, an OTLP export +fallback, and the Daytona credentials. + +## Running the Node runner outside Docker + +You can run the runner directly. From `services/agent/`, with Node 24 on PATH +(`services/agent/AGENTS.md`): + +```bash +pnpm install +pnpm run serve # HTTP sidecar on :8765, GET /health and POST /run +pnpm run run:cli # one JSON request on stdin, one result on stdout +``` + +This is a standalone pnpm package. It is not part of the web workspace. It runs through `tsx` +with no compile step. The only build is `pnpm run build:extension`, which bundles the Pi +extension into `dist/`. + +When the Python service runs in a source checkout with `AGENTA_AGENT_RUNNER_URL` unset, it +spawns this runner through the CLI path instead of calling it over HTTP. See `select_backend` +in `services/oss/src/agent/app.py:49` and `runner_url` in `services/oss/src/agent/config.py`. + +## See also + +- The `run-sh` skill at `.claude/skills/run-sh/SKILL.md`. It is a useful overview but its + flag list is stale. Trust `hosting/docker-compose/run.sh` and `docs/packs/hosting.md` for + the current flags. +- `hosting/CLAUDE.md` for the worktree project-name pattern. +- `agent-configuration.md` for what the config payload contains. +- `architecture.md` and `ports-and-adapters.md` for the service split rationale. diff --git a/docs/design/agent-workflows/documentation/sessions.md b/docs/design/agent-workflows/documentation/sessions.md new file mode 100644 index 0000000000..1c2a8b166b --- /dev/null +++ b/docs/design/agent-workflows/documentation/sessions.md @@ -0,0 +1,134 @@ +# Sessions + +This page describes how sessions behave today, then how they would behave with a real session +store. The two parts are kept separate on purpose. + +## Today + +### Every turn is cold + +The runtime has session ids but no durable server-owned history. Each turn is cold: + +1. The service creates a harness session. +2. The backend sends one `/run` request to the sidecar. +3. The runner starts the process tree (the sandbox-agent daemon and an ACP harness, or + in-process Pi). +4. The harness completes one turn. +5. The session is destroyed. + +Nothing warm is kept between turns. The model sees prior conversation only because the client +sends message history again on every turn. + +- On `/invoke`, history is read from `data.inputs.messages`. +- On `/messages`, history is read from `data.messages` in Vercel `UIMessage` shape, then + converted to neutral runtime messages before the same handler runs. + +The sandbox-agent engine creates an `InMemorySessionPersistDriver` +(`services/agent/src/engines/sandbox_agent.ts:150`), but it exists only for the one `/run` +process. It does not survive across turns, so it does not make the runtime warm. + +### What the session id does + +`session_id` is an opaque conversation id. `/messages` accepts it at the top level. If the +client omits it, the route mints one with a `sess_` prefix +(`sdks/python/agenta/sdk/agents/adapters/vercel/routing.py:43`). If the client sends one, the +route validates it against `^[A-Za-z0-9._:-]{1,128}$` and echoes it; an invalid id is a 400. + +The id flows through the run: + +- `WorkflowInvokeRequest.session_id` +- `_agent(..., session_id=...)` +- `SessionConfig.session_id` +- the `/run` `sessionId` field +- the runner result +- the Vercel stream `start.messageMetadata.sessionId` +- the batch `WorkflowBatchResponse.session_id` + +The id groups turns. It does not make the server authoritative for context. The message +history on the request is still what the model sees. + +### Streaming + +Streaming is implemented without changing the cold lifecycle. The runner emits live NDJSON +records internally: one `{"kind":"event"}` record per event, then one `{"kind":"result"}` +terminal record. The Python `AgentRun` turns those records into live `AgentEvent` objects. The +Vercel adapter projects each event into Vercel UI Message Stream parts, and the route frames +them as SSE. + +So the browser can see text, reasoning, tool calls, tool results, data parts, files, errors, +and finish metadata as they happen. This is live delivery, not a warm or persisted session. + +### `/load-session` + +The route exists and calls a `SessionStore` port. The default store is `NoopSessionStore` +(`sdks/python/agenta/sdk/agents/interfaces.py:112`), and the route registration passes no +other store (`sdks/python/agenta/sdk/decorators/routing.py:515`). So it always returns an +empty list: + +```json +{ "session_id": "sess_abc", "messages": [] } +``` + +That makes the protocol testable. It does not restore history. + +## Intended (not implemented) + +### Create-or-resume + +The intended id behavior is create-or-resume: + +- If the client omits `session_id`, the server creates one and returns it. +- If the client supplies a known `session_id`, the server resumes that session. +- If the client supplies an unknown but valid `session_id`, the server creates a session using + that id. + +The current code only validates and propagates the id. With no durable store, it cannot tell a +known id from an unknown one. So create-or-resume is intent, not behavior. + +There should not be a required `create-session` endpoint for the normal chat path. The same +implicit creation should cover pre-message operations too. For example, a file upload before +the first typed message can create a session and return the id later chat turns use. + +A client that already knows a session id and needs to render history should call +`/load-session` before the first message. + +### A real session store + +To make sessions real, the platform needs: + +- A production `SessionStore` implementation, injected where `NoopSessionStore` is today. +- A call to `save_turn` after each completed `/messages` turn. +- Ownership checks keyed by project and caller. +- A load path that returns persisted Vercel `UIMessage` history. +- A policy for failed, cancelled, and partially streamed turns. + +Until that lands, clients must keep sending full history. + +### Harness session snapshots + +Durable chat history is the MVP. Stateful harnesses may also need their own session state saved +before teardown and loaded during setup. This is separate from storing `UIMessage` history. + +Examples of state that may not be recoverable from messages alone: + +- A sandbox-agent or ACP session blob. +- Tool or harness state created during setup. +- Filesystem or process metadata needed to resume a warm session after a cold restart. + +This interface is not designed yet. The `SessionStore` port covers message history only; a +snapshot port would be a separate addition. It likely needs explicit `save_session` and +`load_session` semantics around cleanup and setup, plus a storage decision after we measure the +size and shape of sandbox-agent/ACP session data. Small JSON blobs may fit in Postgres. Large +opaque blobs may need object storage. Retention should be short by default, measured in days. + +### Warm sessions + +Warm sessions are separate from durable cold history. A warm model would keep the daemon or +harness state alive and use ACP `session/load` or equivalent state restoration. That can +recover state a transcript cannot, but it also needs a filesystem jail, per-session secret +channels, and clear multi-tenant isolation. + +The likely order: + +1. Add server-owned history while keeping cold replay. +2. Add warm daemon sessions only if long-running stateful agents need them. diff --git a/docs/design/agent-workflows/documentation/tools.md b/docs/design/agent-workflows/documentation/tools.md new file mode 100644 index 0000000000..d893da547a --- /dev/null +++ b/docs/design/agent-workflows/documentation/tools.md @@ -0,0 +1,320 @@ +# Tools + +An agent is only as useful as the tools it can call. This page explains how Agenta defines a +tool, the tool types we support, and exactly how each type runs at request time. The question +this page keeps coming back to is *where execution happens*: inside the harness, in a runner +subprocess, back at the Agenta service, or in the browser. The answer is different for each +tool type, and getting it right is what keeps secrets server-side while still letting the +agent act. + +Read the [architecture](architecture.md) and [ports and adapters](ports-and-adapters.md) +pages first. This page assumes the service/runner split and the `/run` wire contract. For the +two harnesses' delivery mechanics in full, see the [Pi adapter](adapters/pi.md) and the +[Claude Code adapter](adapters/claude-code.md). + +## A tool has two lives: declared config and resolved spec + +A tool exists in two forms, and almost every confusion about tools comes from mixing them up. + +1. **The declared config** is what an author commits in `AgentConfig.tools`. It says what the + tool *is*: a reference to a gateway action, an inline snippet, a built-in name. It is + stable, portable, and contains no secrets and no endpoints. +2. **The resolved spec** is what the runner receives on the `/run` wire. It says how to *run* + the tool: the secrets already injected, the callback endpoint already filled in, the + gateway reference already turned into a server-side slug. It is per-run and never committed. + +The service turns the first into the second every run. The runner only ever sees the second. + +The declared models live in `sdks/python/agenta/sdk/agents/tools/models.py`. Every tool config +shares two fields through `ToolConfigBase`, and then a `type` discriminator picks the variant: + +| Config (`type`) | Carries | Example use | +| --- | --- | --- | +| `builtin` | `name` | A harness-native tool such as Pi's `read` or `web_search`. | +| `gateway` | `provider`, `integration`, `action`, `connection`, optional `name` | A Composio action, like `github__create_issue` on a connected account. | +| `code` | `name`, `runtime` (`python`/`node`), `script`, `input_schema`, `secrets` | An inline snippet the author writes, with named vault secrets injected. | +| `client` | `name`, `input_schema` | A tool the browser fulfils, like "ask the user to pick a date." | + +MCP servers are a sibling field, `AgentConfig.mcp_servers`, not a tool type. They are declared +in `sdks/python/agenta/sdk/agents/mcp/models.py` and resolved alongside tools. They are +covered in their own section below. + +## Three orthogonal axes + +The `type` field is one of three independent axes a tool config carries. They do not interact, +and the runner reads each one separately. This is the single idea that makes the tool model +extensible without new branches everywhere. + +- **Executor (`type` at config time, `kind` at runtime):** who fulfils a call. This is the + axis that decides *where execution happens*, and the rest of this page is mostly about it. +- **`needs_approval`:** whether a call waits for a human yes/no before it runs. Default false. +- **`render`:** an optional generative-UI hint so the frontend can draw the call and its + result as something richer than text. + +A code tool can need approval. A gateway tool can carry a render hint. The axes compose. + +The executor axis is named `type` in the committed config and `kind` on the resolved spec. The +rename is deliberate: config talks about where a tool *comes from* (`gateway`), while runtime +talks about *how the runner fulfils it* (`callback`). The mapping is small but worth pinning, +because it is the seam between the two lives of a tool: + +| Declared `type` | Resolved form | Resolved `kind` | +| --- | --- | --- | +| `builtin` | a bare name in `builtin_names` | (none; not a spec) | +| `gateway` | `CallbackToolSpec` with a `call_ref` slug | `callback` | +| `code` | `CodeToolSpec` with secrets in `env` | `code` | +| `client` | `ClientToolSpec` | `client` | + +The resolved specs are also defined in `tools/models.py` (`CallbackToolSpec`, `CodeToolSpec`, +`ClientToolSpec`), and the matching TypeScript shape is `ResolvedToolSpec` in +`services/agent/src/protocol.ts`. A run bundles them as a `ResolvedToolSet`: the built-in +names, the list of specs, and one `ToolCallback` (the endpoint callback tools post back to). + +## How tools get resolved (the service side) + +Resolution is the service's job, but most of it now lives in the SDK. The service calls two +entrypoints in `services/oss/src/agent/app.py` (`_agent`): `resolve_tools(agent_config.tools)` +and `resolve_mcp_servers(agent_config.mcp_servers)`. Both are thin re-exports. The service +files under `services/oss/src/agent/tools/` are shims: +`resolver.py` re-exports the SDK's `resolve_tools` and adds the MCP gate; `gateway.py` and +`secrets.py` re-export the SDK platform adapters. The real composition is +`resolve_tools` in `sdks/python/agenta/sdk/agents/platform/resolve.py`, which builds a +`ToolResolver` (`sdks/python/agenta/sdk/agents/tools/resolver.py`) wired with two +Agenta-platform adapters: `AgentaNamedSecretProvider` for secrets and +`AgentaGatewayToolResolver` for gateway tools (both in +`sdks/python/agenta/sdk/agents/platform/`). The SDK owns the generic algorithm; the platform +adapters plug in the Agenta-specific HTTP calls. The SDK never imports the service. + +Resolution runs per type: + +- **Builtin** passes straight through. The name lands in `builtin_names`. No network call. +- **Code** has its declared `secrets` looked up by name. The named-secret provider resolves + them through `POST /secrets/resolve` (the platform adapter in + `sdks/python/agenta/sdk/agents/platform/secrets.py`, re-exported by + `services/oss/src/agent/tools/secrets.py`) and injects the values into the spec's `env`. The + script itself is not run here. +- **Client** passes through to a `ClientToolSpec`. There is nothing to resolve server-side. +- **Gateway** is the involved one. `AgentaGatewayToolResolver` + (`sdks/python/agenta/sdk/agents/platform/gateway.py`, re-exported by + `services/oss/src/agent/tools/gateway.py`) posts the references to the API's + `POST /tools/resolve`. The API (`api/oss/src/core/tools/service.py`, `resolve_agent_tools`) + validates that the named connection exists, is active, and is authenticated, then enriches + the tool from the Composio catalog with its real description and input schema. It returns a + `call_ref` slug of the form `tools.{provider}.{integration}.{action}.{connection}`. The + resolver wraps each one in a `CallbackToolSpec` and attaches a single `ToolCallback` whose + endpoint is the API's `POST /tools/call`. + +This is what "gateway tools are built at the service level" means in practice. The service +does the connection check and the catalog lookup up front, so a bad connection fails the +invoke immediately instead of failing the model mid-loop, and the agent only ever receives a +name, a schema, and an opaque slug. The Composio key and the connection's auth never leave the +service. + +MCP servers resolve on the same path but only when `AGENTA_AGENT_ENABLE_MCP` is truthy. The +gate lives in `resolve_mcp_servers` (`services/oss/src/agent/tools/resolver.py`): when the +flag is off it returns an empty list before the SDK `MCPResolver` ever runs. When on, the +`MCPResolver` injects each server's named secrets into its `env`, the same way code tools get +theirs. By default this is off, so `mcp_servers` is dropped at the service and `mcpServers` is +omitted from the wire. See the [status](#status-and-known-gaps) section: even with the flag on, +user MCP reaches Claude only, not the default Pi harness, so the field is a no-op in the common +case. + +The whole resolved bundle then rides the `/run` wire: built-in names in `tools`, resolved +specs in `customTools`, the callback in `toolCallback`, and resolved MCP servers in +`mcpServers`. + +## How tools get delivered (the harness fork) + +The runner has to hand resolved tools to a harness, and harnesses do not accept tools the same +way. The runner branches on a capability, `mcpTools`, not on the harness name (the branch is +`buildSessionMcpServers` in `services/agent/src/engines/sandbox_agent/mcp.ts`). A harness that +reports it can take tools over MCP gets them that way; a harness that cannot gets them +natively. Today that splits cleanly into two paths. + +- **Pi takes native tools.** Pi has an extension API, so the runner registers each resolved + spec as a Pi tool directly. In-process this is `buildCustomTools` in + `services/agent/src/engines/pi.ts`; over ACP it is the bundled Pi extension + (`services/agent/src/extensions/agenta.ts`), which reads the public specs from + `AGENTA_TOOL_PUBLIC_SPECS` and does the same registration from inside Pi. Either way Pi runs + the tool body the runner gives it. Pi gets no MCP server at all here: `buildSessionMcpServers` + returns an empty list for Pi, so neither the synthetic `agenta-tools` server nor any user + MCP server is attached. +- **Claude and other ACP harnesses take MCP.** They cannot accept a native tool, so the runner + exposes the same resolved specs as a small synthetic MCP server named `agenta-tools` + (`services/agent/src/tools/mcp-bridge.ts` launches `services/agent/src/tools/mcp-server.ts`). + This bridge is given only public metadata (names, descriptions, schemas) and a relay + directory. It never receives the `call_ref`, the code, the scoped secrets, or the callback + auth. When the model calls a tool, the bridge relays the request back to the runner, and the + runner runs the private spec from memory. This `agenta-tools` server is a tool DELIVERY + vehicle, not a user MCP server: it carries gateway and code tools, and it exists only on the + Claude path. + +Both paths funnel execution through one function, `runResolvedTool` in +`services/agent/src/tools/dispatch.ts`. It is the single place that branches on `kind`, so how +a tool type executes is defined once, not three times. + +## Execution, type by type + +This is the heart of the page. For each tool type, the question is the same: when the model +picks the tool and supplies the arguments, who actually runs it, and where? + +### Gateway tools: the harness calls back to the service + +Execution is a callback. The harness selects the tool and supplies arguments, but the runner +does not run the integration. The tool body POSTs the call to Agenta's `POST /tools/call` +(`services/agent/src/tools/callback.ts`, `callAgentaTool`), sending the `call_ref` slug and +the model's arguments in an OpenAI-style envelope. The API re-resolves the connection, runs the +Composio action through the provider adapter (`execute_tool` in `core/tools/service.py`), and +returns the result, which the runner hands back to the model verbatim. + +So the split is clean: **the harness decides which tool and with what arguments; the service +runs it.** This is the central safety property of the whole tool system. The Composio key and +the connection's auth stay on the service. The agent, the sandbox, and the harness never hold +a credential. They only ever ask Agenta to run a named, pre-validated action. + +There is one transport wrinkle. On Daytona the in-sandbox process cannot reach Agenta over the +network. So the call is relayed through files instead: the in-sandbox tool writes a request +file to a relay directory, the runner (which can reach Agenta) reads it, performs the same +`/tools/call` POST, and writes the answer back (`relayToolCall` in `dispatch.ts`, +`startToolRelay` in `tools/relay.ts`). Same callback, same envelope, different delivery. The +non-Pi MCP bridge uses this same relay even on local runs, because the bridge runs in a +separate process that the runner keeps blind to the private spec. + +### Code tools: the runner runs them locally + +Execution is a local subprocess inside the runner. `runCodeTool` +(`services/agent/src/tools/code.ts`) writes the snippet to a temp file, spawns `python3` or +`node`, passes the model's arguments as JSON on stdin, and reads the JSON result from stdout. +There is no callback. The code runs where the harness runs. + +This is the mirror image of a gateway tool. A gateway tool keeps every secret out of the +sandbox and runs remotely. A code tool needs its secrets *in* the sandbox, so the runner +injects them, but tightly. The child process gets a minimal environment allowlist (`PATH`, +`HOME`, locale, temp dirs) plus only the tool's own declared, resolved secrets. It does not +inherit provider keys, `AGENTA_*` config, or Composio and Daytona variables (`buildChildEnv` +in `code.ts`). The snippet defines a `main` function; Python is called as `main(**inputs)` and +Node as `main(inputs)`. A non-zero exit or a timeout becomes a tool error so the model loop +continues rather than crashing the run. + +The production image ships the interpreters: the runner Dockerfile installs `python3` +(`services/agent/docker/Dockerfile`), and `node` is already present. An earlier missing +`python3` made Python code tools fail with `spawn python3 ENOENT`; that is fixed. One real +constraint remains: the child only has the interpreter and the tool's own secrets, with no +package-install step and no `NODE_PATH` to the runner's modules. So a code tool is limited to +the language standard library. Glue code works; anything that needs a third-party package does +not, until a provisioning story exists. + +### Client tools: the browser fulfils them across a turn boundary + +Execution happens in the browser, not in the runner at all. A client tool is never run +in-sandbox; `runResolvedTool` throws if one is ever dispatched there, and the MCP bridge filters +client tools out of its advertised list. Instead, when the harness calls a client tool, the +runner emits an `interaction_request` event of kind `client_tool`. The `/messages` egress +projects it to a browser component, the browser runs it, and the result returns in the next +`/messages` turn, matched back by id. This is the cross-turn human-in-the-loop path, the same +mechanism approvals use. A client tool is the right type whenever only the user's environment +can answer: their location, a file on their machine, a confirmation only they can give. + +### Built-in tools: the harness runs them natively + +Execution is the harness's own. A built-in tool is just a name. The runner adds it to the +session's allowlist and Pi runs its own implementation of `read`, `write`, `web_search`, and so +on. Nothing is resolved and nothing is delivered. Note that built-ins are a Pi concept here; +they are not delivered to non-Pi harnesses over ACP, which bring their own native tool set. + +### MCP servers: a server process the daemon launches + +Execution happens in a separate server process. A declared MCP server is resolved server-side +(secrets injected into its `env`) and, for MCP-capable harnesses, passed to the ACP daemon as a +stdio server (`toAcpMcpServers` in `services/agent/src/engines/sandbox_agent/mcp.ts`). The +daemon launches the server's `command` with the resolved `env`, and the harness talks to it +over the MCP protocol. + +In practice user MCP is dead on the default path, and for two reasons that stack. First, +resolution is gated behind `AGENTA_AGENT_ENABLE_MCP`, which is off by default, so the servers +never reach the wire. Second, even with the flag on, `buildSessionMcpServers` drops user MCP +for Pi (Pi's ACP adapter does not forward them), so it would reach Claude only. Pi and Agenta +are the default harnesses, so the `mcp_servers` field is accepted and then silently ignored in +the common case. This is the silent-drop that the +[harness-capabilities project](../../projects/harness-capabilities/proposal.md) is built to fix +(fail loud, or deliver MCP on Pi through the extension). The +[removal-and-capability notes](../../scratch/notes-tools-mcp-capabilities.md) lay out the two +options. + +## Approval and rendering + +These are the other two axes, and they ride alongside execution rather than changing where it +happens. + +**`needs_approval`** gates a call on a human answer. Only permission-gating harnesses honor it. +Claude over ACP raises a permission request, which the runner surfaces as an +`interaction_request` of kind `permission` and answers through a `PolicyResponder` +(`services/agent/src/responder.ts`). With no human at the keyboard, the policy auto-approves by +default because the tools are backend-resolved and trusted, and a per-run policy or env +override can flip it to deny. Pi has no permission concept, so the flag is a no-op there. + +**`render`** is a generative-UI hint. The runner does not act on it; it copies the hint from the +spec onto the `tool_call` and `tool_result` events so the egress can project it to the frontend +without a spec lookup. The hint can name a prebuilt component, ship rendered source, or carry a +declarative UI spec (`RenderHint` in `protocol.ts`). + +## The whole picture + +| Tool type | Resolves to | Who executes | Where | Secret handling | +| --- | --- | --- | --- | --- | +| Built-in | a name | the harness | in the harness | none | +| Gateway | `callback` spec + `call_ref` | the Agenta service | back at the service (`/tools/call`), relayed via files on Daytona | key and connection auth stay server-side | +| Code | `code` spec + `env` | the runner | a local subprocess | only the tool's own secrets, scoped to the child | +| Client | `client` spec | the browser | the user's browser, next turn | none | +| MCP | resolved server + `env` | a server process | a stdio child the daemon launches | secrets injected into the server env | + +## Where this lives + +| Concern | File | +| --- | --- | +| Declared tool configs | `sdks/python/agenta/sdk/agents/tools/models.py` | +| Resolved tool specs | `sdks/python/agenta/sdk/agents/tools/models.py` (`ResolvedToolSet`) | +| MCP config | `sdks/python/agenta/sdk/agents/mcp/models.py` | +| SDK resolution algorithm | `sdks/python/agenta/sdk/agents/tools/resolver.py` | +| SDK platform composition (`resolve_tools`/`resolve_mcp`) | `sdks/python/agenta/sdk/agents/platform/resolve.py` | +| Service entrypoints (shims + MCP gate) | `services/oss/src/agent/tools/resolver.py`, `__init__.py` | +| Gateway resolver (calls `/tools/resolve`) | `sdks/python/agenta/sdk/agents/platform/gateway.py` (shim: `services/oss/src/agent/tools/gateway.py`) | +| Named-secret resolution (`/secrets/resolve`) | `sdks/python/agenta/sdk/agents/platform/secrets.py` (shim: `services/oss/src/agent/tools/secrets.py`) | +| API resolve + execute | `api/oss/src/core/tools/service.py`, `api/oss/src/apis/fastapi/tools/router.py` | +| Wire contract | `services/agent/src/protocol.ts`, `sdks/python/agenta/sdk/agents/utils/wire.py` | +| Tool-delivery fork (branch on `mcpTools`) | `services/agent/src/engines/sandbox_agent/mcp.ts` | +| Runtime dispatch (branch on `kind`) | `services/agent/src/tools/dispatch.ts` | +| Callback transport | `services/agent/src/tools/callback.ts` | +| Code execution | `services/agent/src/tools/code.ts` | +| Daytona/non-Pi relay | `services/agent/src/tools/relay.ts` | +| Pi native delivery | `services/agent/src/engines/pi.ts`, `services/agent/src/extensions/agenta.ts` | +| `agenta-tools` server for non-Pi harnesses | `services/agent/src/tools/mcp-bridge.ts`, `services/agent/src/tools/mcp-server.ts` | +| Capability probe | `services/agent/src/engines/sandbox_agent/capabilities.ts` | +| Permission policy | `services/agent/src/responder.ts` | + +## Status and known gaps + +- **User MCP is effectively dead on the default path.** Resolution is off unless + `AGENTA_AGENT_ENABLE_MCP` is truthy, and even on, the runner drops user MCP for Pi. Pi and + Agenta are the default harnesses, so `mcp_servers` is a silent no-op for most runs. It would + reach Claude only. Do not confuse this with the `agenta-tools` server, which is an internal + tool-delivery vehicle for Claude, not a user MCP server. +- `needs_approval` is honored only by permission-gating harnesses (Claude over ACP). It is a + no-op on Pi. +- Gateway tools support only the `composio` provider today; other providers raise. +- The `render` hint is plumbed end to end on the runner side, but full frontend projection of + every render kind is still in progress. +- Gateway calls on Daytona depend on the file relay, because the sandbox cannot reach Agenta + directly. The relay is also used by the non-Pi MCP bridge on local runs. +- **Code tools are standard-library-only.** The image ships `python3` and `node`, but the + child env has no package install and no module path to the runner's dependencies, so a tool + cannot import third-party packages. +- **Harness capabilities are probed but not consumed.** The runner probes `HarnessCapabilities` + per run (`engines/sandbox_agent/capabilities.ts`), uses them only for the internal `mcpTools` + delivery branch, and returns them on the `/run` result. The result field is parsed into + `AgentResult.capabilities` and then read by nobody: no `/inspect` surface, no frontend gate, + no service check. `/health` advertises `engines` and `harnesses` but no capabilities. The + [harness-capabilities proposal](../../projects/harness-capabilities/proposal.md) is the plan + to make this a real, consumed contract. + + diff --git a/docs/design/agent-workflows/triggers.md b/docs/design/agent-workflows/documentation/triggers.md similarity index 100% rename from docs/design/agent-workflows/triggers.md rename to docs/design/agent-workflows/documentation/triggers.md diff --git a/docs/design/agent-workflows/qa/README.md b/docs/design/agent-workflows/projects/qa/README.md similarity index 100% rename from docs/design/agent-workflows/qa/README.md rename to docs/design/agent-workflows/projects/qa/README.md diff --git a/docs/design/agent-workflows/qa/cleanup-plan.md b/docs/design/agent-workflows/projects/qa/cleanup-plan.md similarity index 100% rename from docs/design/agent-workflows/qa/cleanup-plan.md rename to docs/design/agent-workflows/projects/qa/cleanup-plan.md diff --git a/docs/design/agent-workflows/qa/findings.md b/docs/design/agent-workflows/projects/qa/findings.md similarity index 83% rename from docs/design/agent-workflows/qa/findings.md rename to docs/design/agent-workflows/projects/qa/findings.md index 9dd4420dcd..3756f67620 100644 --- a/docs/design/agent-workflows/qa/findings.md +++ b/docs/design/agent-workflows/projects/qa/findings.md @@ -77,7 +77,12 @@ content, if that is still true). Keep the edit narrow and code-backed. ### F-003 No author-facing way to add a custom skill (with or without code) -**Status:** open +**Status:** resolved (2026-06-24, skills-config). The neutral config now carries an +author-supplied `SkillConfig` in the `skills` field, inline or via `@ag.embed`; the runner +materializes it for Pi. Verified live by the skill-invocation scenario (see `matrix.md`, +"Live run results — skill invocation"): the `weather-oracle` skill, supplied by the author +both inline and by embed reference, was surfaced and invoked (token `SKILL-LOADED-7Q42-OK`). +Residuals tracked separately: F-014 (embed reference shape), F-015 (silent drop on Claude). **Severity:** major **Triage:** escalate (product surface: needs a config field and a delivery decision) **Added:** 2026-06-20 @@ -402,6 +407,83 @@ wrongly implied the issue was Pi-specific when the issue was in the shared runne uses `AGENTA_AGENT_RUNNER_URL` for the service-to-runner URL. Runner provider settings moved to `SANDBOX_AGENT_*` env vars on the runner service. +### F-014 Skill embed via `workflow_revision` bare slug 500s; reference at the artifact level + +**Status:** resolved (2026-06-24). Fixed by referencing skills at the artifact level +(`@ag.references{workflow.slug}`, latest revision) in the seeded default config and the +proposal docs; a no-version bare-slug fallback in the shared embed resolver is the deferred +option (logged, not done — to avoid blast radius on shared embed resolution). +**Severity:** major (the seeded default skill never loaded; documented pattern was broken) +**Triage:** fix-now (done) + defer (optional resolver fallback) +**Added:** 2026-06-24 +**Commit:** 670491fee0 (branch `gitbutler/workspace`) +**Found in:** E2 sandbox-agent local, harness `agenta`, capability skill invocation (embed +variant), trigger `What's the weather like today?` +**Source:** live E2E run, `skills-config/build-notes.md`; root-caused in +`api/oss/src/core/embeds/utils.py` (`_resolve_revision_with_normalization`) and +`services/oss/src/agent/schemas.py` (the seeded `_DEFAULT_AGENT_CONFIG`) + +**The problem.** Embedding a skill with a `workflow_revision` reference that carries a bare +artifact slug and no version returns HTTP 500 deterministically (~0.02s, not the LLM): + +```text +oss.src.core.embeds.exceptions.EmbedNotFoundError: Referenced entity not found: + Workflow revision not found: version=None slug='weather-oracle-e2e' id=None +``` + +A `workflow_revision` slug is matched against the revision's **own** slug, which is a content +hash (`6ab8cf001ea2`), not the author-facing artifact slug (`weather-oracle-e2e`). +`_resolve_revision_with_normalization` only normalizes a slug to a revision when a `version` +is also supplied (both normalization branches require `ref.version`). With a bare `{slug}` and +no version, nothing matches and it raises `EmbedNotFoundError`, surfaced as a 500 from +`/api/workflows/revisions/resolve`. + +**Why it matters.** The seeded `_DEFAULT_AGENT_CONFIG` referenced its default skill via +`{"workflow_revision": {"slug": "agenta-getting-started"}}`, the exact broken shape, so the +default agent's forced skill never loaded (confirmed live, HTTP 500). The proposal documented +the same no-version pattern. The artifact-level reference resolves cleanly: +`@ag.references{workflow.slug}` resolves to the latest revision and the token appeared in the +reply (Test 2, embed variant — PASS). + +**Repro.** `POST /services/agent/v0/invoke` with a skill embed +`{"@ag.embed":{"@ag.references":{"workflow_revision":{"slug":"weather-oracle-e2e"}},"@ag.selector":{"path":"parameters.skill"}}}` +(no version) returns 500. The same embed with `{"workflow":{"slug":"weather-oracle-e2e"}}` +returns 200 and the skill loads. Payloads: `req_test2_embed.json` (fails), +`req_test2_default.json` (seeded default, fails), `req_test2_artifact.json` (passes). + +**What was done.** Referenced skills at the artifact level in the seeded default and docs; +version pinning stays available via `{"workflow_revision": {"slug", "version"}}`. Deferred: an +optional no-version bare-slug to latest-revision fallback in the shared embed resolver, left +out to avoid changing shared embed resolution for a case the artifact-level reference already +covers. + +### F-015 Claude harness drops skills silently (no warning) on the non-Pi path + +**Status:** resolved (2026-06-24, warning added at the adapter boundary). Was: the drop +happened with no log line at all. +**Severity:** minor (observability; the drop itself is by design) +**Triage:** fix-now (done) +**Added:** 2026-06-24 +**Commit:** 670491fee0 (branch `gitbutler/workspace`) +**Found in:** E2 sandbox-agent local, harness `claude`, capability skill invocation +**Source:** `services/agent/src/engines/sandbox_agent/run-plan.ts:165` +(`const { skills } = isPi ? resolveSkillDirs(...) : { skills: [], cleanup: noop }`); confirmed +live by timestamps (the Claude run carried no `[sandbox-agent] skills:` log line) + +**The problem.** The runner materializes skills only for Pi. For a non-Pi acpAgent (Claude), +skills are dropped by design — the Claude SDK path cannot load a `SKILL.md`. That part is +correct. But the drop happened with **no warning logged**, so a user who configures skills and +selects Claude gets a silent no-op (the same silent-drop class as F-001/F-007/F-012). The +Claude run here also failed at session creation on a missing `anthropic` provider key (no +`anthropic` key in the resolving project), so the token would be absent regardless, but the +missing warning is the real gap. + +**Why it matters.** Skills configured on a Claude agent vanish with no signal. The proposal +already calls for the Claude adapter to log-and-drop; live, only the drop happened. + +**What was done.** A visible warning is emitted at the adapter boundary when skills are dropped +on a non-Pi harness. + ## How to add a finding during a run Copy the F-001 block, bump the id, and fill every field. Required: the environment, harness, diff --git a/docs/design/agent-workflows/qa/implementation-plan.md b/docs/design/agent-workflows/projects/qa/implementation-plan.md similarity index 100% rename from docs/design/agent-workflows/qa/implementation-plan.md rename to docs/design/agent-workflows/projects/qa/implementation-plan.md diff --git a/docs/design/agent-workflows/qa/matrix.md b/docs/design/agent-workflows/projects/qa/matrix.md similarity index 74% rename from docs/design/agent-workflows/qa/matrix.md rename to docs/design/agent-workflows/projects/qa/matrix.md index 5eed8abc01..a99d267bac 100644 --- a/docs/design/agent-workflows/qa/matrix.md +++ b/docs/design/agent-workflows/projects/qa/matrix.md @@ -36,7 +36,10 @@ the full product is much smaller than it looks. `AgentaAgentConfig`. A plain `pi` run does not load skills, and Claude has no skill concept here. So skill cells are `valid` on `agenta` and `n/a` on `pi` and `claude`. Confirm this during the run: if a plain `pi` run can be made to load a skill, that is a - finding, not an assumption. + finding, not an assumption. As of 2026-06-24 the `skills` field carries an author-supplied + `SkillConfig` (inline or `@ag.embed`), so F-003 is unblocked: skills are no longer + forced-only. On Claude the runner drops them by design (it materializes skills for Pi only) + and now logs a warning when it does (F-015, resolved 2026-06-24). 5. **MCP is delivered to non-Pi harnesses only, and is flag-gated.** Per `ground-truth.md` MCP delivery exists through the stdio bridge for non-Pi harnesses, and in-process Pi reports `mcpTools: false`. So MCP is `valid` on `claude` (sandbox-agent) and `n/a` or @@ -74,6 +77,7 @@ this QA program must drive. `?` means status unknown until run. | MCP (stdio) | n/a? verify | n/a? verify | blocked:mcp-flag + stdio-server + anthropic-key | | skills without code | n/a | valid (forced) | n/a | | skills with code | n/a | valid | n/a | +| skill invocation (author config) | n/a | valid (inline + embed) | dropped by design (warns; F-015 resolved) | | client tools | n/a on /invoke | n/a on /invoke | n/a on /invoke | ### Valid cell x environment (where each valid capability should run) @@ -86,6 +90,7 @@ this QA program must drive. `?` means status unknown until run. | builtin bash / pi | valid | valid | valid | valid | | skill no-code / agenta | valid | valid | valid | valid | | skill with-code / agenta | valid | valid | valid | valid | +| skill invocation / agenta | valid | valid | materializes; run blocked:daytona-model-auth | valid | | gateway tool / pi | blocked:composio | blocked:composio | blocked:composio | blocked:composio | | MCP / claude | n/a | blocked (key+flag+server) | blocked (key+flag+server) | blocked (key+flag+server) | | append_system / pi | valid | known-fail (F-001) | known-fail (F-001) | valid | @@ -253,6 +258,52 @@ Scenario Outline: the agenta harness runs a skill that ships a script # pass proves execution, not a lucky paraphrase. ``` +### Skill invocation (author-configured skill, F-003 unblocked) + +This is the canonical skill-config test: an author-supplied skill is delivered to Pi, surfaced +by its description, and actually invoked. It supersedes the "skills are forced-only" caveat +(F-003) now that the `skills` field carries inline or embedded `SkillConfig`. Two variants: +(a) an inline `SkillConfig`; (b) an `@ag.embed` reference to an `is_skill` workflow. + +```gherkin +Scenario Outline: an author-configured skill is surfaced and invoked + Given an agent with harness agenta on environment + And a skill named "weather-oracle" + description "Use this whenever the user asks about the weather or the forecast." + body "Begin your reply with the exact token SKILL-LOADED-7Q42-OK, then say the + weather is always made of cheese." + And the skill is supplied in parameters.agent.skills + When I send "What's the weather like today?" + Then the reply contains "SKILL-LOADED-7Q42-OK" + And the runner log shows "[sandbox-agent] skills: weather-oracle" + + Examples: + | env | how | + | E1 | inline SkillConfig | + | E2 | inline SkillConfig | + | E2 | embed @ag.embed{@ag.references{workflow.slug=}, @ag.selector{path: parameters.skill}} | +# The token is unguessable, so a pass proves the skill was both surfaced (the description +# matched the message) AND invoked (the body's instruction was followed). The negative control +# below is REQUIRED, not optional. + +Scenario: negative control — no skill, no token + Given the same agent with parameters.agent.skills = [] + When I send "What's the weather like today?" + Then the reply does NOT contain "SKILL-LOADED-7Q42-OK" +# Proves the token comes from the skill, not coincidence. Verified live: the no-skills reply +# asks for the user's location instead. +``` + +How to run it. `POST /services/agent/v0/invoke?project_id=` with +`Authorization: ApiKey ...`, harness `agenta` (it forces `read`+`bash`, which is what makes Pi +surface the skill), and the skill in `parameters.agent.skills`. For the inline variant, drop +the whole `SkillConfig` in `skills[0]`. For the embed variant, first create an `is_skill` +workflow (`POST /api/simple/workflows/` with `flags.is_skill=true` and the `SkillConfig` at +`data.parameters.skill`), then reference it at the **artifact** level +(`@ag.references{workflow.slug}`), not `workflow_revision` with a bare slug (F-014). Saved +payloads: `req_test1_inline.json`, `req_test1_negctl.json`, `req_test2_artifact.json` in the +skills E2E evidence scratchpad. + ### Client tools (via /messages) ```gherkin @@ -341,3 +392,28 @@ also pass natively in-process (python3 is present in that path). Pending: E4 (local SDK script) and the gated cells (Claude, MCP, gateway) once their preconditions are met. + +## Live run results — skill invocation (2026-06-24) + +Run against `localhost:8280`, hotel-agent project (the API key's bound project), harness +`agenta`, skill `weather-oracle`, trigger `What's the weather like today?`, PASS = reply +contains `SKILL-LOADED-7Q42-OK`. This unblocks the F-003 "no author-facing skill config" gap: +the `skills` field now carries inline or embedded `SkillConfig`. Payloads in the skills E2E +evidence scratchpad (`req_test1_*.json`, `req_test2_*.json`). + +| Variant / harness | E2 sandbox-agent local | E3 Daytona | Notes | +| --- | --- | --- | --- | +| inline skill / agenta | pass | n/t | reply began `SKILL-LOADED-7Q42-OK`; runner log `skills: weather-oracle` | +| inline skill negative control / agenta | pass | n/t | no skills → token absent (reply asks for location) | +| embed skill (`workflow.slug`) / agenta | pass | n/t | `is_skill` workflow resolves server-side → token present; artifact-level ref | +| embed skill (`workflow_revision.slug`) / agenta | fail (F-014) | n/t | bare slug, no version → HTTP 500 `EmbedNotFoundError`; hit the seeded default skill | +| skill materialization / agenta | n/a | pass | `skills: weather-oracle`, `sandbox=daytona`; skill uploaded into the Daytona sandbox | +| skill model run / agenta | n/a | blocked:daytona-model-auth | provider key not wired into the Daytona ACP daemon; pre-existing gap, not a skills bug | +| skill / claude | dropped (warns, F-015 resolved) | n/t | runner materializes skills for Pi only; Claude run also blocked:anthropic-key | + +`n/t` = not tested. The Daytona model-auth blocker is the same pre-existing gap covered in +`provider-model-auth/` and `scratch/notes-model-auth.md` (no QA finding owns it; it is an +environment precondition, like `blocked:anthropic-key`, not a skills defect). Skill behavior is +correct up to that boundary: the skill materializes into the Daytona sandbox; only the model +turn cannot run. For Claude the drop is by design (the SDK path can't load `SKILL.md`); the +silent-drop observability gap is F-015. diff --git a/docs/design/agent-workflows/qa/regression-skill-DRAFT.md b/docs/design/agent-workflows/projects/qa/regression-skill-DRAFT.md similarity index 100% rename from docs/design/agent-workflows/qa/regression-skill-DRAFT.md rename to docs/design/agent-workflows/projects/qa/regression-skill-DRAFT.md diff --git a/docs/design/agent-workflows/qa/regression-testing-research.md b/docs/design/agent-workflows/projects/qa/regression-testing-research.md similarity index 100% rename from docs/design/agent-workflows/qa/regression-testing-research.md rename to docs/design/agent-workflows/projects/qa/regression-testing-research.md diff --git a/docs/design/agent-workflows/qa/runs/E1__append_system_pi.json b/docs/design/agent-workflows/projects/qa/runs/E1__append_system_pi.json similarity index 100% rename from docs/design/agent-workflows/qa/runs/E1__append_system_pi.json rename to docs/design/agent-workflows/projects/qa/runs/E1__append_system_pi.json diff --git a/docs/design/agent-workflows/qa/runs/E1__builtin_bash_agenta.json b/docs/design/agent-workflows/projects/qa/runs/E1__builtin_bash_agenta.json similarity index 100% rename from docs/design/agent-workflows/qa/runs/E1__builtin_bash_agenta.json rename to docs/design/agent-workflows/projects/qa/runs/E1__builtin_bash_agenta.json diff --git a/docs/design/agent-workflows/qa/runs/E1__builtin_bash_pi.json b/docs/design/agent-workflows/projects/qa/runs/E1__builtin_bash_pi.json similarity index 100% rename from docs/design/agent-workflows/qa/runs/E1__builtin_bash_pi.json rename to docs/design/agent-workflows/projects/qa/runs/E1__builtin_bash_pi.json diff --git a/docs/design/agent-workflows/qa/runs/E1__code_tool_agenta.json b/docs/design/agent-workflows/projects/qa/runs/E1__code_tool_agenta.json similarity index 100% rename from docs/design/agent-workflows/qa/runs/E1__code_tool_agenta.json rename to docs/design/agent-workflows/projects/qa/runs/E1__code_tool_agenta.json diff --git a/docs/design/agent-workflows/qa/runs/E1__code_tool_pi.json b/docs/design/agent-workflows/projects/qa/runs/E1__code_tool_pi.json similarity index 100% rename from docs/design/agent-workflows/qa/runs/E1__code_tool_pi.json rename to docs/design/agent-workflows/projects/qa/runs/E1__code_tool_pi.json diff --git a/docs/design/agent-workflows/qa/runs/E1__smoke_chat_agenta.json b/docs/design/agent-workflows/projects/qa/runs/E1__smoke_chat_agenta.json similarity index 100% rename from docs/design/agent-workflows/qa/runs/E1__smoke_chat_agenta.json rename to docs/design/agent-workflows/projects/qa/runs/E1__smoke_chat_agenta.json diff --git a/docs/design/agent-workflows/qa/runs/E1__smoke_chat_pi.json b/docs/design/agent-workflows/projects/qa/runs/E1__smoke_chat_pi.json similarity index 100% rename from docs/design/agent-workflows/qa/runs/E1__smoke_chat_pi.json rename to docs/design/agent-workflows/projects/qa/runs/E1__smoke_chat_pi.json diff --git a/docs/design/agent-workflows/qa/runs/E2__append_system_pi.json b/docs/design/agent-workflows/projects/qa/runs/E2__append_system_pi.json similarity index 100% rename from docs/design/agent-workflows/qa/runs/E2__append_system_pi.json rename to docs/design/agent-workflows/projects/qa/runs/E2__append_system_pi.json diff --git a/docs/design/agent-workflows/qa/runs/E2__builtin_bash_agenta.json b/docs/design/agent-workflows/projects/qa/runs/E2__builtin_bash_agenta.json similarity index 100% rename from docs/design/agent-workflows/qa/runs/E2__builtin_bash_agenta.json rename to docs/design/agent-workflows/projects/qa/runs/E2__builtin_bash_agenta.json diff --git a/docs/design/agent-workflows/qa/runs/E2__builtin_bash_pi.json b/docs/design/agent-workflows/projects/qa/runs/E2__builtin_bash_pi.json similarity index 100% rename from docs/design/agent-workflows/qa/runs/E2__builtin_bash_pi.json rename to docs/design/agent-workflows/projects/qa/runs/E2__builtin_bash_pi.json diff --git a/docs/design/agent-workflows/qa/runs/E2__claude_code_tool.json b/docs/design/agent-workflows/projects/qa/runs/E2__claude_code_tool.json similarity index 100% rename from docs/design/agent-workflows/qa/runs/E2__claude_code_tool.json rename to docs/design/agent-workflows/projects/qa/runs/E2__claude_code_tool.json diff --git a/docs/design/agent-workflows/qa/runs/E2__claude_smoke.json b/docs/design/agent-workflows/projects/qa/runs/E2__claude_smoke.json similarity index 100% rename from docs/design/agent-workflows/qa/runs/E2__claude_smoke.json rename to docs/design/agent-workflows/projects/qa/runs/E2__claude_smoke.json diff --git a/docs/design/agent-workflows/qa/runs/E2__code_tool_agenta.json b/docs/design/agent-workflows/projects/qa/runs/E2__code_tool_agenta.json similarity index 100% rename from docs/design/agent-workflows/qa/runs/E2__code_tool_agenta.json rename to docs/design/agent-workflows/projects/qa/runs/E2__code_tool_agenta.json diff --git a/docs/design/agent-workflows/qa/runs/E2__code_tool_pi.json b/docs/design/agent-workflows/projects/qa/runs/E2__code_tool_pi.json similarity index 100% rename from docs/design/agent-workflows/qa/runs/E2__code_tool_pi.json rename to docs/design/agent-workflows/projects/qa/runs/E2__code_tool_pi.json diff --git a/docs/design/agent-workflows/qa/runs/E2__mcp_claude.json b/docs/design/agent-workflows/projects/qa/runs/E2__mcp_claude.json similarity index 100% rename from docs/design/agent-workflows/qa/runs/E2__mcp_claude.json rename to docs/design/agent-workflows/projects/qa/runs/E2__mcp_claude.json diff --git a/docs/design/agent-workflows/qa/runs/E2__smoke_chat_agenta.json b/docs/design/agent-workflows/projects/qa/runs/E2__smoke_chat_agenta.json similarity index 100% rename from docs/design/agent-workflows/qa/runs/E2__smoke_chat_agenta.json rename to docs/design/agent-workflows/projects/qa/runs/E2__smoke_chat_agenta.json diff --git a/docs/design/agent-workflows/qa/runs/E2__smoke_chat_pi.json b/docs/design/agent-workflows/projects/qa/runs/E2__smoke_chat_pi.json similarity index 100% rename from docs/design/agent-workflows/qa/runs/E2__smoke_chat_pi.json rename to docs/design/agent-workflows/projects/qa/runs/E2__smoke_chat_pi.json diff --git a/docs/design/agent-workflows/qa/runs/E3__builtin_bash_pi.json b/docs/design/agent-workflows/projects/qa/runs/E3__builtin_bash_pi.json similarity index 100% rename from docs/design/agent-workflows/qa/runs/E3__builtin_bash_pi.json rename to docs/design/agent-workflows/projects/qa/runs/E3__builtin_bash_pi.json diff --git a/docs/design/agent-workflows/qa/runs/E3__code_tool_agenta.json b/docs/design/agent-workflows/projects/qa/runs/E3__code_tool_agenta.json similarity index 100% rename from docs/design/agent-workflows/qa/runs/E3__code_tool_agenta.json rename to docs/design/agent-workflows/projects/qa/runs/E3__code_tool_agenta.json diff --git a/docs/design/agent-workflows/qa/runs/E3__code_tool_pi.json b/docs/design/agent-workflows/projects/qa/runs/E3__code_tool_pi.json similarity index 100% rename from docs/design/agent-workflows/qa/runs/E3__code_tool_pi.json rename to docs/design/agent-workflows/projects/qa/runs/E3__code_tool_pi.json diff --git a/docs/design/agent-workflows/qa/runs/E3__smoke_chat_pi.json b/docs/design/agent-workflows/projects/qa/runs/E3__smoke_chat_pi.json similarity index 100% rename from docs/design/agent-workflows/qa/runs/E3__smoke_chat_pi.json rename to docs/design/agent-workflows/projects/qa/runs/E3__smoke_chat_pi.json diff --git a/docs/design/agent-workflows/qa/scripts/mcp_qa_server.mjs b/docs/design/agent-workflows/projects/qa/scripts/mcp_qa_server.mjs similarity index 100% rename from docs/design/agent-workflows/qa/scripts/mcp_qa_server.mjs rename to docs/design/agent-workflows/projects/qa/scripts/mcp_qa_server.mjs diff --git a/docs/design/agent-workflows/qa/scripts/run_matrix.py b/docs/design/agent-workflows/projects/qa/scripts/run_matrix.py similarity index 100% rename from docs/design/agent-workflows/qa/scripts/run_matrix.py rename to docs/design/agent-workflows/projects/qa/scripts/run_matrix.py diff --git a/docs/design/agent-workflows/projects/research/opencode-architecture.md b/docs/design/agent-workflows/projects/research/opencode-architecture.md new file mode 100644 index 0000000000..a5fbaa54af --- /dev/null +++ b/docs/design/agent-workflows/projects/research/opencode-architecture.md @@ -0,0 +1,709 @@ +# OpenCode Architecture + +This is a research note. It studies OpenCode's architecture and compares it to the agent +workflow we are building. The goal is to learn from a mature, independent design that solves +the same problem we are solving: run a coding agent behind an API, let many clients drive it, +and stream the run back. + +OpenCode is an open-source AI coding agent built by SST. It has a client-server shape. One +server exposes an HTTP API. Many clients connect over that API: a terminal UI, a desktop app, +a VS Code extension, and a web app. The server runs the agent loop, talks to model providers, +runs tools, and owns conversation state. The clients render. This is close to what we are +designing, so it is worth studying carefully. + +The source moved during this research. The repo is now +[`anomalyco/opencode`](https://github.com/anomalyco/opencode) on the `dev` branch, not +`sst/opencode`. The codebase is also mid-migration from a v1 model to a v2 model. The v1 model +matches the public docs and the DeepWiki summaries. The v2 model lives in a new `packages/core` +and a new `packages/server`, and it is a different and more interesting design. This note +covers the v2 model as the current direction, and flags where v1 still applies. Where the docs +were thin, the note reads the source directly and says so. + +## What the docs cover and what the code shows + +The published docs at [opencode.ai/docs](https://opencode.ai/docs) describe the v1 system: an +HTTP server with an SSE event stream, sessions, messages, a "parts" union, providers, tools, +agents, and modes. Most third-party summaries describe the same v1 shape. + +The `dev` branch tells a newer story. The team has rewritten the core onto +[Effect](https://effect.website) and an event-sourced session model. Sessions are now durable +event aggregates. Messages are projections built from those events. The new server lives in its +own package and is defined with a typed HTTP API DSL. This note treats that v2 code as the real +current design and notes the confidence level on each claim. + +## Services and packages + +OpenCode is a monorepo. The server is TypeScript on the Bun runtime. The terminal UI is Go. +Most other surfaces are TypeScript and SolidJS. The packages that matter for this comparison +are below. File counts come from the `dev` tree and just signal weight. + +| Package | Role | +| --- | --- | +| `packages/core` | The domain. Sessions, messages, events, tools, providers, agents, the agent loop, and the SQLite store. Built on Effect and Drizzle ORM. | +| `packages/server` | The HTTP API. Route groups, handlers, auth, CORS, middleware. Defined with Effect's `HttpApi` DSL, which also emits the OpenAPI spec. | +| `packages/sdk` | The generated TypeScript client. `js/src/gen` is generated from the OpenAPI JSON. There is a v1 client and a v2 client. | +| `packages/tui` | The terminal client, written in Go. It is a normal API client, not privileged. | +| `packages/app` | Shared SolidJS UI logic for the desktop and web surfaces, including the event reducer and session cache. | +| `packages/desktop` | The Electron desktop app. | +| `packages/llm` | Provider-facing types: tool content, provider metadata, message normalization for model APIs. | +| `packages/plugin` | The plugin interface and hook surface. | +| `packages/console`, `packages/enterprise` | Cloud control plane, sharing, billing, and hosted services (OpenCode Zen and Go). Out of scope here. | + +The shape to take away: one server process owns the agent and the state, and every client, +including OpenCode's own TUI, is just an API consumer. The server is the only thing that talks +to model providers and runs tools. This is stated in the architecture overview on +[DeepWiki](https://deepwiki.com/sst/opencode) and confirmed by the package layout in the repo. + +### What each part cares about + +- The **server** cares about the API contract and request handling. It is thin. Handlers call + into `core` services. Source: [`packages/server/src/groups`](https://github.com/anomalyco/opencode/tree/dev/packages/server/src/groups) + and [`handlers`](https://github.com/anomalyco/opencode/tree/dev/packages/server/src/handlers). +- The **core** cares about the agent loop, the session aggregate, the event log, and the + projections. This is where the real model lives. Source: + [`packages/core/src/session`](https://github.com/anomalyco/opencode/tree/dev/packages/core/src/session). +- The **clients** care about rendering the event stream and sending prompts. They hold no + authoritative state. They keep a local cache that the event stream keeps in sync. Source: + [`packages/app/src/context/global-sync`](https://github.com/anomalyco/opencode/tree/dev/packages/app/src/context/global-sync). +- The **SDK** cares about turning the OpenAPI spec into typed methods and an SSE subscription. + It is generated, not hand-written. + +### External dependencies + +- **Model providers.** The server integrates 75+ providers through the Vercel AI SDK and + `@ai-sdk/*` adapters, plus an OpenAI-compatible adapter for local models. Each provider has a + small plugin under [`packages/core/src/plugin/provider`](https://github.com/anomalyco/opencode/tree/dev/packages/core/src/plugin/provider). + Source: [providers doc](https://opencode.ai/docs/providers/). +- **Storage.** SQLite through Drizzle ORM, with write-ahead logging and a busy timeout. The + event log, the session rows, the message projections, and the part rows all live here. Source: + [session lifecycle on DeepWiki](https://deepwiki.com/sst/opencode/2.1-session-lifecycle-and-state) + and the migrations under + [`packages/core/src/database/migration`](https://github.com/anomalyco/opencode/tree/dev/packages/core/src/database/migration). +- **Auth.** OAuth, API keys, and well-known tokens per provider, set through + `PUT /auth/{providerID}`. The server can also gate itself with `OPENCODE_SERVER_PASSWORD`. + Source: [server doc](https://opencode.ai/docs/server/). +- **LSP.** An optional `lsp` tool and LSP client integration for code intelligence. Source: + [tools doc](https://opencode.ai/docs/tools/). +- **MCP servers.** External tool servers wired in through config. Source: + [tools doc](https://opencode.ai/docs/tools/). + +## Layers + +The system layers cleanly from the model up to the screen. + +1. **Provider layer.** Adapters that speak each model API and normalize messages before they go + out. Source: [`packages/llm`](https://github.com/anomalyco/opencode/tree/dev/packages/llm) + and the `ProviderTransform.normalizeMessages` step described on + [DeepWiki](https://deepwiki.com/sst/opencode). +2. **Core domain layer.** The session aggregate, the event log, the agent loop, the tool + registry, and the projections. This layer is provider-agnostic and transport-agnostic. +3. **Server layer.** The HTTP API and the SSE event stream. It exposes the domain over the + wire and emits the OpenAPI spec. +4. **SDK layer.** Generated typed clients over the API. +5. **Client layer.** The TUI, desktop, web, and editor extensions. They render the stream and + send prompts. +6. **Plugin layer.** A cross-cutting extension surface with hooks at well-defined points + (tool execution, permissions, file edits, session lifecycle). Source: + [plugins doc](https://opencode.ai/docs/plugins/). + +The key boundary is between core and everything else. Core does not know about HTTP. The server +does not know how the agent loop works. The clients do not know how the model is called. + +## The protocol + +The transport is HTTP plus Server-Sent Events. There is no websocket and no custom binary +protocol. The full API is published as an OpenAPI 3.1 spec at `/doc`, and the TypeScript SDK is +generated from it. Source: [server doc](https://opencode.ai/docs/server/) +and [OpenAPI spec on DeepWiki](https://deepwiki.com/sst/opencode/7.2-openapi-specification). + +The generator itself is in motion. The v1 SDK used `@hey-api/openapi-ts` over the published +OpenAPI JSON. The team is now replacing that with a private `@opencode-ai/httpapi-codegen` +compiler that "reflects Effect `HttpApi` contracts directly, without OpenAPI or Hey API" and can +"compile once into shared contract IR, then emit either a rich Effect client or a zero-Effect +Promise/fetch client." Source: +[PR #33445, `feat(sdk): add HttpApi client codegen`](https://github.com/anomalyco/opencode/pull/33445). +The destination is the same in both designs. The API contract is written once in code, and the +client is derived from it, so client types cannot drift from the server. The detail to note is +that they decided OpenAPI itself was an intermediate artifact they could drop, and went straight +from the typed API definition to the client. + +The v2 routes live under `/api`. The ones that matter for a session turn: + +| Method and path | Purpose | +| --- | --- | +| `POST /api/session` | Create a session. Returns `SessionV2.Info`. | +| `GET /api/session` | List sessions with cursor pagination. | +| `GET /api/session/:id` | Get one session. | +| `POST /api/session/:id/prompt` | Admit one prompt and schedule the agent loop. Returns an acknowledgment, not the answer. | +| `POST /api/session/:id/agent` | Switch the agent for later turns. | +| `POST /api/session/:id/model` | Switch the model for later turns. | +| `POST /api/session/:id/compact` | Compact the conversation. | +| `POST /api/session/:id/wait` | Block until the agent loop goes idle. | +| `GET /api/session/:id/message` | Page through the projected messages. | +| `GET /api/session/:id/context` | Get the active context messages (everything after the last compaction). | +| `GET /api/event` | Subscribe to the server event stream over SSE. | + +Source for the route shapes: +[`groups/session.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/server/src/groups/session.ts), +[`groups/message.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/server/src/groups/message.ts), +and [`groups/event.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/server/src/groups/event.ts). + +### How a client drives a turn + +This is the part worth internalizing. The prompt call does not return the assistant's answer. + +1. The client creates a session, or reuses an id, then opens one long-lived SSE connection to + `GET /api/event`. The stream opens with a `server.connected` event and then carries every + server event. +2. The client posts a prompt to `POST /api/session/:id/prompt`. The server **admits** the + prompt as a durable event and **schedules** the agent loop. It then returns a small + `SessionInput.Admitted` acknowledgment with a sequence number. The OpenAPI summary for this + route says it plainly: "Durably admit one session input and schedule agent-loop execution + unless resume is false." Source: + [`groups/session.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/server/src/groups/session.ts). +3. The agent loop runs on the server. As it runs, it publishes session events: step started, + text started, text deltas, text ended, tool input started, tool called, tool success, step + ended, and so on. These events flow out over the one SSE stream the client already holds. +4. The client renders by folding those events into its local message cache. When it needs the + settled transcript, it pages `GET /api/session/:id/message`, which returns projected + messages rebuilt from the same events. + +So the request that starts the turn and the stream that carries the turn are decoupled. The +prompt is a command. The output is an event stream. The transcript is a projection. The +[handler](https://github.com/anomalyco/opencode/blob/dev/packages/server/src/handlers/session.ts) +just calls `session.prompt(...)`, and the core +[`Session.prompt`](https://github.com/anomalyco/opencode/blob/dev/packages/core/src/session.ts) +admits the input and calls `execution.wake(sessionID)`. + +Why split admission from execution. OpenCode made this explicit in +[PR #30785, `refactor(core): make v2 session inputs event sourced`](https://github.com/anomalyco/opencode/pull/30785). +Before it, "an accepted prompt lived only in `session_input`" until it became model-visible, so +pending work "could not be reconstructed from synchronized Session history." The fix splits a +prompt into two durable facts: `PromptAdmitted` records "accepted intent" with its delivery mode, +and `PromptPromoted` (now folded into the existing `prompted` event, per +[PR #33443](https://github.com/anomalyco/opencode/pull/33443)) records when the prompt becomes +"model-visible history" at a safe runner boundary. That is the stated reason the POST returns an +acknowledgment rather than the answer. The accepted work is already a durable event the moment +the call returns, and the loop is scheduled separately. A client that drops can re-read the log +and see that its prompt was accepted, even before the agent has produced a token. + +### Steering and queueing + +The prompt payload carries a `delivery` field with two values, `steer` and `queue`, defaulting +to `steer`. Source: +[`session/input.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/core/src/session/input.ts). +A run coordinator serializes execution per session and lets a new prompt either interrupt the +in-flight turn (`steer`) or wait for it to finish (`queue`). The coordinator exposes `run`, +`wake`, and `interrupt`. Its own doc comment states the contract: it "serializes execution for +each key while allowing different keys to run concurrently." `run` "starts execution while idle or +joins the active execution," `wake` "registers one coalesced follow-up after newly recorded +work," and `interrupt` "stops active execution and waits for its cleanup." Source: +[`session/run-coordinator.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/core/src/session/run-coordinator.ts). +This is how a user types a follow-up mid-turn and the agent reacts to it without a second +connection. + +This feature has a long, telling history. The original problem was blunt: a prompt sent while the +session was busy "would be rejected with a `BusyError`," so "users couldn't send messages while +the agent was mid-task." +[PR #19156, `feat: queue pending prompts when session is busy`](https://github.com/anomalyco/opencode/pull/19156) +replaced the rejection with a queue and "injects [queued prompts] as user messages at the start +of each loop iteration, before loading message history." Steering then arrived as the second lane. +[PR #26199, `feat: Add server-owned Steer/Queue pending messages`](https://github.com/anomalyco/opencode/pull/26199) +made the pending state server-owned, "inspired by Codex," so that "the server owns pending state, +ordering, pause/resume, deletes, lane changes, and delivery." The stated reason for server +ownership is to prevent "inconsistent snapshots between clients and runtime status." Later work +([PR #33247](https://github.com/anomalyco/opencode/pull/33247), +[PR #33104](https://github.com/anomalyco/opencode/pull/33104)) added "mid-stream interrupts for +steer, allowing the AI to smoothly pause without wiping the turn," plus a "wrap" mode that lets +the agent "gracefully finish its current step/tool execution before halting for the queued +message." The lesson in this arc: steering is not a feature you bolt onto the transport at the +end. It started as an error and became a first-class, server-owned, event-sourced lane only after +the team had a durable session log to anchor it to. + +## Session, message, and parts model + +This is the section we care about most. OpenCode's v2 model is event-sourced. Read it as three +layers stacked on each other: events at the bottom, projected messages in the middle, the +session aggregate on top. + +### The session aggregate + +A session is identified by a branded id with a `ses_` prefix and a descending ULID, so newer +sessions sort first. A session belongs to one project and has an optional `parentID` for +sub-agent and forked conversations. The `Info` record carries title, optional active `agent`, +optional `model` reference, rolled-up `cost` and `tokens`, a `location` (directory and optional +workspace), and lifecycle timestamps. Source: +[`session/schema.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/core/src/session/schema.ts). + +The session is not a row that the loop mutates directly. It is the head of an event log keyed by +`sessionID`. Every meaningful thing that happens publishes a durable event against that +aggregate. + +### The event log + +Events are the source of truth. Each event has an `evt_` id with an ascending ULID, a `type`, a +`data` payload, an optional `location`, and, when durable, a `{ aggregateID, seq, version }` +block. Durable events get a monotonic `seq` per aggregate, which is what gives the log ordering +and replay. Source: +[`event.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/core/src/event.ts). + +Session events are namespaced `session.next.*`. The set includes prompt admission, agent and +model switches, step lifecycle, text lifecycle, reasoning lifecycle, tool lifecycle, shell, +synthetic and system context, retries, and compaction. Source: +[`session/event.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/core/src/session/event.ts). + +The streaming pattern inside the events is the clever part. Each content kind has a +`started` / `delta` / `ended` triad. The `delta` events are deliberately **not** durable. A +comment in the source says it directly: "Stream fragments are live-only; Text.Ended is the +replayable full-value boundary." So the deltas carry the live typing experience and never hit +the log, while the `ended` event carries the full settled value that replay and projection use. +The same split applies to reasoning and to tool input. Tool execution adds a `progress` event +for bounded mid-run checkpoints, with a comment warning tools not to persist every stdout chunk. +Source: +[`session/event.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/core/src/session/event.ts). + +### The projected messages + +Messages are not stored as the model writes them. They are projections rebuilt from the event +log by a projector, then written to a `MessageTable` and `PartTable` in SQLite. Source: +[`session/projector.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/core/src/session/projector.ts). + +The v2 message is a tagged union, discriminated by `type`. The variants are `user`, +`assistant`, `synthetic`, `system`, `shell`, `compaction`, `agent-switched`, and +`model-switched`. Source: +[`session/message.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/core/src/session/message.ts). + +The notable shift from v1: in v2 the assistant message does **not** hold a flat array of +sibling "parts." It holds a `content` array of `AssistantContent`, itself a tagged union of +`text`, `reasoning`, and `tool`. The assistant message also carries `agent`, `model`, optional +`snapshot` start and end markers, `finish`, `cost`, and a `tokens` breakdown that includes +cache read and write. Source: +[`session/message.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/core/src/session/message.ts). + +The tool content is a state machine, not a flat record. `ToolState` is a tagged union over +`status`: + +- `pending`: the call exists, only the raw input string is known. +- `running`: input is parsed, `structured` output and `content` are accumulating. +- `completed`: final `content`, `structured` output, `result`, and `outputPaths`. +- `error`: an error plus whatever `content` and `result` were produced. + +Source: +[`session/message.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/core/src/session/message.ts). + +So the lifecycle is consistent end to end. The event log emits `tool.input.started`, +`tool.input.delta`, `tool.input.ended`, `tool.called`, `tool.progress`, then `tool.success` or +`tool.failed`. The projector folds those into a single tool entry whose `state` walks +`pending → running → completed | error`. The client renders the same transition live from the +event stream and can reconcile against the projection. + +The user message carries a structured `Prompt`: `text`, optional `files`, and optional `agents`. +A `FileAttachment` has a uri, mime, optional name and description, and an optional source range. +An `AgentAttachment` is an `@`-mentioned subagent. Source: +[`session/prompt.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/core/src/session/prompt.ts). + +For completeness: the v1 model, which the public docs still describe, used a separate `Part` +union (`TextPart`, `ToolPart`, `FilePart`, `ReasoningPart`, `StepStartPart`, `StepFinishPart`, +`SnapshotPart`, `PatchPart`, `AgentPart`, `SubtaskPart`, `CompactionPart`, and more) hung off a +message `info` record. Source: +[message and part types on DeepWiki](https://deepwiki.com/sst/opencode). The v2 design absorbs +those concerns into events plus a smaller projected message. Confidence: the v1 part list is +from DeepWiki and the docs, not re-read from current source; the v2 model is read directly from +`packages/core`. + +### Agents and modes + +An agent in OpenCode is a named configuration: a model, a system prompt, a permission ruleset, a +mode, optional step cap, and provider request overrides. Source: +[`agent.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/core/src/agent.ts) and the +[agents doc](https://opencode.ai/docs/agents/). The default agent id is `build`. + +The `mode` field is `primary`, `subagent`, or `all`. Primary agents are the ones a user drives +directly, like `build` and `plan`. Subagents are spawned by a primary agent or `@`-mentioned by +the user, like `general`, `explore`, and `scout`. The session records its active agent, and the +client can switch it mid-conversation with `POST /api/session/:id/agent`, which the server +records as a `session.next.agent.switched` event. So "mode" is not a separate concept layered on +top of agents. It is a property of the agent, and the active agent is session state. Source: +[agents doc](https://opencode.ai/docs/agents/). + +Permissions live on the agent as a ruleset over tool categories (`read`, `edit`, `bash`, +`glob`, `grep`, `task`, and others) with values `allow`, `ask`, or `deny`, and glob patterns for +finer control. The `plan` agent ships with edits and bash set to `ask`. When a tool needs +approval, the server emits a permission event and waits. Source: +[agents doc](https://opencode.ai/docs/agents/) and +[tools doc](https://opencode.ai/docs/tools/). + +## Why v2: the rationale and the lessons + +This section is the point of the note. For each major v2 choice, it pins down the problem the +choice removed, separates OpenCode's stated reason from inference, and names the lesson for our +own design. A note on provenance first. The event-sourced core was built by jlongster (James +Long), the author of Actual Budget, who is known for putting event sourcing and CRDTs into a +shipping product and wrote the widely-read piece +[Using CRDTs in the Wild](https://archive.jlongster.com/using-crdts-in-the-wild). That pedigree +shows in the design. The first sync PR frames the model in exactly the terms an event-sourcing +practitioner would. This is context for the why, not a substitute for it. + +### The event-sourced session log + +**Stated reason.** The founding PR, +[#17814, `feat(core): initial implementation of syncing`](https://github.com/anomalyco/opencode/pull/17814), +says it directly: "This is a system inspired by event sourcing that tracks mutations of +session-related data through events." The design constraints are spelled out and are the key to +why it stays simple: "We don't need distributed clocks. We only support a single writer and many +readers. Events can be total ordered via a sequential integer, guaranteed to update atomically via +sqlite." The payoff is also stated: "After this PR I will add more routes for replaying these +events which will let you recreate sessions." A second PR, +[#30785](https://github.com/anomalyco/opencode/pull/30785), gives the sharper reason for pushing +even pending input into the log. Before it, accepted-but-not-yet-run prompts "could not be +reconstructed from synchronized Session history." + +**The v1 problem it removed.** In v1 the session was rows the loop mutated in place. State lived +in whatever happened to be written, so there was no single ordered record to replay, and a client +could not rebuild a session it had not watched live. Reconnection and multi-client sync had no +foundation to stand on. + +**Lesson for us.** The single-writer, many-reader shape is the whole reason event sourcing here is +cheap, not academic. One server process owns each session, so a per-session monotonic integer is +enough ordering. No vector clocks, no consensus. This matches our setup. Our service is the single +writer for a session. If we adopt server-owned history, an append-only event log with a per-session +`seq`, stored in our normal database, gives us replay and reconnection without distributed-systems +machinery. The constraint that makes it work is one we already satisfy. + +### Projecting messages from events, not storing a wire format + +**Stated reason.** The replay route promised in #17814 became the projector. The projector reads +the durable events and upserts message and part rows with `onConflictDoUpdate`, which the analysis +of the source confirms is "idempotent message/part insertion, enabling safe event replay." Token +and cost usage is applied with reversible signed arithmetic so a removed or edited part can be +backed out. The one load-bearing comment in the projector states a real invariant: "A newer turn +supersedes stale incomplete rows; never resume an older assistant projection." Source: +[`session/projector.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/core/src/session/projector.ts). + +**The v1 problem it removed.** v1 stored messages and a large `Part` union close to a client wire +format. That couples the stored shape to one renderer and to one moment in the schema's life. The +v2 split makes the events the truth and the message table a cache you can rebuild. When the message +shape changes, you re-project. You do not migrate stored transcripts. + +**Lesson for us.** This is the cleanest argument against our current convert-on-the-edge approach. +We take Vercel `UIMessage` in, run, and convert `AgentEvent` back to Vercel parts out. That bakes +one client's wire format into the round trip. If history becomes server-owned, store neutral events +as truth and project to Vercel, ACP, or AG-UI on read. The projection is a pure function of the +log, so it is safe to replay, safe to change, and the same log serves every egress format. The +idempotent-upsert and reversible-usage details are worth copying verbatim. They are what make +re-projection and edits safe. + +### The live-delta versus durable-`ended` boundary + +**Stated reason.** The split is documented in a source comment, not just inferred: "Stream +fragments are live-only; Text.Ended is the replayable full-value boundary." The tool-progress +comment is just as explicit about the cost it avoids: "Replayable bounded running-tool state. +Tools should checkpoint semantic transitions or at a bounded cadence, not persist every +stdout/stderr chunk." Source: +[`session/event.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/core/src/session/event.ts). + +**The problem it removes.** If every token delta were durable, the log would bloat in proportion to +output length, replay would get slow, and the disk would carry data no reader ever needs after the +turn settles. Persisting only the settled `ended` value keeps the log proportional to the number of +content segments, not the number of tokens. + +**Lesson for us.** We already emit start, delta, and end events. The missing discipline is on the +write path. Persist only the boundary, and treat deltas as live-only transport. We get smooth +streaming and a small replayable log at once. This is the lesson to take first, because it is a +rule about what to write, not a new subsystem. + +### The tool state machine + +**Stated reason.** None found. The `ToolState` tagged union over `status` +(`pending → running → completed | error`) carries no explaining comment, and no PR was found that +argues for it. Source: +[`session/message.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/core/src/session/message.ts). + +**Inference (marked as inference, not their stated reason).** The shape itself is the argument. Each +status carries exactly the fields valid in that state: `pending` has only the raw `input` string; +`running` adds parsed input, `structured` output, and accumulating `content`; `completed` adds +`result` and `outputPaths`; `error` swaps in an `error`. A flat record with all fields optional +would let illegal combinations typecheck, such as a `completed` call with no result, or a `pending` +call that somehow has output. Encoding the state in a discriminant makes those states +unrepresentable. The same union appears in the events, in the projected message, and on the client, +so all three agree on "what state is this call in" by construction. This is the standard reason to +prefer a tagged union over optional fields, and it is consistent with the rest of this codebase, +which leans on tagged unions everywhere. We are confident in the benefit; we just did not find +OpenCode stating it. + +**Lesson for us.** Model tool lifecycle as a tagged union in the data, mirrored in the event, the +stored row, and the client. It removes a class of "which fields are set" bugs and gives every +surface one definition of the call's state. + +### Steering and the per-session run coordinator + +**Stated reason.** Covered in the steering section above. The short version: prompts during a busy +turn used to fail with `BusyError` ([#19156](https://github.com/anomalyco/opencode/pull/19156)), so +queueing replaced rejection, then server-owned steer/queue lanes +([#26199](https://github.com/anomalyco/opencode/pull/26199), "inspired by Codex") replaced ad-hoc +client handling to stop "inconsistent snapshots between clients and runtime status." The coordinator +"serializes execution for each key while allowing different keys to run concurrently." + +**The v1 problem it removed.** No way to talk to a working agent. The choice was reject or race. The +coordinator gives a single serialized execution per session with two well-defined entry points for +a follow-up. + +**Lesson for us.** Design the coordinator and the `steer | queue` flag in from the start, not after. +The OpenCode history shows the cost of retrofitting. They shipped rejection, then queueing, then +steering, then mid-stream interrupt, then graceful wrap, across a dozen PRs. We can read the +endpoint and design straight to it: one per-session serialized runner, a delivery flag on the +prompt, and the injection happening at a safe loop boundary, "before loading message history." + +### Defining the API in code so the client is generated + +**Stated reason.** Partly stated, partly inferred. The docs state the mechanism ("All types are +generated from the server's OpenAPI specification") but not the why. The newer +[PR #33445](https://github.com/anomalyco/opencode/pull/33445) states the direction more plainly: +reflect the Effect `HttpApi` contract directly and emit the client from it, "without OpenAPI or Hey +API," compiling "once into shared contract IR" that can emit a rich or a zero-dependency client. + +**Inference on the benefit.** The reason this matters is drift. When the API is written once in code +and the client is derived from it, the client types cannot diverge from the server. Hand-written +clients drift the moment a route changes and nobody updates the client. OpenCode did not have to +state this; it is why anyone generates a client from a contract. Their extra move is the lesson: +they treated even OpenAPI as a replaceable middle artifact and went straight from the typed API +definition to the client. + +**Lesson for us.** Keep one source of truth for the wire contract and generate the typed client from +it. We do not need Effect to get this. A typed API definition that emits both the spec and the +client is enough. The point is that the contract is authored once and the client is derived, never +written twice. + +### The migration strategy, as its own lesson + +This is not a single design choice, but it is the most reusable thing in the history. OpenCode did +not big-bang the rewrite. The first sync PR put event writing "behind a feature flag so that we can +easily change the schema if we need to," ran a temporary dual-write of v1 and v2 paths, kept "the db +mutations exactly the same for each of the write paths," and shipped it "through beta first." +Source: [PR #17814](https://github.com/anomalyco/opencode/pull/17814). The transitional +`session.next.*` event namespace and the parallel v1/v2 SDK clients are the visible residue of that +approach. The lesson for us, if we move to server-owned history, is to dual-write and flag-gate the +new event log beside the current path, project from it, and cut over only once the projection +matches the live behavior. We do not have to choose between cold replay and event sourcing on day +one. + +## Learnings and interesting things + +**The prompt is a command, not a request-response.** The HTTP call that starts a turn returns an +acknowledgment with a sequence number, and all output arrives on a separate, already-open event +stream. This is the cleanest answer I have seen to a problem we keep hitting: how do you start a +long agent turn over HTTP without holding a request open, and how do you reconnect mid-turn. You +do not stream the answer back on the POST. You admit the work and let the client read the event +stream. A reconnecting client just re-reads from a sequence number. + +**Event sourcing with a live/durable split.** The deltas are live-only and the `ended` events +are the durable, replayable boundary. This gets you smooth token streaming and a clean, +compact, replayable log at the same time, without writing every token to disk. The durable +events project into messages, so the transcript is always reconstructable and the streaming UI +is always cheap. This is a strong pattern and the one I would borrow first. + +**Tool state as a state machine in the data model.** `pending → running → completed | error` is +encoded in the schema as a tagged union, not implied by which fields happen to be set. The same +state shows up in events, in the projection, and on the client. There is one source of truth for +"what state is this tool call in," and the type system enforces the transitions. + +**The server is the single owner of state, and even the first-party TUI is just a client.** +There is no privileged in-process path for OpenCode's own UI. This forces the API to be complete +and keeps every surface honest. It is the discipline that makes a desktop app, a web app, and an +editor extension all viable against the same server. + +**Generate the SDK from the API, and define the API in code.** The server is written with +Effect's typed `HttpApi` DSL. That same definition emits the OpenAPI spec, and the spec +generates the SDK. The contract is written once and the client types cannot drift from it. + +**Steering is first-class.** `delivery: steer | queue` plus a per-session run coordinator means +a follow-up prompt can interrupt or queue behind the current turn. Mid-turn interruption is a +data-model decision, not a hack bolted onto the transport. + +**What I would be cautious about.** The whole core is built on Effect, which is a large bet on a +functional effect system and a steep on-ramp for contributors. The codebase is also visibly +mid-migration, with v1 and v2 session models, two SDK clients, and `session.next.*` event names +that read like a transitional namespace. The SDK generator is moving too, from `@hey-api/openapi-ts` +over OpenAPI toward a custom Effect-contract codegen +([PR #33445](https://github.com/anomalyco/opencode/pull/33445)), so the exact toolchain is not +settled. The public docs lag the code by a full architecture generation, which made this research +slower and means anyone reading their docs is reading the old model. Cloning the event-sourced core +without the Effect machinery would take real work. The good news from the migration history is that +they did this incrementally behind a feature flag with a dual-write, not as a big-bang rewrite, so +the path is reproducible without betting the whole product on it at once. + +## Comparison to ours + +Our design is documented under +[`docs/design/agent-workflows`](../README.md). The relevant pages are +[architecture](../architecture.md), [protocol](../protocol.md), +[ports-and-adapters](../ports-and-adapters.md), and [sessions](../sessions.md). + +### Where we already agree + +- **Client-server with a thin transport and a real core.** Our SDK owns neutral ports and DTOs + in `sdks/python/agenta/sdk/agents/`, and the service is a thin consumer. OpenCode splits + `core` from `server` the same way. Both keep the agent loop out of the HTTP layer. +- **A neutral intermediate event model.** We emit `AgentEvent` objects and project them into + one or more egress formats (Vercel UI Message Stream today, ACP and AG-UI planned). OpenCode + emits `session.next.*` events and projects them into messages and into the SSE stream. Both of + us treat the live run as a stream of typed events, not as one blob. +- **Lifecycle events with start, delta, and end.** Our protocol maps `message`, `thought`, and + reasoning to start/delta/end parts. OpenCode does the same with its `started`/`delta`/`ended` + triads. We arrived at the same shape independently. +- **Tool calls and results as discrete events with an approval path.** Our `tool_call`, + `tool_result`, and `interaction_request` events line up with OpenCode's tool lifecycle plus + permission events. Both of us model human approval in the event stream. +- **Tool delivery is harness-specific, but the event is neutral.** We resolve tools server-side + and let the runner execute them. OpenCode has a tool registry and a permission ruleset. The + external event shape stays uniform in both. + +### Where they differ, and what it suggests + +**Sessions: durable and server-owned versus cold replay.** This is the biggest gap. Our runtime +is cold. Each turn creates a fresh session, runs one `/run`, and tears it down. The model only +sees prior context because the client re-sends the full history every turn. Our `SessionStore` +is a port with only a `NoopSessionStore` behind it, and `/load-session` returns an empty list. +Source: [sessions](../sessions.md). OpenCode is the opposite. The server owns the conversation +as a durable event log, the client sends only the new prompt, and history is a query. Their +model is what our [sessions](../sessions.md) page calls future work. Their event log is also a +concrete answer to our open "session snapshot" question: you do not snapshot opaque harness +state, you keep an event log you can replay and project. + +**Prompt response: acknowledge-and-stream versus stream-on-the-POST.** Today our `/messages` +streams the Vercel UI Message Stream as the SSE body of the POST that carried the prompt. That +ties the turn to one open request. OpenCode admits the prompt, returns a sequence-numbered +acknowledgment, and streams everything on a separate long-lived `GET /api/event` connection. If +we want reconnect-mid-turn, multiple watchers on one session, or a turn that outlives a flaky +client connection, their split is the design to copy. It would mean adding a durable per-session +event stream endpoint alongside `/messages`, and treating the prompt POST as a command that +returns an id. + +**Message model: projection-from-events versus convert-on-the-edge.** We convert Vercel +`UIMessage` input into neutral `Message` objects on the way in, run, and convert `AgentEvent` +back into Vercel parts on the way out. OpenCode never converts a transcript on the edge. The +transcript is always a projection of the durable event log, so any client can page it and any +client can rebuild it. If we move to server-owned history, we should store events or neutral +messages as the source of truth and project to Vercel, ACP, or AG-UI on read, rather than +storing one client's wire format. + +**Steering: built-in versus absent.** We have no mid-turn steering or queueing concept. Our turn +is one cold `/run`. OpenCode's `delivery: steer | queue` plus the run coordinator gives +interrupt and queue semantics for free. When we add warm or server-owned sessions, we will want +the same two verbs, and it is cheaper to design the event and the coordinator in from the start +than to retrofit them. + +**Agent and model as session state versus per-run config.** OpenCode records the active agent +and model on the session and switches them with their own events. Our harness, model, and +sandbox selection ride on each `/run` as `RunSelection` and `AgentConfig`. Source: +[ports-and-adapters](../ports-and-adapters.md). For a chat that spans many turns, treating agent +and model as switchable session state, with an event when they change, is the better fit. Our +[agent-template](../agent-template.md) split already points this way; OpenCode shows it working. + +**One harness versus many.** Here we differ on purpose, and it is our advantage. OpenCode owns +its agent loop. There is one harness, written in TypeScript on the AI SDK. We run external +harnesses (Pi, Claude) over a backend and harness port, with local and Daytona sandboxes. +Source: [architecture](../architecture.md). That makes our event model harder, because we have +to normalize several harness wire formats into one `AgentEvent`, but it also lets us run agents +we did not write. OpenCode does not have that constraint, so it can make the event log and the +loop one tightly-coupled thing. We should not copy that coupling. Our neutral `AgentEvent` +boundary is the right call for a multi-harness platform. + +### Concrete takeaways for our session, message, and protocol design + +1. **Make the server own session history as an event log, and make the transcript a + projection.** Store neutral events or neutral messages as the source of truth. Project to + Vercel, ACP, or AG-UI on read. This directly fills the gap our + [sessions](../sessions.md) page documents and avoids storing one client's wire format. The why: + we are a single writer per session, so a per-session monotonic `seq` in our normal database + buys replay and reconnection with no distributed-systems machinery, exactly as OpenCode's + [#17814](https://github.com/anomalyco/opencode/pull/17814) lays out. +2. **Split the prompt from the stream.** Add a durable per-session event stream the client + subscribes to, and turn the prompt POST into an admit-and-schedule command that returns an + id and a sequence number. Keep `/messages` as a convenience streaming path, but make the + event stream the reconnectable source of truth. The why: OpenCode made accepted input a durable + `PromptAdmitted` event so pending work survives a dropped client and is reconstructable from + history ([#30785](https://github.com/anomalyco/opencode/pull/30785)). +3. **Adopt the live-delta, durable-boundary split.** Keep token deltas live-only and persist a + settled `ended` value per text, reasoning, and tool-input segment. We already emit the start, + delta, and end events; the missing half is persisting only the boundary, not every delta, so + the log stays small and replayable. The why is stated in their source: deltas are "live-only," + the `ended` event is "the replayable full-value boundary," and tools must not "persist every + stdout/stderr chunk." +4. **Model tool state as an explicit state machine in the data, not as optional fields.** A + `pending → running → completed | error` tagged union, mirrored in the event, the stored + message, and the client, removes a class of "which fields are set" bugs. The why is inference, + not their stated reason: only the fields valid in a state exist in that state, so illegal + combinations cannot typecheck. +5. **Plan for steering and queueing now.** When we move off cold replay, design a per-session + coordinator and a `steer | queue` delivery flag into the prompt contract from the start. The + why: OpenCode shipped this across a dozen PRs starting from a plain `BusyError` rejection + ([#19156](https://github.com/anomalyco/opencode/pull/19156)). We can design straight to the + endpoint they reached, and make pending state server-owned to avoid client/runtime drift + ([#26199](https://github.com/anomalyco/opencode/pull/26199)). +6. **Keep agent and model as session state once chat spans turns.** Record the active agent and + model on the session and emit an event on change, instead of re-sending them on every run. +7. **Migrate incrementally, behind a flag, with a dual-write.** If we move to server-owned + history, do not rewrite in one cut. Flag-gate the event log beside the current path, project + from it, verify the projection matches live behavior, then cut over. This is how OpenCode + shipped the rewrite without freezing the product ([#17814](https://github.com/anomalyco/opencode/pull/17814)). + +The honest summary: OpenCode has already built the durable, server-owned, event-sourced session +model that our docs describe as future work, and it pairs that with an acknowledge-and-stream +protocol that solves reconnection cleanly. Their constraint is simpler than ours, since they own +their one agent loop, so we should borrow their session and protocol mechanics while keeping our +neutral multi-harness `AgentEvent` boundary, which is the thing their design does not need and +we do. + +## Sources + +- OpenCode docs: [overview](https://opencode.ai/docs/), [server](https://opencode.ai/docs/server/), + [sdk](https://opencode.ai/docs/sdk/), [agents](https://opencode.ai/docs/agents/), + [tools](https://opencode.ai/docs/tools/), [plugins](https://opencode.ai/docs/plugins/), + [providers](https://opencode.ai/docs/providers/). +- Repository: [`anomalyco/opencode`](https://github.com/anomalyco/opencode) (`dev` branch). Key + source files cited inline: `packages/core/src/session/message.ts`, `schema.ts`, `info.ts`, + `event.ts`, `prompt.ts`, `input.ts`, `run-coordinator.ts`, `projector.ts`, `session.ts`, + `agent.ts`; `packages/core/src/event.ts`; `packages/server/src/groups/session.ts`, + `message.ts`, `event.ts`; `packages/server/src/handlers/session.ts`. +- Pull requests used for the stated rationale and the migration history: + [#17814 initial syncing](https://github.com/anomalyco/opencode/pull/17814), + [#30785 event-source session inputs](https://github.com/anomalyco/opencode/pull/30785), + [#33443 simplify input promotion](https://github.com/anomalyco/opencode/pull/33443), + [#19156 queue when busy](https://github.com/anomalyco/opencode/pull/19156), + [#26199 server-owned steer/queue](https://github.com/anomalyco/opencode/pull/26199), + [#33247](https://github.com/anomalyco/opencode/pull/33247) and + [#33104 steer interrupts and wrap](https://github.com/anomalyco/opencode/pull/33104), + [#33445 HttpApi client codegen](https://github.com/anomalyco/opencode/pull/33445), + [#33238 simplify event model](https://github.com/anomalyco/opencode/pull/33238). +- Context on the author of the event-sourced core: jlongster (James Long), Actual Budget, + [Using CRDTs in the Wild](https://archive.jlongster.com/using-crdts-in-the-wild). +- DeepWiki overviews (secondary, used for the v1 model and the architecture summary): + [repo overview](https://deepwiki.com/sst/opencode), + [session lifecycle](https://deepwiki.com/sst/opencode/2.1-session-lifecycle-and-state), + [OpenAPI spec](https://deepwiki.com/sst/opencode/7.2-openapi-specification). + +## Confidence notes + +- The v2 event-sourced model, the event triads, the tool state machine, the message tagged + union, the prompt admit-and-schedule flow, and the `steer | queue` delivery are all read + directly from `packages/core` and `packages/server` on the `dev` branch. High confidence. +- The v1 "parts" list and some lifecycle event names are from the public docs and DeepWiki, not + re-read from current source, because v2 has largely replaced them. Treat the v1 part inventory + as descriptive of the documented system, not the current head. +- Provider counts, the LSP integration, and the plugin hook list come from the docs and were not + cross-checked against every source file. Medium confidence on exact counts, high confidence on + the shapes. +- The codebase is mid-migration. Names like `session.next.*` and the parallel v1/v2 SDK clients + are transitional and may change. The architectural direction is clear; the exact identifiers + may not be stable. +- On the rationale: the event-sourcing motivation (single writer, many readers, replay), the + event-sourced inputs motivation (pending work must be reconstructable from history), the + queue/steer motivation (prompts used to fail with `BusyError`; server-owned to avoid client/runtime + drift), and the live/durable split are OpenCode's **stated** reasons, quoted from PRs and source + comments. High confidence. The tool-state-machine benefit and the generate-the-client benefit are + **inference** from the shape and from standard practice, clearly marked as such, because no PR or + comment was found stating them. No reason, stated or inferable, was found for the exact choice of + the `session.next.*` namespace; it reads as transitional. + + diff --git a/docs/design/agent-workflows/projects/runner-interface/README.md b/docs/design/agent-workflows/projects/runner-interface/README.md new file mode 100644 index 0000000000..1f45128357 --- /dev/null +++ b/docs/design/agent-workflows/projects/runner-interface/README.md @@ -0,0 +1,529 @@ +# RFC: The Agent Runner Interface (`/run`) + +| | | +| --- | --- | +| **Status** | Draft. Describes the active-stack code as built. | +| **Scope** | The wire boundary between the Python agent service and the TypeScript runner sidecar. | +| **Audience** | Anyone changing the `/run` payload, the transports, the event model, or either runner engine. | +| **Related** | [protocol.md](../protocol.md) (all public surfaces), [architecture.md](../architecture.md) (runtime shape), [ports-and-adapters.md](../ports-and-adapters.md) (SDK ports). This page is the deep dive on the internal `/run` slice that those pages summarize. | + +## 1. Summary + +The agent workflow runs in two processes. A Python process (the **agent service**) decides +*what* to run: it parses config, resolves provider secrets and tools, and threads trace +context. A Node process (the **runner sidecar**) decides *how* to run it: it drives a coding +harness (Pi or Claude) and streams back what happened. + +Those two processes talk over one contract: a `POST /run` request carrying a single agent +turn, and a structured result describing the turn. The same contract is delivered two ways +(HTTP to a running sidecar, or a subprocess CLI in a source checkout) and in two modes +(one-shot JSON, or live NDJSON). This RFC specifies that contract precisely: the transports, +the request and result schemas, the event model, the streaming framing, the error model, and +the versioning rules. + +The boundary is hand-mirrored on both sides and pinned by golden fixtures. The single most +important operational rule is in [Section 11](#11-versioning-and-the-change-both-sides-rule): +any field change touches Python, TypeScript, the golden fixtures, and both contract tests in +the same PR. + +## 2. Why a two-process boundary exists + +The split is not incidental. It is load-bearing for three reasons. + +1. **The harnesses are Node libraries.** Pi, Claude Code, and the `sandbox-agent` package + have no Python SDK. The agent loop has to run in Node. The rest of Agenta is Python. The + boundary is where those two worlds meet. +2. **Secret isolation.** The sidecar deliberately does not inherit the full service + environment. Provider keys and tool credentials are resolved by the service and passed + only inside the scoped `/run` payload that needs them. The sidecar sees a key because the + service chose to send it for that one run, not because it shares the service's env. +3. **Separation of concerns.** "What to run" (Agenta config, vault secrets, gateway tools, + trace context) stays in the service. "How to run it" (harness lifecycle, ACP, sandbox + creation, event shaping) stays in the runner. The `/run` contract is the only thing both + sides must agree on. + +## 3. Roles and terminology + +| Term | Meaning | +| --- | --- | +| **Agent service** | The Python FastAPI process. Owns config parsing, secret/tool resolution, tracing, and the public `/invoke` and `/messages` surfaces. Code: `services/oss/src/agent/`. | +| **Runner sidecar** | The Node process that runs the agent loop. Serves `GET /health` and `POST /run`. Code: `services/agent/`. Compose service name: `sandbox-agent`. | +| **Backend (SDK)** / **engine (runner)** | The same axis seen from two sides. The SDK `Backend` adapter (`InProcessPiBackend`, `SandboxAgentBackend`) hard-codes its engine id and serializes `/run`. The runner dispatches on that id (`pi` or `sandbox-agent`) to a TS engine (`engines/pi.ts`, `engines/sandbox_agent.ts`). | +| **Harness** | Which agent runs inside the engine: `pi`, `claude`, or `agenta`. | +| **Sandbox** | Where the run happens: `local` or `daytona`. | +| **Transport** | How the `/run` JSON is delivered: HTTP or subprocess CLI. | +| **Mode** | One-shot (one JSON result) or streaming (NDJSON records). | + +A clarification that the naming invites confusion on: **"in-process" means in-process to the +Node runner, not to Python.** `InProcessPiBackend` still crosses the `/run` wire. It just +tells the runner to drive the Pi SDK directly (`engines/pi.ts`) instead of starting the +`sandbox-agent` daemon and an ACP adapter (`engines/sandbox_agent.ts`). Both backends use the +identical transports and wire; they differ only in the `backend` field value and therefore in +which TS engine the runner picks. + +## 4. Topology and transport selection + +``` +browser / workflow client + | + | POST /invoke or POST /messages + v ++-------------------------------+ +| agent service (Python) | +| services/oss/src/agent/app.py | +| parse config | +| resolve secrets + tools | +| pick backend, build /run | ++-------------------------------+ + | + | ONE of two transports, chosen by whether a URL is set: + | + | (a) HTTP POST {AGENTA_AGENT_RUNNER_URL}/run + | (b) spawn pnpm exec tsx src/cli.ts (stdin -> stdout) + v ++-------------------------------+ +| runner sidecar (Node) | +| services/agent/src/server.ts | <- (a) +| services/agent/src/cli.ts | <- (b) +| dispatch on `backend` | +| "pi" -> runPi | +| "sandbox-agent"-> runSandboxAgent ++-------------------------------+ +``` + +The service always constructs a `SandboxAgentBackend` (`select_backend` in `app.py`). The +transport is a deployment choice, made by `_runner_config.resolve_runner_command` and the +adapter's `_deliver`: + +- **HTTP**, when `url` is set. The service reads it from `AGENTA_AGENT_RUNNER_URL` + (`config.runner_url()`). This is the deployed-container path: the sidecar is its own + service and the Python process calls it in-network. +- **Subprocess CLI**, when `url` is unset. The service passes `cwd` from `config.runner_dir()` + (overridable with `AGENTA_AGENT_RUNNER_DIR`), and the adapter spawns the default command + `pnpm exec tsx src/cli.ts` in that directory. This is the source-checkout / local-dev path. + +`resolve_runner_command` fails fast with `AgentRunnerConfigurationError` if it gets neither a +`url`, an explicit `command`, nor a `cwd` that actually contains `src/cli.ts`. There is no +silent "do nothing" runner. + +### Engine identity + +The engine id is not in the user-facing config. Each backend hard-codes it +(`InProcessPiBackend._ENGINE = "pi"`, `SandboxAgentBackend._ENGINE = "sandbox-agent"`) and +stamps it on the payload as `backend`. The subprocess transport also exports it as the +`AGENT_BACKEND` env var, as a backstop. At dispatch time the **payload's `backend` field +wins**; `AGENT_BACKEND` is only the fallback when the field is absent, and the runner's own +default is `sandbox-agent`. + +### Relevant environment variables + +| Variable | Side | Effect | +| --- | --- | --- | +| `AGENTA_AGENT_RUNNER_URL` | service | Set -> HTTP transport to this base URL. Unset -> subprocess CLI. | +| `AGENTA_AGENT_RUNNER_DIR` | service | Overrides the runner checkout dir used for the subprocess transport. | +| `AGENTA_AGENT_RUNNER_TIMEOUT_SECONDS` | service | Per-call transport timeout. Default `180`. | +| `AGENT_BACKEND` | runner | Fallback engine when the request omits `backend`. Default `sandbox-agent`. | +| `PORT` | runner | HTTP listen port. Default `8765`. | + +## 5. The runner HTTP surface + +The sidecar serves two routes from Node's built-in `http` server (no framework). Source: +`services/agent/src/server.ts`. + +### `GET /health` + +Returns runner identity so a client can detect an incompatible runner before the first run. + +```json +{ + "status": "ok", + "runner": "0.1.0", + "protocol": 1, + "engines": ["pi", "sandbox-agent"], + "harnesses": ["pi", "claude", "agenta"] +} +``` + +`protocol` is the MAJOR of the `/run` wire contract (`PROTOCOL_VERSION` in `version.ts`). +`runner` is the package build version, which is independent of the protocol. See +[Section 11](#11-versioning-and-the-change-both-sides-rule). + +### `POST /run` + +Body is an `AgentRunRequest` ([Section 7](#7-the-run-request)). Response depends on the +`Accept` header: + +| `Accept` | Mode | Response | +| --- | --- | --- | +| absent or anything but NDJSON | one-shot | One `AgentRunResult` JSON. HTTP `200` when `ok`, `500` when not. | +| `application/x-ndjson` | streaming | An NDJSON stream of `StreamRecord` lines, always under HTTP `200`. | + +Other status codes from the route: + +| Status | Cause | +| --- | --- | +| `400` | Request body is present but not valid JSON. | +| `404` | Any path other than `GET /health` or `POST /run`. | +| `500` | One-shot run returned `ok:false`, or an unexpected error in the request listener. | + +An empty body parses to `{}` rather than erroring. The runner then runs with all-default +fields, which is what the contract tests rely on. + +## 6. Transports in detail + +There are four delivery functions, two per transport, in +`sdks/python/agenta/sdk/agents/utils/ts_runner.py`. The backend's `_deliver` (one-shot) and +`_deliver_stream` (streaming) pick HTTP vs subprocess by the same `if self._url:` rule. + +### One-shot + +- **HTTP** (`deliver_http`): `POST {url}/run` with the JSON body, parse the JSON response. + Any status `>= 400` raises `RuntimeError("Agent runner HTTP {status}: {body}")` so a + transport failure is a clear error, not an opaque parse failure. +- **Subprocess** (`deliver_subprocess`): spawn the command, write the JSON to stdin, read + stdout. stdout carries the result and nothing else; logs go to stderr. Empty stdout raises + with the exit code and stderr tail. Non-JSON stdout raises with both stream tails. + +### Streaming (NDJSON) + +- **HTTP** (`deliver_http_stream`): `POST {url}/run` with `Accept: application/x-ndjson`, + yield each parsed line as it arrives. The `async with` client closes the connection when + the generator is closed or cancelled, which the runner observes as a client disconnect and + turns into run cancellation ([Section 9](#9-cancellation-and-timeouts)). +- **Subprocess** (`deliver_subprocess_stream`): spawn the command with `--stream`, write the + request to stdin, read stdout line by line against a deadline. A `finally` kills the child + if the consumer stops early, so a dropped stream never leaks a runner process. + +Both streaming transports enforce the terminal-result invariant +([Section 8](#8-streaming-framing)): if the stream drains without a `result` record, they +raise `RuntimeError("Agent runner stream ended without a terminal result record")`. + +### Symmetry guarantee + +The one-shot and streaming paths return the *same* `AgentRunResult` shape. The streaming +terminal record carries the identical result object the one-shot path would return, so the +Python side parses both with the same `result_from_wire`. The only difference: on the +streaming path the terminal result's `events` is emptied, because the events were already +delivered live (see [Section 8](#8-streaming-framing)). + +## 7. The `/run` request + +Type: `AgentRunRequest` in `services/agent/src/protocol.ts`, hand-mirrored in +`sdks/python/agenta/sdk/agents/utils/wire.py` (`request_to_wire`). camelCase on the wire. + +| Field | Type | Meaning | +| --- | --- | --- | +| `backend` | string | Engine id: `pi` or `sandbox-agent`. Set by the adapter, not the user. The runner dispatches on it. | +| `harness` | string | `pi`, `claude`, or `agenta`, subject to backend support. | +| `sandbox` | string | `local` or `daytona`. The in-process Pi path is local only. | +| `sessionId` | string \| null | External conversation id. The runtime is still cold; history arrives in `messages`, not by resuming a warm session. | +| `agentsMd` | string | Instructions injected as the agent's `AGENTS.md`. | +| `model` | string | Requested model id (`gpt-5.5`) or `provider/id` (`openai-codex/gpt-5.5`). | +| `messages` | ChatMessage[] | Conversation so far. The runner picks the latest user turn and replays the rest. | +| `secrets` | object | Provider keys as env vars (`{"OPENAI_API_KEY": "..."}`), resolved from the vault by the service. | +| `trace` | TraceContext \| null | Trace context so the run nests under the caller's `/invoke` span. | +| `tools` | string[] | Built-in tool names to enable (harness-shaped). | +| `customTools` | ResolvedToolSpec[] | Resolved runnable tools (gateway callback, code, or client). | +| `toolCallback` | ToolCallbackContext | Where callback tools POST back. Required when `customTools` is set. | +| `mcpServers` | McpServerConfig[] | User-declared MCP servers, secret env already injected. Omitted entirely when there are none. | +| `permissionPolicy` | string | `auto` (default) or `deny`, for permission-gating harnesses. | +| `systemPrompt` | string | Pi only: replace Pi's base system prompt. `AGENTS.md` is still appended after it. | +| `appendSystemPrompt` | string | Pi only: append to Pi's base prompt without replacing it. | +| `prompt` | string | Optional explicit latest turn. Falls back to the last user message in `messages`. | +| `skills` | string[] | Bundled skill directory names to force-load (the Agenta harness). | + +### How the request is assembled + +`request_to_wire` does not list tool, prompt, or MCP fields literally. It spreads three +harness-shaped helpers off the config object: + +- `config.wire_tools()` shapes `tools` / `customTools` / `toolCallback` / `permissionPolicy` + per harness. Pi sends built-ins plus native specs and no gating; Claude sends MCP-delivered + specs plus the permission policy. This is why the Pi and Claude golden requests differ. +- `config.wire_prompt()` adds `systemPrompt` / `appendSystemPrompt` only for harnesses that + expose them (Pi). It is empty otherwise. +- `config.wire_mcp()` adds `mcpServers` only when the user declared some, so a tool-free run's + payload is byte-for-byte unchanged. + +The engine id is passed in explicitly by the caller (the adapter), because each adapter +hard-codes its own. + +### ResolvedToolSpec + +A tool the service already resolved. Three orthogonal axes: + +- `kind` (the executor): `callback` POSTs back through Agenta's `/tools/call` (gateway tools; + the Composio key stays server-side); `code` runs `code` in a sandbox subprocess with `env` + (scoped resolved secrets); `client` is fulfilled by the browser across a turn boundary. + Absent means `callback` for back-compat. +- `needsApproval`: gate the call on a human yes/no. +- `render`: a generative-UI hint (`component`, `source`, or `spec`). + +`callRef` is set for `callback` tools only (the slug the bridge sends back). `runtime` / `code` +/ `env` are set for `code` tools. Provider keys and connection auth never ride on the spec; +they stay server-side. + +### Worked example (Pi) + +From `golden/run_request.pi.json`: + +```json +{ + "backend": "pi", + "harness": "pi", + "sandbox": "local", + "sessionId": "sess-1", + "agentsMd": "You are a helpful assistant.", + "model": "openai-codex/gpt-5.5", + "messages": [{"role": "user", "content": "hi"}], + "secrets": {"OPENAI_API_KEY": "sk-test"}, + "trace": { + "traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + "endpoint": "https://otlp.example/v1/traces", + "authorization": "Access tok-123", + "captureContent": true + }, + "tools": ["read", "write"], + "customTools": [ + { + "name": "get_user", + "description": "Get a user", + "inputSchema": {"type": "object", "properties": {}}, + "callRef": "tools__composio__github__GET_THE_AUTHENTICATED_USER__github-tvn", + "kind": "callback" + } + ], + "toolCallback": { + "endpoint": "https://api.example/tools/call", + "authorization": "Access tok-123" + }, + "permissionPolicy": "auto", + "systemPrompt": "You are Pi.", + "appendSystemPrompt": "Be terse." +} +``` + +The Claude golden (`run_request.claude.json`) differs as the harness shaping predicts: no +`tools` built-ins beyond an empty list, no Pi prompt overrides, `permissionPolicy: "deny"`, +and `backend: "sandbox-agent"`. + +## 8. The `/run` result and the event model + +Type: `AgentRunResult` in `protocol.ts`, parsed by `result_from_wire` in `wire.py`. + +| Field | Type | Meaning | +| --- | --- | --- | +| `ok` | bool | Success flag. `false` makes the Python side raise (see below). | +| `output` | string | Final assistant text. What the playground renders. | +| `messages` | ChatMessage[] | Structured assistant messages for the turn. | +| `events` | AgentEvent[] | Structured event log. Empty on the streaming path. | +| `usage` | AgentUsage | Token/cost totals, rolled onto the caller's workflow span. | +| `stopReason` | string | Why the turn ended, when the harness reports it. | +| `capabilities` | HarnessCapabilities | What the harness was probed to support this run. | +| `sessionId` | string | Session id, carried forward by the adapter for the next turn. | +| `model` | string | Model actually used. | +| `traceId` | string | Trace id of the run (the caller's trace when a traceparent was passed). | +| `error` | string | Failure message, set when `ok` is `false`. | + +### `ok` is a hard boundary + +`result_from_wire` raises `RuntimeError(f"Agent run failed: {error}")` whenever `ok` is +falsey. A failed run never reaches the model loop as an empty reply; it surfaces as a clear +Python exception. This holds on both the one-shot and streaming paths, because both parse the +terminal result with the same function. + +### The event model + +`AgentEvent` mirrors the ACP `session/update` variants the runner surfaces. Two text families +coexist and a consumer sees one or the other for a given block, never both: + +- **Coalesced**: `message` and `thought` carry a whole block. These appear in the one-shot + result's `events` log, because the non-streaming path has no per-token granularity to + recover. +- **Lifecycle / delta**: `message_start` / `message_delta` / `message_end` and the matching + `reasoning_*` trio are emitted live on the streaming path. A consumer that sees the delta + family for a block never also sees a coalesced `message` for it. + +The full variant set: + +| Event | Carries | +| --- | --- | +| `message` / `thought` | `text` (coalesced block) | +| `message_start/delta/end` | `id`, `delta` (live assistant text) | +| `reasoning_start/delta/end` | `id`, `delta` (live reasoning) | +| `tool_call` | `id?`, `name?`, `input?`, `render?` | +| `tool_result` | `id?`, `output?`, `data?` (structured), `isError?`, `render?` | +| `interaction_request` | `id`, `kind` (`permission` / `input` / `client_tool`), `payload?`. A HITL request; the reply returns cross-turn in the next `/messages` history, matched by `id`. | +| `data` | `name`, `data`, `transient?` (one-way generative UI) | +| `file` | `url`, `mediaType` | +| `usage` | `input?`, `output?`, `total?`, `cost?` | +| `error` | `message` | +| `done` | `stopReason?` | + +`result_from_wire` drops any event whose `type` it does not recognize, rather than failing the +whole parse. The `run_result.ok.json` golden includes a typeless event specifically to pin +that drop behavior. + +### Capabilities + +`HarnessCapabilities` is probed from the runtime (`sandbox-agent` `AgentCapabilities`) and +returned in the result. The runner branches on these flags rather than on the harness name: +`textMessages`, `images`, `fileAttachments`, `mcpTools`, `toolCalls`, `reasoning`, `planMode`, +`permissions`, `usage`, `streamingDeltas`, `sessionLifecycle`. + +### Worked example (success) + +From `golden/run_result.ok.json`, abridged: + +```json +{ + "ok": true, + "output": "Hello!", + "messages": [{"role": "assistant", "content": "Hello!"}], + "events": [ + {"type": "message", "text": "Hello!"}, + {"type": "usage", "input": 10, "output": 5, "total": 15, "cost": 0.001}, + {"type": "done", "stopReason": "end_turn"} + ], + "usage": {"input": 10, "output": 5, "total": 15, "cost": 0.001}, + "stopReason": "end_turn", + "capabilities": {"textMessages": true, "toolCalls": true, "usage": true}, + "sessionId": "sess-42", + "model": "gpt-5.5", + "traceId": "trace-abc" +} +``` + +A failure is just `{"ok": false, "error": "model exploded"}`. + +## 8b. Streaming framing + +When a caller asks for live delivery (HTTP `Accept: application/x-ndjson`, or the CLI +`--stream` flag), the runner writes newline-delimited JSON. Each line is a `StreamRecord`: + +```ts +type StreamRecord = + | { kind: "event"; event: AgentEvent } + | { kind: "result"; result: AgentRunResult }; +``` + +The framing rules are exact and load-bearing: + +1. One `{kind:"event"}` record flushes the moment its `AgentEvent` is built. +2. The run ends with **exactly one** `{kind:"result"}` record. This holds for success and for + failure: a thrown engine error becomes `{kind:"result", result:{ok:false, error}}`, not a + dropped connection. +3. The terminal result's `events` is emptied (`{...result, events: []}`) because the events + were already delivered live. A streaming consumer must rebuild the log from the `event` + records, not expect it on the result. +4. A stream that ends without a terminal `result` is an error. Both Python streaming + transports raise rather than hand the caller a resultless run. + +The browser never sees this NDJSON. The `/messages` egress converts it to a Vercel UI Message +Stream over SSE. NDJSON is strictly the Python-to-runner internal framing. + +## 9. Cancellation and timeouts + +**Cancellation** is wired end to end on the streaming path: + +- HTTP: the server listens on the *response* `close` (not the request, whose body is already + fully read) and aborts an `AbortController` when the client drops. The signal is passed into + `runSandboxAgent`. On the Python side, closing or cancelling the async generator closes the + httpx connection, which the runner sees as that disconnect. +- Subprocess: the streaming transport's `finally` kills the child if the consumer breaks or is + cancelled. + +One asymmetry worth knowing: the HTTP server passes the abort `signal` to `runSandboxAgent` +but not to `runPi`, and the CLI dispatch passes no signal at all. In-process Pi and all CLI +runs are cancelled by transport teardown (connection close or process kill), not by a +cooperative in-engine signal. + +**Timeouts** are transport-level on the Python side, from +`AGENTA_AGENT_RUNNER_TIMEOUT_SECONDS` (default 180s). The one-shot HTTP path uses the httpx +client timeout; the one-shot subprocess path uses `asyncio.wait_for` and kills the child on +expiry; the streaming subprocess path enforces a per-read deadline. There is no separate +server-side run timeout in the runner today; a run that never ends is bounded by the caller's +transport timeout. + +## 10. Error model + +Failures fall into two clean classes. + +1. **Transport failures**: the runner could not be reached or did not produce a parseable + result. HTTP `>= 400`, empty stdout, non-JSON stdout, a timeout, or a stream with no + terminal result. Each raises a `RuntimeError` with a specific message and (for subprocess) + the exit code and stderr tail. +2. **Run failures**: the runner ran but the turn failed. The result is `{"ok": false, + "error": "..."}`, which `result_from_wire` turns into a `RuntimeError("Agent run failed: + ...")`. On the one-shot HTTP path this also carries HTTP `500`; on the streaming path it + arrives as a normal terminal `result` record under HTTP `200`. + +The runner hardens its own process against background crashes: when running as the server +entrypoint it installs `unhandledRejection` and `uncaughtException` handlers that log and keep +serving, instead of letting one run's stray rejection (for example a `sandbox-agent` adapter +install or a Daytona preview SSE failing off the awaited path) kill the process and take every +in-flight request with it. + +## 11. Versioning and the "change both sides" rule + +The contract is intentionally duplicated, not shared through an imported module. Keeping the +request/result/event/capability types in `protocol.ts` (rather than one runner importing them +from the other) is what lets `engines/pi.ts` and `engines/sandbox_agent.ts` stay peers, and it +keeps Python free of a TS dependency. + +Duplication is made safe by golden fixtures and two contract tests: + +- Fixtures: `sdks/python/oss/tests/pytest/unit/agents/golden/` (`run_request.pi.json`, + `run_request.claude.json`, `run_result.ok.json`, `run_result.error.json`). +- Python asserts them in `test_wire_contract.py`. +- TypeScript asserts them in `tests/unit/wire-contract.test.ts`, which also has a compile-time + key guard, so a drifted `protocol.ts` fails `tsc`. + +**The rule:** any change to a request field, result field, event kind, or capability touches, +in the same PR: the golden fixture, `protocol.ts`, `wire.py`, and both contract tests. + +`PROTOCOL_VERSION` (`version.ts`) is the wire MAJOR, surfaced on `GET /health`. It is meant to +let a client refuse a runner whose major it does not understand. Today this is an available +affordance, not an enforced guard: no Python caller probes `/health` or checks the major +before the first `/run`. Wiring that probe is open work +([Section 12](#12-known-gaps-and-open-questions)). + +## 12. Known gaps and open questions + +These are properties of the boundary as built, not bugs to fix inside this RFC. They are the +candidate agenda for follow-up design. + +- **The runtime is cold.** Every turn is one `/run`: create a session, run, tear down. + `sessionId` rides the wire and is carried forward, but multi-turn context comes from + replaying `messages`, not from a warm daemon or a persisted model session. ACP + `session/load`, fork, and warm reuse are not wired. +- **No schema validation on the runner.** `POST /run` JSON-parses the body and runs with + whatever fields are present (an empty body becomes `{}`). There is no structural validation + or rejection of unknown fields at the boundary; correctness rests on the golden tests, not + on a runtime guard. +- **The version skew guard is not consumed.** `/health` exposes `protocol`, but nothing checks + it. A client and runner can silently disagree across a major bump. +- **Pi prompt overrides are dropped on the ACP path.** `systemPrompt` / `appendSystemPrompt` + serialize into the request, but the `sandbox-agent` Pi engine does not deliver them yet. + They only take effect on the in-process Pi engine. +- **Cancellation is uneven.** Only `runSandboxAgent` over HTTP receives the abort signal. + In-process Pi and all CLI runs rely on transport teardown. +- **Remote MCP is not executed.** `mcpServers` carries `http` transport on the wire, but the + active-stack runner path executes local `stdio` MCP only. Remote servers are skipped. +- **No run-level timeout in the runner.** Only the caller's transport timeout bounds a run. +- **Result/event size is unbounded.** The one-shot result inlines the whole `events` log and + `messages`. There is no paging or cap on the boundary. + +## 13. File reference + +| Concern | Python | TypeScript | +| --- | --- | --- | +| Wire types | `sdks/python/agenta/sdk/agents/utils/wire.py` | `services/agent/src/protocol.ts` | +| Transports | `sdks/python/agenta/sdk/agents/utils/ts_runner.py` | `services/agent/src/server.ts`, `src/cli.ts` | +| Backend adapters | `adapters/sandbox_agent.py`, `adapters/in_process.py`, `adapters/_runner_config.py` | `src/engines/sandbox_agent.ts`, `src/engines/pi.ts` | +| Runner identity | (consumes `/health`, not yet) | `src/version.ts` | +| Service wiring | `services/oss/src/agent/app.py`, `config.py` | n/a | +| Golden fixtures | `sdks/python/oss/tests/pytest/unit/agents/golden/` | shared (same files) | +| Contract tests | `tests/pytest/unit/agents/test_wire_contract.py` | `tests/unit/wire-contract.test.ts` | + + diff --git a/docs/design/agent-workflows/sandbox-agent-refactor-plan.md b/docs/design/agent-workflows/projects/sandbox-agent-refactor/sandbox-agent-refactor-plan.md similarity index 100% rename from docs/design/agent-workflows/sandbox-agent-refactor-plan.md rename to docs/design/agent-workflows/projects/sandbox-agent-refactor/sandbox-agent-refactor-plan.md diff --git a/docs/design/agent-workflows/sdk-local-tools/README.md b/docs/design/agent-workflows/projects/sdk-local-tools/README.md similarity index 100% rename from docs/design/agent-workflows/sdk-local-tools/README.md rename to docs/design/agent-workflows/projects/sdk-local-tools/README.md diff --git a/docs/design/agent-workflows/sdk-local-tools/codebase-conventions.md b/docs/design/agent-workflows/projects/sdk-local-tools/codebase-conventions.md similarity index 100% rename from docs/design/agent-workflows/sdk-local-tools/codebase-conventions.md rename to docs/design/agent-workflows/projects/sdk-local-tools/codebase-conventions.md diff --git a/docs/design/agent-workflows/sdk-local-tools/context.md b/docs/design/agent-workflows/projects/sdk-local-tools/context.md similarity index 100% rename from docs/design/agent-workflows/sdk-local-tools/context.md rename to docs/design/agent-workflows/projects/sdk-local-tools/context.md diff --git a/docs/design/agent-workflows/sdk-local-tools/conventions-review.md b/docs/design/agent-workflows/projects/sdk-local-tools/conventions-review.md similarity index 100% rename from docs/design/agent-workflows/sdk-local-tools/conventions-review.md rename to docs/design/agent-workflows/projects/sdk-local-tools/conventions-review.md diff --git a/docs/design/agent-workflows/sdk-local-tools/organization-proposal.md b/docs/design/agent-workflows/projects/sdk-local-tools/organization-proposal.md similarity index 100% rename from docs/design/agent-workflows/sdk-local-tools/organization-proposal.md rename to docs/design/agent-workflows/projects/sdk-local-tools/organization-proposal.md diff --git a/docs/design/agent-workflows/sdk-local-tools/plan.md b/docs/design/agent-workflows/projects/sdk-local-tools/plan.md similarity index 100% rename from docs/design/agent-workflows/sdk-local-tools/plan.md rename to docs/design/agent-workflows/projects/sdk-local-tools/plan.md diff --git a/docs/design/agent-workflows/sdk-local-tools/research.md b/docs/design/agent-workflows/projects/sdk-local-tools/research.md similarity index 100% rename from docs/design/agent-workflows/sdk-local-tools/research.md rename to docs/design/agent-workflows/projects/sdk-local-tools/research.md diff --git a/docs/design/agent-workflows/sdk-local-tools/review/evidence/app-mcp-reassign.md b/docs/design/agent-workflows/projects/sdk-local-tools/review/evidence/app-mcp-reassign.md similarity index 100% rename from docs/design/agent-workflows/sdk-local-tools/review/evidence/app-mcp-reassign.md rename to docs/design/agent-workflows/projects/sdk-local-tools/review/evidence/app-mcp-reassign.md diff --git a/docs/design/agent-workflows/sdk-local-tools/review/evidence/attach-orthogonal-mutation.md b/docs/design/agent-workflows/projects/sdk-local-tools/review/evidence/attach-orthogonal-mutation.md similarity index 100% rename from docs/design/agent-workflows/sdk-local-tools/review/evidence/attach-orthogonal-mutation.md rename to docs/design/agent-workflows/projects/sdk-local-tools/review/evidence/attach-orthogonal-mutation.md diff --git a/docs/design/agent-workflows/sdk-local-tools/review/evidence/description-default-inconsistency.md b/docs/design/agent-workflows/projects/sdk-local-tools/review/evidence/description-default-inconsistency.md similarity index 100% rename from docs/design/agent-workflows/sdk-local-tools/review/evidence/description-default-inconsistency.md rename to docs/design/agent-workflows/projects/sdk-local-tools/review/evidence/description-default-inconsistency.md diff --git a/docs/design/agent-workflows/sdk-local-tools/review/evidence/gateway-no-logging.md b/docs/design/agent-workflows/projects/sdk-local-tools/review/evidence/gateway-no-logging.md similarity index 100% rename from docs/design/agent-workflows/sdk-local-tools/review/evidence/gateway-no-logging.md rename to docs/design/agent-workflows/projects/sdk-local-tools/review/evidence/gateway-no-logging.md diff --git a/docs/design/agent-workflows/sdk-local-tools/review/evidence/gateway-orthogonal-untested.md b/docs/design/agent-workflows/projects/sdk-local-tools/review/evidence/gateway-orthogonal-untested.md similarity index 100% rename from docs/design/agent-workflows/sdk-local-tools/review/evidence/gateway-orthogonal-untested.md rename to docs/design/agent-workflows/projects/sdk-local-tools/review/evidence/gateway-orthogonal-untested.md diff --git a/docs/design/agent-workflows/sdk-local-tools/review/evidence/handler-resolution-error.md b/docs/design/agent-workflows/projects/sdk-local-tools/review/evidence/handler-resolution-error.md similarity index 100% rename from docs/design/agent-workflows/sdk-local-tools/review/evidence/handler-resolution-error.md rename to docs/design/agent-workflows/projects/sdk-local-tools/review/evidence/handler-resolution-error.md diff --git a/docs/design/agent-workflows/sdk-local-tools/review/findings.md b/docs/design/agent-workflows/projects/sdk-local-tools/review/findings.md similarity index 100% rename from docs/design/agent-workflows/sdk-local-tools/review/findings.md rename to docs/design/agent-workflows/projects/sdk-local-tools/review/findings.md diff --git a/docs/design/agent-workflows/sdk-local-tools/review/metadata.json b/docs/design/agent-workflows/projects/sdk-local-tools/review/metadata.json similarity index 100% rename from docs/design/agent-workflows/sdk-local-tools/review/metadata.json rename to docs/design/agent-workflows/projects/sdk-local-tools/review/metadata.json diff --git a/docs/design/agent-workflows/sdk-local-tools/review/plan.md b/docs/design/agent-workflows/projects/sdk-local-tools/review/plan.md similarity index 100% rename from docs/design/agent-workflows/sdk-local-tools/review/plan.md rename to docs/design/agent-workflows/projects/sdk-local-tools/review/plan.md diff --git a/docs/design/agent-workflows/sdk-local-tools/review/progress.md b/docs/design/agent-workflows/projects/sdk-local-tools/review/progress.md similarity index 100% rename from docs/design/agent-workflows/sdk-local-tools/review/progress.md rename to docs/design/agent-workflows/projects/sdk-local-tools/review/progress.md diff --git a/docs/design/agent-workflows/sdk-local-tools/review/questions.md b/docs/design/agent-workflows/projects/sdk-local-tools/review/questions.md similarity index 100% rename from docs/design/agent-workflows/sdk-local-tools/review/questions.md rename to docs/design/agent-workflows/projects/sdk-local-tools/review/questions.md diff --git a/docs/design/agent-workflows/sdk-local-tools/review/risks.md b/docs/design/agent-workflows/projects/sdk-local-tools/review/risks.md similarity index 100% rename from docs/design/agent-workflows/sdk-local-tools/review/risks.md rename to docs/design/agent-workflows/projects/sdk-local-tools/review/risks.md diff --git a/docs/design/agent-workflows/sdk-local-tools/review/scope.md b/docs/design/agent-workflows/projects/sdk-local-tools/review/scope.md similarity index 100% rename from docs/design/agent-workflows/sdk-local-tools/review/scope.md rename to docs/design/agent-workflows/projects/sdk-local-tools/review/scope.md diff --git a/docs/design/agent-workflows/sdk-local-tools/review/scorecard.md b/docs/design/agent-workflows/projects/sdk-local-tools/review/scorecard.md similarity index 100% rename from docs/design/agent-workflows/sdk-local-tools/review/scorecard.md rename to docs/design/agent-workflows/projects/sdk-local-tools/review/scorecard.md diff --git a/docs/design/agent-workflows/sdk-local-tools/review/summary.md b/docs/design/agent-workflows/projects/sdk-local-tools/review/summary.md similarity index 100% rename from docs/design/agent-workflows/sdk-local-tools/review/summary.md rename to docs/design/agent-workflows/projects/sdk-local-tools/review/summary.md diff --git a/docs/design/agent-workflows/sdk-local-tools/status.md b/docs/design/agent-workflows/projects/sdk-local-tools/status.md similarity index 100% rename from docs/design/agent-workflows/sdk-local-tools/status.md rename to docs/design/agent-workflows/projects/sdk-local-tools/status.md diff --git a/docs/design/agent-workflows/sidecar-deployment-proposal/README.md b/docs/design/agent-workflows/projects/sidecar-deployment-proposal/README.md similarity index 100% rename from docs/design/agent-workflows/sidecar-deployment-proposal/README.md rename to docs/design/agent-workflows/projects/sidecar-deployment-proposal/README.md diff --git a/docs/design/agent-workflows/sidecar-deployment-proposal/proposal.md b/docs/design/agent-workflows/projects/sidecar-deployment-proposal/proposal.md similarity index 100% rename from docs/design/agent-workflows/sidecar-deployment-proposal/proposal.md rename to docs/design/agent-workflows/projects/sidecar-deployment-proposal/proposal.md diff --git a/docs/design/agent-workflows/sidecar-deployment-proposal/status.md b/docs/design/agent-workflows/projects/sidecar-deployment-proposal/status.md similarity index 100% rename from docs/design/agent-workflows/sidecar-deployment-proposal/status.md rename to docs/design/agent-workflows/projects/sidecar-deployment-proposal/status.md diff --git a/docs/design/agent-workflows/projects/skills-config/architecture.md b/docs/design/agent-workflows/projects/skills-config/architecture.md new file mode 100644 index 0000000000..c8c39c3423 --- /dev/null +++ b/docs/design/agent-workflows/projects/skills-config/architecture.md @@ -0,0 +1,147 @@ +# Skills: system architecture + +How an agent skill flows through the system, from the config a user authors to the +`SKILL.md` the harness loads. Companion to `proposal.md` (the spec) and `build-notes.md` +(the implementation log). This doc is the architecture reference: the data model, the +resolution path, and the component boundaries. + +## What a skill is + +A skill is a reusable unit of instructions an agent loads on demand. It follows the +`SKILL.md` shape: a `name`, a `description`, a Markdown `body`, and optional bundled +`files`. The description is the trigger the model matches against the task. The body is the +procedure. Only the name and description stay in context at all times; the harness reads the +body and files only when the model decides the skill applies (progressive disclosure). + +The runtime shape is one `SkillConfig`: + +``` +SkillConfig { + name: str # ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$, <= 64 chars + description: str + body: str + files: [SkillFile] # optional bundled files + disable_model_invocation: bool # optional + allow_executable_files: bool # optional; gates executable files +} +SkillFile { path: str, content: str, executable: bool } +``` + +There is no type or source discriminator on a skill. A skill is either written inline or +pulled in by reference, and both resolve to the same `SkillConfig` before the agent runs. + +## Two authoring shapes + +The agent config carries a flat `skills` list, a sibling of `tools` and `mcp_servers`. Each +entry is one of two shapes: + +1. **Inline.** A literal `SkillConfig` written directly in the config. +2. **Reference (`@ag.embed`).** A pointer to a stored skill, resolved server-side before the + run. This is the existing embed mechanism the platform already uses for variants and + environments, not a new slot: + + ```json + { + "@ag.embed": { + "@ag.references": {"workflow": {"slug": "weather-oracle"}}, + "@ag.selector": {"path": "parameters.skill"} + } + } + ``` + + A bare `workflow` reference resolves to the latest revision. A `workflow_revision` + reference with a `version` pins a specific one. The selector path `parameters.skill` is + the canonical storage key for a skill's payload (see the data model below). + +## Data model: a skill is a non-runnable workflow + +A stored skill is a workflow artifact with `flags.is_skill = true` and no URI, so it is not +runnable. Its `SkillConfig` package lives at `data.parameters.skill`. `is_skill` sits in the +existing JSONB `flags` column alongside `is_application` / `is_evaluator` / `is_snippet`, so +it needs no migration. `is_snippet` is the precedent: a non-runnable, embeddable workflow. +`is_skill` is its own artifact family rather than a specialization of `is_snippet` so skills +get their own catalog, validation, and lifecycle. + +Runnability stays interface-derived (`has_url` / `has_script` / `has_handler`). A skill has +none of those, so the runnable check already excludes it without a special case. + +## End-to-end flow + +``` +agent config (skills: inline | @ag.embed) + │ + ▼ +ResolverMiddleware ── resolves @ag.embed in the EFFECTIVE parameters + (sdk/middlewares/running/resolver.py) (inline request params, else the revision's) + │ the embed resolver walks arrays, so @ag.embed inside skills[i] resolves + ▼ +wire_skills() ── normalizes each entry to a concrete inline SkillConfig on the /run wire + (sdk/agents/skills/wire.py, spread by request_to_wire) + │ + ▼ +runner /run ── receives skills as resolved inline packages (no references on the wire) + │ + ▼ +skills materializer ── composes SKILL.md + writes files into the sandbox skill dir + (services/agent/src/engines/skills.ts) + │ + ▼ +harness ── Pi loads SKILL.md; Claude SDK drops skills and logs a warning +``` + +The key boundary: **references resolve before the wire.** The runner only ever sees concrete +inline `SkillConfig` packages. It never resolves a reference and never reaches back to the +platform for a skill. + +## Component responsibilities + +- **`ResolverMiddleware`** (`sdks/python/agenta/sdk/middlewares/running/resolver.py`): + resolves `@ag.embed` markers in the effective parameters. The effective source is the + inline `request.data.parameters` when the caller sent them (the playground running an + unsaved config, where there is no revision), otherwise the revision's. The embed resolver + already traverses arrays, so a reference nested in `skills[i]` resolves on either path. +- **`wire_skills()`** (`sdks/python/agenta/sdk/agents/skills/`): the seam that turns the + `skills` list into concrete inline packages on the `/run` wire. `SkillConfig` / + `SkillFile` models and their validation live here. +- **Skills materializer** (`services/agent/src/engines/skills.ts`): composes the `SKILL.md` + (YAML frontmatter + body), writes bundled files under the skill directory, validates + `skill.name` against path traversal, rejects a `SKILL.md` clobber, and defaults executable + files to deny. +- **Catalog + schema** (`sdks/python/agenta/sdk/utils/types.py`, + `services/oss/src/agent/schemas.py`): the `skill_config` catalog type and the `skills` + field on the agent config (a union of inline `SkillConfig` and an `@ag.embed` ref), so the + default seeded config validates under raw/advanced schema validation. + +## Platform skills via a reserved catalogue + +Agenta's own managed skills are served from a code-defined **`PlatformWorkflowCatalog`** under a +reserved slug namespace (`_agenta.*`), not seeded per project. They stay ordinary `@ag.embed` +references; only resolution differs. A read-only platform revision provider sits at the +`WorkflowsService.fetch_workflow_revision` seam (injected from `api/entrypoints/routers.py`): +a `_agenta.*` slug returns a synthetic `WorkflowRevision` from code and never hits Postgres, +while every other slug takes the existing DB path. The default agent config embeds +`_agenta.agenta-getting-started`. + +The synthetic revision carries `flags.is_skill=True`, `flags.is_platform=True`, the validated +`SkillConfig` at `data.parameters.skill`, no `uri`, and deterministic UUIDv5 IDs. `is_platform` +is the read-only signal: the SDK/client must not edit or delete the workflow, and the playground +renders it as a read-only platform entry. Versions live immutably in code; an artifact-level ref +resolves to `current`, a revision-level ref with a `version` pins one. Updating a catalogue entry +and deploying updates every project at once, with no per-project copy and no migration. A user +cannot create or shadow a `_agenta.*` slug (the prefix is reserved on create/edit and never falls +through to the DB). See `proposal.md` for the full design. The earlier per-project seeder and the +`is_locked` lock are removed. + +## Executable files + +Executable files are off by default. A bundled file runs only when its `executable` flag is +set, the skill sets `allow_executable_files`, and the sandbox policy allows execution. A +skill carries author-supplied content, and a script the model can run is a wider surface than +a typed tool, so the default is deny. + +## Harness support + +Skills load on the Pi-based harnesses (`pi` and `agenta`): the harness reads `SKILL.md` and +surfaces the skill to the model. The Claude SDK harness cannot load `SKILL.md`, so it drops +any attached skills and logs a visible warning at the non-Pi drop point +(`services/agent/src/engines/sandbox_agent/run-plan.ts`). diff --git a/docs/design/agent-workflows/projects/skills-config/build-notes.md b/docs/design/agent-workflows/projects/skills-config/build-notes.md new file mode 100644 index 0000000000..81bcedafd4 --- /dev/null +++ b/docs/design/agent-workflows/projects/skills-config/build-notes.md @@ -0,0 +1,122 @@ +# Skills config — build notes (implementation log) + +Running log of what was built, what was found live, and the judgment calls made during the +autonomous implementation push. Companion to `proposal.md` (the spec). Newest first. + +## Platform-skills catalogue redesign (2026-06-24) + +Replaced the per-project seed-and-lock approach for platform default skills with a code-defined +**`PlatformWorkflowCatalog`** served under a reserved `_agenta.*` slug namespace (design reviewed +by Codex xhigh; see proposal.md "Platform skills via a reserved catalogue"). Why: the old seeder +made per-project DB copies, never reached existing projects with new skill versions, would have +needed a migration per release, and the lock was unsafe. The catalogue ships with the release and +every project resolves the same code-defined skill, no seeding and no migration. + +What landed (branch `feat/agent-skills`, working tree — not yet committed): +- New: `core/workflows/interfaces.py` (`PlatformWorkflowProvider` port), `platform_catalog.py` + (`PlatformWorkflowCatalog`, synthetic revision + deterministic UUIDv5 ids + `SkillConfig` + validation), `core/workflows/types.py` (`ReservedWorkflowSlug`), `apis/fastapi/workflows/ + exceptions.py` (`handle_workflow_exceptions` → 400), `tests/.../test_platform_catalog.py`. +- `WorkflowsService.fetch_workflow_revision` short-circuits a `_agenta.*` slug to the catalogue + BEFORE any DB call (artifact ref → current, revision ref + version → pinned, unknown → None, + never falls through to the DB). `_reject_reserved_slug` on `create_workflow` + + `commit_workflow_revision`; catalogue injected at the composition root (`entrypoints/routers.py`). +- `is_platform` flag added to API + SDK workflow flag models (JSONB, no migration) as the + read-only signal; threaded through `infer_flags_from_data`. +- Default agent config embeds `_agenta.agenta-getting-started`. +- **Deleted** `core/workflows/defaults.py` (seeder) + its callers (`commoners.py`, + `accounts/service.py`, `db_manager_ee.py`) + its tests + any lock code. No compat shim + (pre-release). +- FE: `SkillConfigControl` renders a platform skill (reserved `_agenta.` slug, or resolved + `flags.is_platform`) read-only — "Platform skill" tag, slug, version, no edit/delete. +- Also cleared the open CodeRabbit comments on skills code (parsing.py recursive embed reject, + skills.ts file-entry validation + duplicate-name reject, types.py path pattern, qa doc nits). + +Review found + fixed: P0 — a look-around `pattern=` on the skill-file path crashed `import agenta` +(pydantic_core's Rust regex rejects look-ahead); replaced with a look-around-free pattern. P1 — +`create_workflow_revision` lacked `@handle_workflow_exceptions()` so a reserved-slug rejection +500'd instead of 400; decorator added. The reported `wire_harness_options` AttributeError was a +red herring (the P0 import crash surfacing under xdist), confirmed not a real bug. + +Tests green: API workflows 46, SDK agents 307, agent-service 24, runner TS skills 174, FE 115. +Pending: Codex implementation review (security pass on the reserved namespace), live E2E on the +`:8280` stack, then commit/push under the coordination protocol. + +## Status + +- Phase A (SDK `SkillConfig` + `wire_skills()` seam + `ResolverMiddleware` inline-embed fix + + runner materializer): DONE, reviewed (code-review subagent + Codex xhigh), fix pass applied, + green. +- Phase B (API `is_skill` family flag + `skill_config` catalog type + `is_locked` lock mechanism + + project-creation seeding + default-config embed): DONE, green. +- Phase C (FE playground `SkillConfigControl` + Skills section): DONE, lint + typecheck clean. +- Live E2E (local Pi): PASS — the agent genuinely loads and **invokes** the skill (marker token + observed in a real model reply, negative control clean), for both the inline and the + embed-reference paths. + +## Live E2E results (2026-06-23/24) + +Canonical invocation test: skill `weather-oracle`, description "Use this whenever the user asks +about the weather…", body instructs the model to begin its reply with `SKILL-LOADED-7Q42-OK`. +Trigger message "What's the weather like today?". PASS = token present in the reply. + +- **Inline skill, local Pi: PASS.** Reply began with `SKILL-LOADED-7Q42-OK`; runner log + `skills: weather-oracle`. Negative control (no skills) → token absent. +- **Embed reference, local Pi: PASS.** An `is_skill` workflow referenced via + `@ag.embed{@ag.references{workflow.slug}}` resolved server-side and the reply contained the + token. Proves the headline reference path end to end. +- **Daytona: BLOCKED (not a skills bug).** Skill *materialized into* the Daytona sandbox + correctly (`skills: weather-oracle`, `sandbox=daytona`); the run then failed on the + pre-existing Daytona model-auth gap (provider key not wired into the Daytona ACP daemon), so + the model never ran. Skills behavior is correct up to that boundary. +- **Claude: BLOCKED on auth; skills correctly dropped.** The runner materializes skills only for + Pi, so the Claude run dropped them as designed; the run failed at session creation on missing + `anthropic` provider auth. See gap below. + +## Bugs / gaps found live, and decisions + +1. **Embed slug 500 (FIXED).** A `workflow_revision` reference with a bare artifact slug and no + `version` returns HTTP 500 `EmbedNotFoundError`, because a `workflow_revision` slug matches + the revision's own hash slug, not the author-facing artifact slug + (`_resolve_revision_with_normalization` only normalizes when a version is present). The + **seeded default config** used this broken shape, so the default `agenta-getting-started` + skill itself failed. + - **Decision:** reference skills at the **artifact** level — `@ag.references{workflow.slug}` + — which resolves to the latest revision and is verified working. Fixed in + `services/oss/src/agent/schemas.py` and the proposal docs. Version pinning stays available + via `{workflow_revision: {slug, version}}`. + - **Deferred (not done, low risk):** optionally add a no-version bare-slug → latest-revision + fallback in the shared embed resolver. Not done to avoid blast radius on shared embed + resolution; the artifact-level reference makes it unnecessary for skills. Logged for a + future hardening pass. +2. **Claude skills-drop is silent (FIXED).** The proposal calls for the Claude adapter to + log-and-drop skills (its SDK path can't load SKILL.md). Live, the drop happened but no warning + was logged. Fixed: the runner now emits a visible warning at the non-Pi drop point + (`run-plan.ts`), covering any non-Pi harness. + +## Decision: defer the lock mechanism to a follow-up PR (2026-06-24) + +Two independent final reviews (a code-review subagent + Codex xhigh) converged: the skills core +is sound, but the **`is_locked` lock mechanism is not production-safe** as built. Specific holes: +`is_locked` is settable through public create/edit (any client can permanently brick any +workflow); locked artifacts are still mutable via `create_workflow_variant`, +`fork_workflow_variant` (the DAO fork bypasses the service), and the unarchive paths; and the +seeder's create-then-lock is not idempotent against a partial first seed. Properly hardening this +is a cross-cutting change to the shared workflows service that affects apps and evaluators, and +deserves its own focused PR + review. + +**Decision:** remove the lock mechanism from this PR and **seed the default `agenta-getting-started` +skill unlocked**. The skills feature is complete and reviewed-clean without it. Locking the default +skill (so users cannot edit/delete it) becomes a follow-up. Logged as an open issue. + +Kept from the reviews (real regardless of the lock): +- Seeding is now **best-effort** — a seeding failure logs and continues, never breaks + org/project creation/signup. +- The agent-config catalog schema now models a skills entry as **inline OR `@ag.embed`** so the + seeded default (an embed) validates under raw/advanced schema validation. + +## Working preferences captured for this push + +- Autonomous mandate: run straight through to PR creation without pausing for approval; make + best-judgment calls and record them here. Use GitButler for branching/commits; group commits + sensibly rather than fussing over granularity. diff --git a/docs/design/agent-workflows/tool-resolution-layering/plan.md b/docs/design/agent-workflows/projects/tool-resolution-layering/plan.md similarity index 100% rename from docs/design/agent-workflows/tool-resolution-layering/plan.md rename to docs/design/agent-workflows/projects/tool-resolution-layering/plan.md diff --git a/docs/design/agent-workflows/projects/typescript-structure/README.md b/docs/design/agent-workflows/projects/typescript-structure/README.md new file mode 100644 index 0000000000..a86e689022 --- /dev/null +++ b/docs/design/agent-workflows/projects/typescript-structure/README.md @@ -0,0 +1,35 @@ +# TypeScript structure for the agent runner + +Planning workspace for making the new TypeScript code in the agent-workflows project +usable, maintainable, and testable, with tests that run easily and run in CI. + +The new TypeScript lives mostly in one place: `services/agent/` (the Node "agent runner" +sidecar). This folder researches its current shape and proposes how to structure, test, +and gate it the way the rest of the monorepo already handles Python and frontend code. + +## Files + +- [context.md](context.md) — why this work exists, goals, non-goals, who it is for. +- [research.md](research.md) — what is actually in the repo today: where the TS lives, how + it builds, ships, and is (barely) tested; the conventions the repo already standardizes + for TS; a Python-to-TypeScript mental model; the gaps. +- [plan.md](plan.md) — the phased plan to close the gaps, with concrete file changes, + scripts, and CI wiring. +- [status.md](status.md) — source of truth for progress and open decisions. Read this + first to see where things stand. + +## TL;DR + +The runner code is well-organized (clear `engines/`, `tools/`, `tracing/` seams, a single +`protocol.ts` wire contract). The weak spots are tooling, not architecture: + +1. Eight test files exist but there is **no test runner and no `pnpm test`**. Each test is + a hand-run `tsx` script. +2. Those tests run in **no CI workflow**. The Node side is invisible to the unit-test gate. +3. There is **no typecheck gate** even though the code is already `strict: true`. +4. The TS side has **no test asserting the cross-language wire contract**, which is only + pinned from Python today. + +The plan adopts **vitest** (the runner `web/packages/*` already use), wires a Node job into +`12-check-unit-tests.yml`, adds a `tsc --noEmit` gate, and adds a golden-fixture round-trip +test so `protocol.ts` cannot drift from the Python wire silently. diff --git a/docs/design/agent-workflows/projects/typescript-structure/context.md b/docs/design/agent-workflows/projects/typescript-structure/context.md new file mode 100644 index 0000000000..0f59ca7125 --- /dev/null +++ b/docs/design/agent-workflows/projects/typescript-structure/context.md @@ -0,0 +1,49 @@ +# Context + +## Why this work exists + +The agent-workflows project introduced the first substantial server-side TypeScript in a +repo that was Python on the backend and TypeScript only on the frontend. The new code is +the agent runner sidecar at `services/agent/`. It drives the agent harnesses (Pi, Claude +Code, sandbox-agent's `sandbox-agent`) because those are Node libraries with no Python SDK. The +Python agent service calls into it over one JSON contract. + +This code grew fast during the build-out. It works and it is reasonably well-factored, but +it sits outside the conventions the rest of the monorepo follows. The owner is a Python +developer and wants this TypeScript to feel as routine to maintain and test as the Python +does: a single command to run the tests, the tests running in CI, a typecheck gate, and a +clear place for new code and new tests to go. + +## Goals + +1. **Testable, easily.** One command (`pnpm test`) runs every unit test for the runner. + Watch mode and coverage work. Writing a new test is obvious and low-ceremony. +2. **Tested in CI.** The runner's tests run on every PR that touches it, with results + published the same way the Python and web suites are. +3. **Typechecked.** The `strict` TypeScript already configured produces a CI signal, so a + type error fails the build instead of reaching the dockerized sidecar at runtime. +4. **Contract-safe.** The wire contract between the Python service and the Node runner is + guarded from both sides, not just from Python. +5. **Maintainable and discoverable.** A new contributor (or agent) can find where runner + code and runner tests belong, following the same instruction-layering the repo uses for + `web/` and `api/`. + +## Non-goals + +- Rewriting or re-architecting the runner. The `engines` / `tools` / `tracing` split and + the `protocol.ts` contract stay. This is about tooling and structure, not a redesign. +- Folding `services/agent` into the `web/` pnpm workspace. It is a deployable sidecar with + its own Docker build and its own lockfile; it should stay a standalone package (see + research.md for the trade-off). +- Changing the frontend TypeScript (`web/oss/src/components/AgentChatSlice/`). That code + already lives in the web app under established conventions (vitest, package practices). + It is out of scope here. +- End-to-end / live-LLM acceptance tests for the runner. Those depend on real harness + credentials and are tracked separately in the agent-workflows test work. This plan is + about the fast unit/contract layer that can run on every PR with no secrets. + +## Who this is for + +The maintainer (Python-first) and any future contributor or agent touching +`services/agent`. research.md includes a Python-to-TypeScript mental model so the tooling +choices map onto things already familiar from the SDK and API side (uv, ruff, pytest). diff --git a/docs/design/agent-workflows/projects/typescript-structure/plan.md b/docs/design/agent-workflows/projects/typescript-structure/plan.md new file mode 100644 index 0000000000..98addc834c --- /dev/null +++ b/docs/design/agent-workflows/projects/typescript-structure/plan.md @@ -0,0 +1,173 @@ +# Plan + +Four phases, ordered so value lands early and nothing later depends on a refactor. Phases 1 +and 2 are the core ask (easy-to-run tests, tests in CI). Phase 3 protects the contract. +Phase 4 is structure and maintainability, adopted progressively. + +Effort estimates assume one developer familiar with the runner. They are deliberate, not +padded. + +## Phase 1 — Make the tests run with one command (~half day) + +Goal: `pnpm test` in `services/agent` runs every unit test, with watch and coverage. + +0. **Fix the latent bug the typecheck will expose.** `src/tools/dispatch.ts` references an + undefined `callRef` at lines 88 and 92 inside `relayToolCall`. Use the in-scope value + (`toolName`, or thread the spec's `callRef` in) so the error path stops throwing + `ReferenceError`. Found by Codex; this is the proof the typecheck gate has teeth. +1. Add dev deps to `services/agent/package.json`: `vitest`, `@vitest/coverage-v8`, **and + `typescript`** (currently absent: `node_modules/.bin/tsc` does not exist, so `typecheck` + cannot run without it). Match the versions `web/packages/*` pin (`vitest` `^4.1.x`); align + `@types/node` with Node 24. +2. Add `services/agent/vitest.config.ts`, modeled on `agenta-shared/vitest.config.ts`: + `include: ["tests/unit/**/*.test.ts"]`, `environment: "node"`, + `reporters: ["default", "junit"]` to `test-results/junit.xml`, v8 coverage over `src/`. +3. Add scripts to `package.json`: + + ```jsonc + "test": "pnpm run test:unit", + "test:unit": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "typecheck": "tsc --noEmit" + ``` + +4. Move `test/*.test.ts` to `tests/unit/*.test.ts` and wrap the bare `{ ... }` blocks in + `describe` / `it` so reporting and junit are per-case. **Do not bother rewriting every + `assert` to `expect`** (Codex's point): vitest runs `node:assert` fine, so the conversion + is just adding `describe`/`it` wrappers, not touching assertions. Keep filenames. The + dynamic-import-after-env pattern (e.g. `skills.test.ts`) stays valid; add + `vi.resetModules()` only where a file needs a clean module per case. +5. Update the `Run:` header comment in each test to `pnpm test` (or + `pnpm exec vitest run tests/unit/.test.ts` for a single file). + +Done when: `pnpm test` is green locally and prints a single summary across all files. + +## Phase 2 — Run them in CI (~half day) + +Goal: the runner's tests gate every PR that touches `services/agent`. + +1. Add a `run-services-node-unit-tests` job to `.github/workflows/12-check-unit-tests.yml`, + mirroring the existing `run-web-unit-tests` setup but scoped to the package: + - `actions/setup-node@v4` with `node-version: '24'`, `corepack enable`. + - Cache the pnpm store keyed on `services/agent/pnpm-lock.yaml`. + - `working-directory: services/agent`, `pnpm install --frozen-lockfile`, then + `pnpm run typecheck` and `pnpm run test:unit`. + - **Ensure `python3` is on the runner.** `test/code-tool.test.ts` spawns `python3` (and + `node`) through `runCodeTool`. ubuntu-latest ships python3, but make it explicit, or + split the subprocess code-tool test into an integration test the unit job can skip. + - Publish `services/agent/test-results/junit.xml` with + `EnricoMi/publish-unit-test-result-action@v2`, `check_name: Agent Runner Unit Tests`. +2. Path-filter the job. The workflow already triggers on `services/**`; gate the new job's + steps so it only does work when `services/agent/**` changed (the same `if:` pattern the + other jobs use for their package selection), to avoid installing Node on unrelated PRs. +3. Decide whether `typecheck` failing fails the job. Recommendation: yes. The code is + already `strict`; a type error should not merge. + +Done when: a PR touching `services/agent` shows an "Agent Runner Unit Tests" check, and a +deliberately broken type or assertion turns it red. + +## Phase 3 — Guard the wire contract from the TS side (~half day) + +Goal: a contract change must update Python and TypeScript together, or fail on both. + +**Codex correction (important):** `protocol.ts` is types only, erased at runtime. "Loading +JSON and round-tripping it through an interface" validates nothing at runtime. The contract +test needs real runtime checks, in two layers: + +1. Add `tests/utils/golden.ts` that loads the shared fixtures from + `sdks/python/oss/tests/pytest/unit/agents/golden/` (relative path from the runner, read + at test time). No copying; one source of truth. +2. **Runtime validation, not type assertion.** Either (a) introduce a zod (or equivalent) + schema that mirrors `protocol.ts` and `parse()` each golden fixture in + `tests/unit/wire-contract.test.ts`, or (b) write explicit structural assertions (required + keys present, types correct, the `ok` discriminant). Option (a) doubles as a real runtime + guard the server can use on inbound requests; option (b) is lighter but only a test. +3. **Type-level check, separately.** Use vitest's `expectTypeOf` (or a `tsd`-style check) so + a fixture that drifts from `AgentRunRequest` fails `typecheck`, independent of the runtime + assertions. +4. Exercise the pure helpers in `protocol.ts` (`messageText`, `resolvePromptText`, + `resolveRunSessionId`) against fixture-derived inputs. +5. Note in `protocol.ts` and Python `test_wire_contract.py` that the contract is now pinned + from both sides, so future editors look both ways. + +Done when: editing a field name in `protocol.ts` without updating the fixtures (or vice +versa) fails this test, at runtime and at typecheck. + +## Phase 4 — Structure and maintainability (progressive, no big bang) + +Adopt as the runner is touched, not in one sweep. + +1. **Add `services/agent/AGENTS.md`** (with a `CLAUDE.md` symlink, matching `web/`, `api/`). + Keep it short: the package is a standalone pnpm project; how to run/serve/test/typecheck; + where runner code goes (`src/{engines,tools,tracing}`) and where tests go + (`tests/unit`, fixtures in `tests/utils`); the wire contract is mirrored in Python + `wire.py` and pinned by golden fixtures, so change both sides; vitest is the runner. + Add a thin `.claude/rules` / `.cursor/rules` pointer if the repo expects one. +2. **Local typecheck gate (optional).** The root `.husky/pre-commit` already runs prettier + and gitleaks repo-wide. Optionally add `pnpm --dir services/agent typecheck` for changed + TS, or leave the gate to CI to keep commits fast. Recommendation: CI is the gate; skip + the local hook unless commits regularly land type errors. +3. **Linting (optional, phase-2 nice-to-have).** There is no eslint outside `web/`. + `prettier` (global hook) covers formatting. A small `typescript-eslint` flat config for + `services/agent` would add real value for async runner code (`no-floating-promises`, + `no-misused-promises`). Treat as optional; `tsc --strict` + prettier is an acceptable + floor. +4. **Extract a testability seam (Codex).** `server.ts` and `cli.ts` wire transport to the + engines inline, so HTTP/CLI behavior can only be tested with a live harness. Export + `createServer(runAgent)` and `runCli(runAgent)` that take the engine as an argument. Then + unit tests inject a fake engine returning a deterministic `AgentRunResult` and cover + `/health`, invalid-JSON handling, `POST /run`, NDJSON record ordering, and CLI exit codes, + with no Pi/Claude/sandbox-agent. This is the highest-value structural change for testability. +5. **Decompose the two large files opportunistically.** When next editing `engines/sandbox_agent.ts` + or `tracing/otel.ts`, pull a cohesive seam into its own module and unit-test it, the way + `responder.ts` was extracted from `sandbox_agent.ts`. Not a scheduled refactor. + +## Phase 5 — Make it a versioned, supportable service (Codex's main gap) + +The review's core point: the plan above makes the runner testable but does not make it a +first-class deployable. These items make the SDK and the sidecar safe to release on their +own cadences. Scope and sequence with the platform/release owner; some are bigger than a +half-day. + +1. **Protocol/version negotiation.** Add a `protocolVersion` (major) to the wire and have + `GET /health` (or a new `/capabilities`) return `runnerVersion`, `protocolVersion`, + supported engines, and harnesses. The Python adapter probes once and refuses an + incompatible major before the first run. Today `/health` returns only `{status:"ok"}` and + `package.json` is `0.0.0`. +2. **Release ownership.** Decide whether the sidecar version tracks the Agenta release or is + versioned independently, and stop shipping `0.0.0`. The SDK should pin a compatible runner + *protocol* range, not a package-version equality. +3. **Sidecar image publishing.** No CI publishes the runner image today (only api/web/services + images are built, e.g. in `42-railway-build.yml`). Add a build/publish job so the HTTP + sidecar (the production boundary) is actually distributable. +4. **Local code-tool execution policy.** `runCodeTool` scopes secret env, but a `code` tool + still runs an arbitrary `python3`/`node` process in the sidecar. State the sandbox, + resource, and network policy (it is already sandboxed in Daytona; the local/in-sidecar + path needs an explicit stance), so this is a deliberate posture, not an oversight. +5. **Config hygiene.** `services/oss/src/agent/app.py` reads `AGENTA_AGENT_*` via raw + `os.getenv`. The repo convention (root `AGENTS.md`) is to add config to + `api/oss/src/utils/env.py` and consume the shared `env` object. Align it. +6. **Fix the stale `local.py` docstring.** `sdks/python/.../adapters/local.py` says the Pi + runner is "shipped inside the wheel," which is not true today and is the likely source of + the wheel confusion. Either implement that path deliberately (see the packaging options in + the answer to question 1) or correct the docstring to match reality. + +## Sequencing and ownership + +- Phases 1 to 3 are independent of any runtime change and can land as one small PR or three + tiny ones. They add no production code paths, only tooling and tests. Start here. +- Phase 4 item 1 (`AGENTS.md`) is worth doing alongside Phase 1 so the new test location is + documented the moment it exists. Item 4 (the `createServer`/`runCli` seam) unblocks the + HTTP/CLI tests and is worth pulling forward. +- Phase 5 is a separate track, owned with whoever owns releases and deployment. It does not + block Phases 1 to 4, but it is what turns "tested code" into "supportable service." +- None of this blocks ongoing runner feature work; it runs in parallel. + +## What success looks like + +- `cd services/agent && pnpm test` runs the whole suite in one go, green, with a summary. +- A PR touching the runner gets a red/green unit-test + typecheck check automatically. +- `protocol.ts` cannot drift from the Python wire without a test failing. +- A new contributor reads `services/agent/AGENTS.md` and knows where code and tests go and + how to run them, without reading the whole tree. diff --git a/docs/design/agent-workflows/projects/typescript-structure/research.md b/docs/design/agent-workflows/projects/typescript-structure/research.md new file mode 100644 index 0000000000..c21eb13955 --- /dev/null +++ b/docs/design/agent-workflows/projects/typescript-structure/research.md @@ -0,0 +1,193 @@ +# Research + +Findings from reading the repo on 2026-06-20. Everything below is observed in the tree, not +assumed. + +## 1. Where the new TypeScript actually lives + +Server-side TypeScript that did not exist before agent-workflows is concentrated in one +package: + +``` +services/agent/ standalone pnpm package "agenta-sandbox-agent" + package.json ESM, type:module, pnpm 10.30, Node 24 + tsconfig.json strict, noEmit, moduleResolution Bundler + pnpm-lock.yaml its OWN lockfile (not in the web workspace) + src/ + cli.ts (88) entrypoint: stdin JSON in, stdout JSON out + server.ts (155) entrypoint: HTTP sidecar on :8765 (GET /health, POST /run) + protocol.ts (295) the /run wire contract: request, result, events, caps + responder.ts (77) permission/HITL policy seam (extracted from sandbox_agent.ts) + engines/ + pi.ts (403) drive the Pi SDK in-process + sandbox_agent.ts (1085) drive any harness over ACP via sandbox-agent + skills.ts (50) resolve forced-skill names to dirs on disk + tools/ (7 files) callback, code, dispatch, mcp-bridge, mcp-server, relay, ... + tracing/ + otel.ts (1026) turn a run into OTel spans nested under /invoke + extensions/ + agenta.ts (114) Pi extension, esbuild-bundled into dist/ for Pi to load + test/ (8 files) hand-run tsx scripts (see section 3) + skills/ SKILL.md bundled forced-skills for the Agenta harness + config/ fallback hello-world agent + docker/ Dockerfile (prod) + Dockerfile.dev + scripts/ build-extension.mjs (esbuild bundle of the extension) +``` + +Total runner source is ~4,100 lines. It is the only meaningful server-side TS in the repo. + +Other TypeScript exists but is **not** in scope: + +- `web/oss/src/components/AgentChatSlice/` — frontend, already under web conventions. +- `web/packages/*`, `web/oss`, `web/ee` — the established frontend, vitest + Playwright. +- `docs/`, `examples/` — Docusaurus and sample apps. + +So "TypeScript in different places" is really one homeless package (`services/agent`) plus +frontend code that already has a home. The plan targets the package. + +## 2. How the runner builds, runs, and ships today + +- **No compile step for the app.** It runs through `tsx` (a TS-aware Node loader). Both the + dev image (`tsx watch src/server.ts`) and the prod image (`tsx src/server.ts`) execute + the source directly. `tsconfig.json` is `noEmit: true`; it exists only for typechecking, + and nothing runs that typecheck. +- **One real build:** `scripts/build-extension.mjs` esbuild-bundles `src/extensions/agenta.ts` + into `dist/extensions/agenta.js` so Pi can load it anywhere. Both Dockerfiles run + `pnpm run build:extension`. +- **Two transports, one contract.** Python reaches the runner either over HTTP (the docker + sidecar) or by spawning the CLI as a subprocess. Both carry the same `/run` JSON. See + `sdks/python/agenta/sdk/agents/utils/ts_runner.py` (`deliver_http`, `deliver_subprocess`, + plus the NDJSON streaming variants). +- **Standalone package.** `services/agent` has its own `pnpm-lock.yaml` and is absent from + `web/pnpm-workspace.yaml`. That isolation is deliberate and worth keeping: the sidecar + image installs only the runner's deps, with no coupling to the web dependency graph. +- **No TS in the wheel today, but a docstring claims otherwise.** The SDK wheel is pure + Python (`uv_build`, zero `.ts`/`.js`). However `sdks/python/.../adapters/local.py` (the + unimplemented `LocalBackend`) says the Pi runner is "the bundled JS runner ... shipped + inside the wheel." That is aspirational and NOT YET IMPLEMENTED, but it is almost certainly + the source of the "is the TS part of the SDK / wheel" worry. The future-local-backend + question (bundle a built JS runner into the wheel vs require Docker/npm) is real and + undecided; see plan Phase 5 item 6 and the distribution options in status.md. + +Scripts present in `package.json` today: `run:cli`, `serve`, `serve:watch`, +`build:extension`, `login`. There is **no `test`, no `typecheck`, no `lint`, no `format`.** + +## 3. How it is tested today (the gap) + +There are 8 test files under `services/agent/test/`: + +``` +code-tool.test.ts continuation.test.ts mcp-servers.test.ts responder.test.ts +skills.test.ts stream-events.test.ts tool-bridge.test.ts tool-dispatch.test.ts +``` + +They are genuinely good tests in content. The problem is entirely in how they run: + +- Each file is a **standalone script** using `node:assert/strict`, with bare `{ ... }` + blocks for grouping and a `console.log("...: ok")` at the end. The header of each says + `Run: pnpm exec tsx test/.test.ts`. +- There is **no runner and no aggregation.** Running "the test suite" means running eight + commands by hand. A failure is a thrown assertion and a non-zero exit on one file; there + is no summary, no count, no `--watch`, no filtering, no coverage, no junit. +- They run in **no CI workflow.** `12-check-unit-tests.yml` has a `run-services-unit-tests` + job, but it only looks at `services/oss/tests/pytest/unit` (Python) and runs + `uv run python run-tests.py`. It never installs Node or touches `services/agent`. Every + vitest mention in CI refers to `web/packages`. So the runner's tests have never gated a + PR. +- There is **no TS-side contract test.** `protocol.ts` says the contract is pinned by + golden fixtures under `sdks/python/oss/tests/pytest/unit/agents/golden/` and checked by + the Python `test_wire_contract.py`. That guards the Python mirror (`wire.py`). Nothing on + the TS side asserts that `protocol.ts` still accepts those fixtures, so the runner can + drift from the contract and only Python would notice. + +## 4. What the repo already standardizes for TypeScript tests + +We do not need to invent a convention. The frontend already has one, and there is a written +spec: + +- **vitest is the repo's TS unit runner.** `web/packages/*` (agenta-shared, entities, + entity-ui, playground, annotation) each ship a `vitest.config.ts` and these scripts: + + ```jsonc + "test": "pnpm run test:unit", + "test:unit": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "typecheck": "tsc --noEmit" + ``` + + Config (from `agenta-shared/vitest.config.ts`): `include: ["tests/unit/**/*.test.ts"]`, + `environment: "node"`, `reporters: ["default", "junit"]` writing `test-results/junit.xml`, + and v8 coverage. This is exactly the shape a Node service wants. + +- **CI runs them generically.** The web job runs `pnpm -r --if-present test:unit` across + workspace packages and publishes `web/packages/*/test-results/junit.xml` via the + `publish-unit-test-result-action`. Any package that defines `test:unit` is picked up; the + rest are skipped. A new package following the same script names slots in for free. + +- **There is a folder-layout spec.** `docs/designs/testing/testing.structure.specs.md` + defines runner-first layout: `/tests//{unit,integration,acceptance,utils}` + plus `manual/` and `legacy/`. In practice the vitest packages collapse this to + `tests/unit/**/*.test.ts` (one runner, so no `vitest/` level). The agent runner's current + flat `test/` directory matches neither; aligning it to `tests/unit/` matches the closest + precedent (web packages) and the spec. + +## 5. Python-to-TypeScript mental model + +For mapping the tooling onto what the SDK/API side already does: + +| Concern | Python (api/, sdks/) | TypeScript (services/agent) | +|----------------------|---------------------------|------------------------------------| +| Package manager | `uv` | `pnpm` (own lockfile) | +| Run a script | `uv run python x.py` | `pnpm exec tsx x.ts` | +| Test runner | `pytest` | **vitest** (proposed) | +| One command to test | `uv run python run-tests.py` | `pnpm test` (proposed) | +| Type checker | `mypy` / pyright | `tsc --noEmit` (configured, unrun) | +| Formatter | `ruff format` | `prettier` (runs repo-wide in hooks) | +| Linter | `ruff check` | none today (eslint is web-only) | +| Fixtures | `conftest.py` fixtures | `tests/utils/` helper modules | +| CI unit gate | `12-check-unit-tests.yml` Python jobs | new Node job (proposed) | + +The headline: the TS runner has a formatter (via the global pre-commit) but no test runner, +no test gate, and no type gate. The Python side has all three. Closing that is the work. + +## 6. The cross-language contract is the seam that matters most + +`protocol.ts` is the single source of the `/run` types. `sdks/python/.../utils/wire.py` +hand-mirrors them. The contract is pinned by shared golden JSON +(`run_request.pi.json`, `run_request.claude.json`, `run_result.ok.json`, +`run_result.error.json`) and asserted by `test_wire_contract.py` on the Python side only. + +This is the highest-value place to add a TS test. A vitest test that loads those same +golden files and round-trips them through `protocol.ts` (parse the request shape, build a +result that matches the result fixture) means a contract change has to update both sides or +fail on both sides. It reuses fixtures that already exist, needs no harness and no network, +and directly protects the Python-to-Node boundary the whole feature rests on. + +## 7. Maintainability observations (not blockers) + +- **Architecture is sound.** Engines are peers behind one contract; tools are split by + concern; the responder seam was already extracted from `sandbox_agent.ts` (and is unit-tested). + `protocol.ts` carries thorough doc comments. A Python dev can navigate it. +- **Two large files.** `engines/sandbox_agent.ts` (1,085) and `tracing/otel.ts` (1,026) are the + obvious decomposition candidates. The responder extraction is the precedent: pull + cohesive seams out into separately testable units when you next touch them. Not a + big-bang refactor, and not a prerequisite for the test/CI work. +- **No `AGENTS.md` for the package.** The repo pushes area conventions into nested + `AGENTS.md` files (`web/AGENTS.md`, `api/AGENTS.md`) with a `CLAUDE.md` symlink. + `services/agent` has a strong `README.md` but no `AGENTS.md`, so the "where does runner + code/tests go, how do I run them" rules have nowhere to live. Adding one is cheap and + fits the repo's instruction-layering model. +- **Env-at-import-time.** Some modules read env on import (e.g. `skills.ts` reads + `AGENTA_AGENT_SKILLS_DIR`; the test sets it before a dynamic `import()`). vitest isolates + modules per test file, so this keeps working, but new tests touching such modules should + use dynamic import or `vi.resetModules()` rather than top-level import. + +## 8. One real decision to make + +**vitest vs `node:test`.** `node:test` is built in and adds zero dependencies, but it has +no first-class junit reporter or coverage UX and would diverge from the frontend. vitest +adds one dev dependency but matches `web/packages` exactly, gives junit + v8 coverage + +watch + filtering out of the box, and lets the CI wiring mirror the web job. Recommendation: +**vitest.** Everything in the plan assumes it; swapping to `node:test` would only change the +runner dependency and config, not the structure. diff --git a/docs/design/agent-workflows/projects/typescript-structure/status.md b/docs/design/agent-workflows/projects/typescript-structure/status.md new file mode 100644 index 0000000000..e9e0f85991 --- /dev/null +++ b/docs/design/agent-workflows/projects/typescript-structure/status.md @@ -0,0 +1,229 @@ +# Status + +Source of truth for this planning folder. Update as work proceeds. + +## Current state — 2026-06-20 + +Research complete. Plan drafted and then reviewed by Codex (gpt-5.5, xhigh). Plan widened in +response (see plan.md Phases 1, 3, 5). **Phase 1 is implemented and green.** + +### Phase 1 done (2026-06-20) + +- Fixed the `callRef` bug in `src/tools/dispatch.ts` (lines 88, 92 now use `toolName`). +- Added dev deps: `vitest` 4.1.9, `@vitest/coverage-v8` 4.1.9, `typescript` 5.9.3; bumped + `@types/node` to 24.13.2 (matches the Node 24 runtime). `pnpm-lock.yaml` updated. +- Added `vitest.config.ts` (node env, junit to `test-results/junit.xml`, v8 coverage). +- Added scripts: `test`, `test:unit`, `test:watch`, `test:coverage`, `typecheck`. +- Moved `test/*.test.ts` (9 files, including `extension-tools.test.ts` from the + `feat/agent-runner-engines` lane) to `tests/unit/*.test.ts`, wrapped in `describe`/`it`, + kept `node:assert`, fixed import depth to `../../src/`. +- Added `test-results/` and `coverage/` to `.gitignore`. + +Verified: `pnpm typecheck` exits 0 (and a planted type error makes it exit 2, so the gate has +teeth). `pnpm test` = 9 files, 42 tests, all pass, junit written. `pnpm test:coverage` works +(32.6% line coverage; engines are not exercised by unit tests yet, as expected). + +Not mine in the same working tree: `src/engines/pi.ts`, `src/engines/sandbox_agent.ts`, the +Dockerfiles, and `src/engines/skills.ts` were already modified/untracked from the parallel +`feat/agent-runner-engines` lane. The combined tree still typechecks and tests green. + +### Phase 2 done (2026-06-20) + +- Added job `run-services-node-unit-tests` to `.github/workflows/12-check-unit-tests.yml`, + mirroring the web (pnpm setup) and python-services (has_tests guard + package-selection + gate) jobs: Node 24 + corepack pnpm, `pnpm install --frozen-lockfile`, `pnpm run typecheck`, + `pnpm run test:unit` (working-directory `services/agent`), then publish + `services/agent/test-results/junit.xml` as "Agent Runner Unit Test Results". +- No `setup-python`: the code-tool test spawns `python3`/`node`, both preinstalled on ubuntu + runners. +- Verified locally: the workflow YAML parses and the job is present; + `pnpm install --frozen-lockfile` succeeds (lockfile matches package.json), so CI will not + fail on a lockfile mismatch. + +### Codex review of Phase 1+2 (xhigh) — all 5 findings fixed (2026-06-20) + +Codex confirmed the `callRef` fix is correct and the test conversion is assertion-faithful, +then found 5 issues. All fixed and verified: + +1. **High — CI could pass while running nothing.** The `has_tests` guard let the job skip + silently. Removed it; vitest exits non-zero on no test files, so a missing suite now fails. +2. **High — the nested `.gitignore` is itself ignored.** Root `.gitignore` line 68 (`.*`) + ignores every nested `.gitignore`, so the `services/agent/.gitignore` artifact rules could + never land. Reverted that edit; added `services/agent/test-results/` and + `services/agent/coverage/` to ROOT `.gitignore` (the repo's convention). Verified with + `git check-ignore`. +3. **Medium — typecheck did not cover tests/config.** Broadened `tsconfig.json` `include` to + `src + tests + vitest.config.ts`. Proven: a planted type error in a test file now fails + `pnpm typecheck`. +4. **Medium — brittle env isolation.** `skills.test.ts` now saves/restores + `AGENTA_AGENT_SKILLS_DIR` in `afterAll`; `responder.test.ts` has an `afterEach` that clears + `SANDBOX_AGENT_DENY_PERMISSIONS` even if an assertion throws. +5. **Low — the fixed bug had no direct test.** Added two `relayToolCall` tests in + `tool-dispatch.test.ts`: the ok path returns the relayed text, and the empty-error path + asserts `tool relay failed for ` (this would have thrown `ReferenceError` before + the fix). + +Final state after Phase 1+2: `pnpm typecheck` exits 0 (covers src + tests + config; planted +errors exit 2). `pnpm test` = 9 files / 44 tests pass. `pnpm install --frozen-lockfile` clean. +Workflow YAML valid. + +### Phase 3 done (2026-06-20) + +The TS side of the cross-language wire contract (the "later PR" the Python +`test_wire_contract.py` names). Two layers, per Codex's correction that types are erased: + +- `tests/utils/golden.ts` reads the shared fixtures from + `sdks/python/oss/tests/pytest/unit/agents/golden/` in place via `node:fs` (no copy). +- `tests/unit/wire-contract.test.ts`: + - **Runtime**: loads `run_request.pi.json`, `run_request.claude.json`, `run_result.ok.json`, + `run_result.error.json`; asserts shapes; exercises `resolvePromptText`, + `resolveRunSessionId`, `messageText`; checks the camelCase capability keys and the + trailing untyped event the wire carries. + - **Compile-time**: `KNOWN_REQUEST_KEYS` (mirrored from the Python test) and the capability + keys are assigned to `(keyof AgentRunRequest)[]` / `(keyof HarnessCapabilities)[]`. If + `protocol.ts` renames or drops a field the wire still emits, `tsc` fails. + +Both gates proven: a wire key not on `AgentRunRequest` fails `tsc` (TS2322); clean restores +it. Final: `pnpm test` = **10 files / 51 tests** pass, `pnpm typecheck` exits 0. + +Phases 1, 2, and 3 are implemented, reviewed, and green. + +### Phase 4 done (2026-06-20) + +- `services/agent/AGENTS.md` + `CLAUDE.md` symlink (matches `web/`, `api/`): standalone pnpm + package, commands, where code/tests go, the mirrored wire contract, the testing seams. +- **Testability seam (Codex's #1 structural item):** `server.ts` exports + `createAgentServer(run)` / `createRequestListener(run)`; `cli.ts` exports + `runCli(raw, stream, io)` with an injectable engine and output sink (streaming stays live). + Both entrypoints auto-run only when they are the process entry (`src/entry.ts` + `isEntrypoint`), so importing them in tests is inert. +- New tests: `server.test.ts` (5) drives a real server on an ephemeral port with a fake + engine (/health, /run, 400 invalid JSON, 500 failure, NDJSON order); `cli.test.ts` (4) + drives `runCli` with a fake engine + collecting write (one-shot, invalid JSON, failure, + streaming order). +- Deferred (documented): `typescript-eslint` (tsc --strict + prettier is the floor; risks a + rabbit hole in existing engine code) and decomposing `sandbox_agent.ts`/`otel.ts` (opportunistic). + +### Phase 5 partial (2026-06-20) — runner side done; client/release/CI need decisions + +Implemented (self-contained, additive): +- `src/version.ts`: `PROTOCOL_VERSION = 1`, `RUNNER_VERSION` (from package.json), engines, + harnesses. `GET /health` now returns this identity instead of `{status:"ok"}`. Verified + live: `{"status":"ok","runner":"0.1.0","protocol":1,"engines":[...],"harnesses":[...]}`. +- `package.json` version `0.0.0` -> `0.1.0`. +- Fixed the misleading `sdks/python/.../adapters/local.py` docstring (the source of the wheel + worry): the runner is NOT in the wheel; runner-delivery is an open decision. + +Deferred (genuine decisions / other areas / would deepen entanglement): +- Client-side probe: the Python adapter should `GET /health` once and refuse an incompatible + protocol major (SDK `ts_runner.py`/adapters; needs the version-compat policy decided). +- Release ownership + SDK pinning a runner protocol range (decision: does the sidecar version + track the Agenta release or version independently?). +- Sidecar image publishing in CI (`42-railway-build.yml` builds only api/web/services today). +- Config hygiene: `services/oss/src/agent/app.py` raw `os.getenv` -> shared `env` object + (that file is modified by another lane right now; editing it would conflict). + +Final after Phases 4+5: `pnpm test` = **12 files / 60 tests** pass, `pnpm typecheck` exits 0. + +### Commit status (2026-06-20) — pushed as a stacked PR + +Landed as a stacked branch, not in the tangled GitButler workspace. Built in a clean git +worktree off `origin/feat/agent-runner-engines`: + +- Branch **`chore/agent-runner-test-setup`**, **draft PR #4784**, base + **`feat/agent-runner-engines`**. +- 36 files: the test migration, the `createAgentServer`/`runCli` seam, the `dispatch.ts` fix, + `version.ts` + richer `/health`, `AGENTS.md`, the CI job, and these docs. +- On that base: `pnpm test` = **10 files / 47 tests** green, `tsc --noEmit` clean, + `pnpm install --frozen-lockfile` clean. The `run-services-node-unit-tests` CI job is + registered on the PR (skips while draft, like every unit-test job; runs when marked ready). + +Two tests are NOT on this branch because their deps live on sibling branches: +`skills.test.ts` (needs `engines/skills.ts` from `feat/agenta-on-sandbox-agent`) and +`wire-contract.test.ts` (needs the shared Python golden fixtures). They land when those reach +this branch (e.g. `feat/agent-runner-engines` merges/rebases with `feat/agenta-on-sandbox-agent`). + +The original full suite (12 files / 60 tests, incl. skills + wire-contract) still lives intact +in the local workspace and is what lands once the deps converge. Worktree left at +`/tmp/agenta-ts-tests` for iteration. + +## Codex review (xhigh) — 2026-06-20 + +Codex's verdict: the plan is directionally right but too narrow. It fixes test ergonomics +but does not yet make the runner a versioned, supportable server component. Verified findings +we accepted: + +- **Real bug (verified):** `services/agent/src/tools/dispatch.ts` references `callRef` at + lines 88 and 92, but that identifier is not defined in `relayToolCall` (only `spec.callRef` + exists elsewhere). On a Daytona relay failure/timeout, the error-message build throws + `ReferenceError` and masks the real error. A `tsc --noEmit` gate catches it. This is the + strongest argument for the typecheck gate, and it is a one-line fix. +- **`typescript` is not a dependency (verified):** `node_modules/.bin/tsc` does not exist. + The `typecheck` script needs `typescript` added; `tsx` does not provide `tsc`. +- **Phase 3 was naive (accepted):** TS interfaces are erased at runtime, so "round-trip the + golden JSON through `protocol.ts`" does nothing at runtime. Use runtime validation (a zod + schema or explicit structural assertions), plus a separate type-level check. +- **Testability seam (accepted):** export `createServer(runAgent)` / `runCli(runAgent)` so + HTTP and CLI paths can be tested with a fake engine, no live Pi/Claude/sandbox-agent. +- **CI detail (verified):** `test/code-tool.test.ts` spawns `python3`. The Node CI job needs + Python available, or that test gets split out. +- **Bigger gaps (accepted, now Phase 5):** no protocol/version negotiation, no sidecar image + publishing in CI, no release ownership (`package.json` is `0.0.0`), local code-tool + execution has no stated sandbox/resource policy, and `services/oss/src/agent/app.py` reads + `AGENTA_AGENT_*` via raw `os.getenv` instead of the shared env object. +- **Packaging smoking gun (verified):** `sdks/python/.../adapters/local.py` docstring says a + "bundled JS runner ... shipped inside the wheel," but it is marked NOT YET IMPLEMENTED. + Nothing TS is in the wheel today; the future `LocalBackend` plans to put a bundled JS + runner there. That aspirational note is the likely source of the wheel worry. + +Where Codex was wrong: it claimed 9 test files; there are 8 (`skills.test.ts` was already +counted). Minor. + +## What is true in the repo today + +- `services/agent` is a standalone pnpm package (own lockfile, Node 24, ESM, `tsx` runtime, + `strict` tsconfig with `noEmit`). +- 8 unit tests exist under `services/agent/test/`, written as hand-run `tsx` + `node:assert` + scripts. No `pnpm test`, no runner, no aggregation. +- Those tests run in NO CI workflow. `12-check-unit-tests.yml`'s services job is Python-only + (`services/oss/tests/pytest/unit`). +- No typecheck gate runs anywhere, despite `strict`. +- The wire contract is pinned from Python only (`test_wire_contract.py` + golden fixtures); + the TS `protocol.ts` has no test asserting it. +- The repo already standardizes vitest for TS units (`web/packages/*`), with a written + folder spec (`docs/designs/testing/testing.structure.specs.md`). + +## Open decisions + +1. **Runner: vitest vs node:test.** Recommended: vitest (matches `web/packages`, junit + + coverage + watch out of the box). Blocks Phase 1 config only; structure is the same + either way. +2. **Folder layout: move `test/` to `tests/unit/`?** Recommended: yes, to match web packages + and the structure spec. Low-risk mechanical move. +3. **Does `typecheck` failure fail CI?** Recommended: yes. +4. **Add eslint to `services/agent`?** Recommended: defer (optional Phase 4); prettier + + `tsc --strict` is the floor. + +## Progress + +- [x] Inventory the new TS and how it builds/ships +- [x] Confirm the test/CI/typecheck gaps (verified: no CI runs the runner tests) +- [x] Capture the repo's existing TS conventions (vitest, structure spec, CI shape) +- [x] Write context / research / plan +- [x] Phase 1: vitest + scripts + convert tests (green: 42 tests, typecheck gate live) +- [x] Phase 2: CI Node job + junit publish (added to 12-check-unit-tests.yml; YAML + frozen install verified) +- [x] Phase 3: golden-fixture contract test on the TS side (runtime + compile-time guards; both proven) +- [x] Phase 4: `AGENTS.md` + the `createAgentServer`/`runCli` seam + server/cli tests (eslint deferred) +- [~] Phase 5: runner-side version/`/health` + version bump + local.py docstring DONE; client probe, release scheme, image publishing, app.py config hygiene DEFERRED (decisions) +- [ ] Commit: lands with `feat/agent-runner-engines` (shared files block an independent commit) + +## Notes / caveats for the next reader + +- `services/agent` is intentionally NOT in `web/pnpm-workspace.yaml`. Keep it standalone so + the sidecar Docker build stays decoupled from the web dependency graph. +- The golden fixtures live under `sdks/python/oss/tests/pytest/unit/agents/golden/`. The TS + contract test should read them in place, not copy them. +- Frontend TS (`web/oss/src/components/AgentChatSlice/`) is out of scope; it already has a + home and conventions. +- Some runner modules read env at import time; new tests should dynamic-import after setting + env (vitest isolates modules per file). diff --git a/docs/design/agent-workflows/agent-coordination.md b/docs/design/agent-workflows/scratch/agent-coordination.md similarity index 100% rename from docs/design/agent-workflows/agent-coordination.md rename to docs/design/agent-workflows/scratch/agent-coordination.md diff --git a/docs/design/agent-workflows/scratch/branch-cleanup-report.md b/docs/design/agent-workflows/scratch/branch-cleanup-report.md new file mode 100644 index 0000000000..3c1b70e983 --- /dev/null +++ b/docs/design/agent-workflows/scratch/branch-cleanup-report.md @@ -0,0 +1,179 @@ +# Agent workflows branch and PR cleanup report + +Date: 2026-06-22 + +This report compares the local GitButler workspace against the open agent-workflows PR set inspected on 2026-06-22. It has been updated after comparison with `docs/design/agent-workflows/branch-pr-cleanup-report.md`. + +This is a read-only assessment: no branches, commits, or PRs were mutated while gathering the data. + +## Executive summary + +The agent-workflows work is split across several live stacks. Most applied GitButler lanes map cleanly to open PRs. The main operational risk is not the committed lanes; it is the large `zz [unassigned changes]` bucket, which contains newer work that is not safely saved into any branch or PR. + +Main cleanup findings: + +1. `#4774` is a stale duplicate of the runner-engine work and should be closed in favor of `#4778`. [closed] +2. `#4777` is a stale duplicate of the docs work and should be closed in favor of `#4779`. [closed] +3. `#4773` is not deprecated. It is the runner-tools base PR. Locally its commits are folded into the bottom of the applied `feat/agent-runner-engines` lane, so it does not appear as a separate GitButler lane. +4. `#4782` is not a normal GitButler lane because it is based on `integration/agenta-rivet-base`, a merge-based integration branch. Keep it only as an integration/demo branch unless it is rebuilt as a clean linear lane later. [closed] +5. `#4775` remains the one local/remote ambiguity. GitHub reports remote head `592282` with two commits, while current `but status` shows applied lane `feat/agent-playground-ui` at `7120276` only. Treat this as a real discrepancy until reconciled. +6. The unassigned working tree contains significant work saved nowhere else, including the `rivet -> sandbox_agent` rename, several new design-doc folders, test relocation cleanup, local husky hook changes, and broad SDK/service/runner/frontend deltas. + +## Current stack map + +| Stack | PR | Head branch | Base | Applied lane? | Status | +|---|---:|---|---|---|---| +| SDK | `#4771` | `feat/agent-sdk-runtime` | `main` | yes, `sd` | live | +| SDK | `#4772` | `feat/agent-service` | `feat/agent-sdk-runtime` | yes, `rv` | live | +| SDK/tools | `#4785` | `fix/composio-no-auth-toolkits` | `feat/agent-service` | yes, `fi` | live | +| Runner | `#4773` | `feat/agent-runner-tools` | `main` | folded into `nn` base | live base PR | +| Runner | `#4778` | `feat/agent-runner-engines` | `feat/agent-runner-tools` | yes, `nn` | live | +| Runner | `#4774` | `feat/agent-runner-engine` | `feat/agent-runner-tools` | no | superseded; close | +| Frontend | `#4775` | `feat/agent-playground-ui` | `main` | yes, `pl`, but local display differs from PR head | reconcile | +| Frontend | `#4780` | `fe-feat/agent-chat-ui-slice` | `feat/agent-playground-ui` | yes, `ha` | live | +| Hosting | `#4776` | `chore/agent-hosting-compose` | `main` | yes, `st` | live | +| Sandbox-agent | `#4786` | `chore/sandbox-agent-core` | `main` | yes, `cor` | live | +| Sandbox-agent | `#4787` | `chore/sandbox-agent-railway` | `chore/sandbox-agent-core` | yes, `ra` | live | +| Sandbox-agent | `#4788` | `chore/sandbox-agent-kubernetes` | `chore/sandbox-agent-core` | yes, `ku` | live | +| Sandbox-agent | `#4789` | `ci/sandbox-agent-image` | `chore/sandbox-agent-core` | yes, `ci` | live | +| Docs | `#4779` | `docs/agent-workflows` | `main` | yes, `do` | live | +| Docs | `#4777` | `docs/agent-workflows-design` | `main` | no | superseded; close | +| Rivet/Agenta harness | `#4782` | `feat/agenta-on-rivet` | `integration/agenta-rivet-base` | no | merge-based, off-workspace | + +Related but outside the original list: + +| PR | Branch | Status | +|---:|---|---| +| `#4784` | `chore/agent-runner-test-setup` | draft, stacked on `#4778` | +| `#4783` | `claude/git-butler-agent-prs-b227dz` | draft, agent-adjacent design doc | + +## Question 1: PR branches not applied locally + +### `#4774` / `feat/agent-runner-engine` + +Deprecated. Close it. + +This is the older singular-named runner-engine PR. The local applied lane and current live PR are `feat/agent-runner-engines` / `#4778`. `#4778` contains the runner-engine work plus later fixes, including the Python3 / Pi extension rebuild work. + +### `#4777` / `docs/agent-workflows-design` + +Deprecated. Close it. + +This is the older docs PR. The applied docs lane and current live PR are `docs/agent-workflows` / `#4779`, which includes the original design docs plus the QA matrix, findings, and driver work. + +### `#4773` / `feat/agent-runner-tools` + +Keep it. + +This is the runner-tools base PR, not an orphan. Locally the runner-tools commits sit at the bottom of the applied `nn` lane for `feat/agent-runner-engines`, which is why there is no separate applied GitButler lane for `feat/agent-runner-tools`. That is acceptable for the current stack as long as GitHub continues to show `#4778` based on `feat/agent-runner-tools`. + +### `#4782` / `feat/agenta-on-rivet` + +Keep only if it remains useful as an integration branch; otherwise rebuild or close later. + +This PR is based on `integration/agenta-rivet-base`, which is a merge-based bundle of the in-flight agent-workflows stacks. GitButler series need linear history, so this branch is deliberately off-workspace. The practical risk is drift: as the underlying SDK/service/runner/hosting/docs branches change, this integration branch must be manually refreshed. + +The branch also still uses the old `rivet` naming while the rest of the work is moving toward `sandbox-agent`. If it remains alive, it should eventually be rebuilt or renamed after the sandbox-agent rename lands. + +### `#4775` / `feat/agent-playground-ui` + +Reconcile before merging. + +Current GitHub metadata reports: + +| Field | Value | +|---|---| +| PR head | `592282099d8394d1e194e33550e6ec940d66d63f` | +| Commits | `7120276dd9` then `592282099d` | +| Base | `main` | + +Current `but status` reports the applied `pl` lane as: + +| Field | Value | +|---|---| +| Local displayed head | `7120276dd9` | +| Commit shown | `feat(frontend): agent config playground controls` | + +That means the remote PR has a review-fix commit that is not shown in the applied GitButler lane display. This may be a GitButler display/stacking artifact, or the local lane may be behind the remote branch. Do not force-push or rewrite `#4775` until this is resolved explicitly. + +## Question 2: Local work without an open PR + +For committed/applied GitButler lanes, every lane in the agent-workflows scope has an open PR or is part of a known PR stack. + +The work without an open PR is the uncommitted working tree. Because it is not committed to any lane, it has no PR by definition. + +## Question 3: Local changes not saved elsewhere + +Yes. This is the main risk. + +The other cleanup report records 77 tracked files changed, 32 untracked files, and net `+1623/-2504` in the working tree. The current `but status` also shows both cleanup reports themselves as unassigned files. + +Important working-tree-only clusters: + +| Cluster | Evidence from current status / other report | Suggested owner | +|---|---|---| +| `rivet -> sandbox_agent` code rename | New/renamed `sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py`, `services/agent/src/engines/sandbox_agent.ts`; old `rivet.py` / `rivet.ts` deleted or renamed | Fold into `#4786` / `chore/sandbox-agent-core`, or create a new `chore/sandbox-agent-rename` lane stacked on `#4786` | +| Test relocation cleanup | Old `services/agent/test/*.test.ts` files deleted after `#4786` introduced `services/agent/tests/unit/*` | Fold into `#4786` | +| New design-doc folders | `provider-model-auth/`, `skills-config/`, `model-config/`, `code-tool-sandbox/`, `harness-capabilities/`, `typescript-structure/`, QA plan files | Fold into `#4779`, or split into a follow-up docs lane if `#4779` should stay stable | +| Local husky/user hooks | `.husky/post-checkout-user`, `.husky/pre-commit-user`, tracked hook edits, `.gitignore` edits | Keep local/unassigned, discard, or move to a small chore branch only if intended for the repo | +| SDK/service/runner/frontend deltas | Broad edits across `sdks/python/agenta/sdk/agents/**`, `services/agent/**`, `services/oss/src/agent/**`, `web/oss/src/components/AgentChatSlice/state/sessions.ts` | Diff per file and absorb into owning lanes only after confirming intent | +| Cleanup reports | `docs/design/agent-workflows/branch-cleanup-report.md`, `docs/design/agent-workflows/branch-pr-cleanup-report.md` | Decide whether to keep one, both, or fold into docs lane | + +Important GitButler caution: do not run plain `but commit` here. It would sweep all unassigned changes into one branch. Use file assignment first, for example `but rub `, then commit with `--only`. + +## Recommended cleanup plan + +1. Take a GitButler safety snapshot before branch surgery: + +```bash +but oplog snapshot -m "pre-cleanup 2026-06-22" +``` + +2. Close stale duplicate PRs: + +`#4774` is superseded by `#4778`. + +`#4777` is superseded by `#4779`. + +3. Do not close `#4773`. + +Treat `#4773` as the live runner-tools base PR. Its absence as a separate applied GitButler lane is expected because its commits are folded into the `nn` lane locally. + +4. Resolve `#4775` before any push/rewrite. + +The remote PR head is `592282`, but current `but status` displays local `pl` at `7120276`. Determine whether this is only GitButler display behavior or whether the local lane is missing the remote review-fix commit. + +5. Decide the future of `#4782`. + +Either keep `integration/agenta-rivet-base` as a throwaway integration target, or rebuild the single harness commit as a clean linear lane after the sandbox-agent rename lands. Until then, do not treat it as a normal merge-ready PR. + +6. Triage unassigned changes by owner: + +| Unassigned bucket | Likely owner | +|---|---| +| SDK deltas | `feat/agent-sdk-runtime` | +| Service deltas | `feat/agent-service` | +| Runner wire/tool deltas | `feat/agent-runner-tools` | +| Runner engine/server/tracing deltas | `feat/agent-runner-engines` | +| Sandbox-agent rename and test relocation | `chore/sandbox-agent-core` or new `chore/sandbox-agent-rename` | +| Hosting compose deltas | `chore/agent-hosting-compose` or sandbox-agent deployment branches | +| Docs deltas | `docs/agent-workflows` or a new docs follow-up | +| Hook/plumbing changes | Keep unassigned, discard, or separate chore PR | + +7. Only after assignment, commit each lane separately and push. + +## Landing order once clean + +1. SDK stack: `#4771` -> `#4772` -> `#4785` +2. Runner stack: `#4773` -> `#4778`, then draft `#4784` if kept +3. Frontend stack: `#4775` -> `#4780` +4. Hosting: `#4776` +5. Sandbox-agent stack: `#4786` -> `#4787`, `#4788`, `#4789` +6. Docs: `#4779` +7. Rivet/Agenta harness: `#4782` last, or rebuilt after the sandbox-agent rename + +## One-line answers + +1. PR branches not applied locally: close `#4774` and `#4777`; keep `#4773`; treat `#4782` as merge-based/off-workspace; reconcile `#4775` because GitHub and GitButler currently disagree on its visible head. +2. Local work with no PR: no committed applied lane lacks a PR, but the uncommitted working tree has no PR. +3. Local changes saved nowhere else: yes, significantly. The `rivet -> sandbox_agent` rename, new design-doc folders, test relocation cleanup, husky/user-hook changes, and broad SDK/service/runner/frontend edits are working-tree-only until triaged and committed. diff --git a/docs/design/agent-workflows/scratch/branch-pr-cleanup-report.md b/docs/design/agent-workflows/scratch/branch-pr-cleanup-report.md new file mode 100644 index 0000000000..db52c53fe4 --- /dev/null +++ b/docs/design/agent-workflows/scratch/branch-pr-cleanup-report.md @@ -0,0 +1,204 @@ +# Agent-workflows branch & PR cleanup report + +Date: 2026-06-22 +Scope: the agent-workflows PR set (#4771–#4789) vs the GitButler workspace on +`gitbutler/workspace`. + +This is a findings + plan document. Nothing has been changed. Review before acting. + +## TL;DR + +- The agent-workflows work is split into 7 stacks. Six are applied as GitButler + lanes and map cleanly to PRs. One (the rivet/Agenta-harness integration) is a + merge-based branch that GitButler cannot stack, so it lives off-workspace. +- **Two PRs are stale duplicates and should be closed:** `#4774` + (feat/agent-runner-engine) is superseded by `#4778` (feat/agent-runner-engines); + `#4777` (docs/agent-workflows-design) is superseded by `#4779` + (docs/agent-workflows). +- **A large body of uncommitted work exists only in the working tree** (77 tracked + files changed, 32 untracked, net +1623/-2504). The headline pieces — a + `rivet → sandbox_agent` code rename and ~6 new design-doc folders — are saved + nowhere else. This is the main risk. +- Every applied lane is already pushed and in sync with its origin branch. + +## The stacks (PR ↔ local lane map) + +| Stack | PR | head branch | base | Applied lane? | Status | +|---|---|---|---|---|---| +| A. SDK | #4771 | feat/agent-sdk-runtime | main | yes (`sd`) | live | +| A. SDK | #4772 | feat/agent-service | feat/agent-sdk-runtime | yes (`rv`) | live | +| A. SDK | #4785 | fix/composio-no-auth-toolkits | feat/agent-service | yes (`fi`) | live | +| B. Runner | #4773 | feat/agent-runner-tools | main | folded into `nn` base | live (base PR) | +| B. Runner | #4778 | feat/agent-runner-engines | feat/agent-runner-tools | yes (`nn`) | **live** | +| B. Runner | #4774 | feat/agent-runner-engine | feat/agent-runner-tools | no | **SUPERSEDED → close** | +| B. Runner | #4784 (draft) | chore/agent-runner-test-setup | feat/agent-runner-engines | no | draft, stacked on #4778 | +| C. Frontend | #4775 | feat/agent-playground-ui | main | yes (`pl`) | live | +| C. Frontend | #4780 | fe-feat/agent-chat-ui-slice | feat/agent-playground-ui | yes (`ha`) | live | +| D. Hosting | #4776 | chore/agent-hosting-compose | main | yes (`st`) | live | +| E. Sandbox-agent | #4786 | chore/sandbox-agent-core | main | yes (`cor`) | live | +| E. Sandbox-agent | #4787 | chore/sandbox-agent-railway | chore/sandbox-agent-core | yes (`ra`) | live | +| E. Sandbox-agent | #4788 | chore/sandbox-agent-kubernetes | chore/sandbox-agent-core | yes (`ku`) | live | +| E. Sandbox-agent | #4789 | ci/sandbox-agent-image | chore/sandbox-agent-core | yes (`ci`) | live | +| F. Docs | #4779 | docs/agent-workflows | main | yes (`do`) | **live** | +| F. Docs | #4777 | docs/agent-workflows-design | main | no | **SUPERSEDED → close** | +| G. Rivet harness | #4782 | feat/agenta-on-rivet | integration/agenta-rivet-base | no | merge-based, off-workspace | +| G. Rivet harness | (no PR) | integration/agenta-rivet-base | — | no | merge bundle of A–F | + +Related, not in the cleanup list but agent-adjacent: +- `#4783` (draft) `claude/git-butler-agent-prs-b227dz` → main — "Sandbox runtime + metering — scoped-resource design" (design doc). + +## Question 1 — PR branches not applied locally: deprecated, mistake, or fine? + +Five branches have PRs (or are PR bases) but are not GitButler lanes: + +1. **`feat/agent-runner-engine` (#4774) — DEPRECATED, close it.** + It is the older sibling of `feat/agent-runner-engines` (#4778). Same logical + commits, different SHAs, but #4778 additionally has + `fix(agent): install python3 and rebuild the Pi extension` and the + `extension-tools.test.ts` + `Dockerfile.dev` work. The plural-named #4778 is the + one applied locally and the one we keep. Singular #4774 should be closed. + +2. **`docs/agent-workflows-design` (#4777) — DEPRECATED, close it.** + Superseded by `docs/agent-workflows` (#4779). #4779 contains everything in #4777 + plus the QA matrix, findings, and driver (28 extra files / +2921 lines). #4779 is + the applied lane. + +3. **`feat/agent-runner-tools` (#4773) — NOT deprecated, keep.** + It is the genuine base of the runner stack. Its two commits (`wire protocol`, + `tool bridge secrets`) sit at the bottom of the `nn` lane, which is why it is not + a separate lane. On GitHub the #4778 diff is computed from the merge-base, so the + #4773 → #4778 split is coherent. Minor wart: the "keep tool bridge secrets + runner-side" commit was re-created with a different SHA inside #4778, so it + appears in both branches' history (GitHub's 3-dot diff hides this). Harmless; + leave as is. + +4. **`feat/agenta-on-rivet` (#4782) + `integration/agenta-rivet-base` — NOT a + mistake, but fragile.** + `integration/agenta-rivet-base` is a **merge commit** that bundles the SDK, + service, runner, hosting, and docs stacks into one branch; `#4782` adds a single + harness commit (`run the Agenta harness on the rivet/ACP backend with forced + skills`) on top. It is not applied as a lane because GitButler cannot stack a + merge-based branch — this is the documented "series need linear history" gotcha. + So it is deliberately off-workspace, used as an integration/demo target. Two + concerns: (a) it still uses the old **rivet** naming while the rest of the work is + moving to **sandbox-agent**, and (b) it will drift as the underlying stacks change. + +No branch here is an accidental orphan. The only true deletions are the two +superseded duplicates (#4774, #4777). + +## Question 2 — Local work without an open PR + +- **Every applied lane already has a PR**, and every lane is pushed and in sync with + its origin branch. There is no committed-but-unpushed or committed-but-PR-less lane + inside the agent-workflows scope. +- The only "work without a PR" is the **uncommitted working-tree changes** (see Q3) — + they are not committed to any lane, so they have no PR by definition. +- There are also many unrelated local branches in the repo (e.g. `feat/agent-tools-wp7`, + `feat/agent-harness-port`, POC branches). Those are out of scope for this cleanup + and not part of the #4771–#4789 set. + +## Question 3 — Local changes not saved anywhere else (the real risk) + +There is substantial uncommitted work on `gitbutler/workspace` that is **not in any +branch, local or remote**: + +- **`rivet → sandbox_agent` code rename (working-tree only):** + - new: `sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py` + - new: `services/agent/src/engines/sandbox_agent.ts` + - deleted: `rivet.py`, `rivet.ts` + No remote branch contains `sandbox_agent.py`. This is the missing other half of the + sandbox-agent rename: the `chore/sandbox-agent-*` branches (#4786–#4789) renamed the + deployment/runner surface but **left the engine + SDK adapter named `rivet`**. The + working tree finishes that rename and is uncommitted. + +- **New design-doc folders (working-tree only):** + `provider-model-auth/`, `skills-config/`, `model-config/`, `code-tool-sandbox/`, + `harness-capabilities/`, plus `feature-matrix-test.md`, `qa/cleanup-plan.md`, + `qa/implementation-plan.md`. None exist in any remote branch. + (`typescript-structure/` is the one exception — it also lives in + `chore/agent-runner-test-setup`, draft #4784.) + +- **Test relocation tail:** the 8 `services/agent/test/*.test.ts` deletions are the + cleanup half of the relocation to `services/agent/tests/unit/*` that #4786 (`cor`) + introduced. #4786 added the new layout but did not delete the old files; the working + tree deletes them. So this deletion belongs with the #4786 stack. + +- **Local-only husky hooks:** `.husky/post-checkout-user`, `.husky/pre-commit-user` + (plus edits to the tracked husky scripts and `.gitignore`). Likely local + developer-machine config, not feature work. + +- Plus broad edits across `sdks/python/agenta/sdk/agents/*`, `services/agent/src/*`, + `services/oss/src/agent/*`, and `web/oss/.../AgentChatSlice` — net **+1623/-2504** + across 77 tracked files. Because these overlap files already committed in the lanes, + they represent a **newer, diverged version** sitting on top of what the PRs contain. + +**Risk:** all of the above lives only in the working tree of one machine. A bad +`but` operation, a reset, or a worktree mishap loses it. It needs to be triaged into +lanes/branches and committed, or deliberately parked. + +## Recommended plan + +Do these in order. Steps 1–2 are safe and reversible; step 3 needs your decisions. + +### 1. Close the two duplicate PRs +- Close **#4774** (feat/agent-runner-engine) with a note pointing to #4778. +- Close **#4777** (docs/agent-workflows-design) with a note pointing to #4779. +- After closing, delete their remote branches (`feat/agent-runner-engine`, + `docs/agent-workflows-design`) and the local refs, so the rename stops being + ambiguous. + +### 2. Snapshot before touching the workspace +- `but oplog snapshot -m "pre-cleanup 2026-06-22"` so any lane surgery is reversible. + +### 3. Triage the uncommitted work (the important part) +Assign each cluster to a destination, then commit. Suggested mapping: + +- **`rivet → sandbox_agent` rename** → this is the conceptual completion of the + sandbox-agent line. Decide one of: + - fold it into the `#4786` `chore/sandbox-agent-core` lane (`cor`) so the rename is + complete in one place, **or** + - give it its own lane `chore/sandbox-agent-rename` stacked on `cor`. + Either way it must also update the SDK/service references and the rivet harness + (#4782) eventually. +- **`services/agent/test/*` deletions** → fold into the `#4786` lane (`cor`) next to + the relocation that created `tests/unit/`. +- **Design-doc folders** (`provider-model-auth/`, `skills-config/`, `model-config/`, + `code-tool-sandbox/`, `harness-capabilities/`, `feature-matrix-test.md`, + `qa/*-plan.md`) → fold into the docs lane `#4779` (`do`), or a new + `docs/agent-workflows-more` lane if you want to keep #4779 scoped to what is already + in review. +- **`.husky/*-user`, `.gitignore`, husky script edits** → if these are local-machine + config, keep them unassigned (do not commit), or move to a small + `chore/husky-user-hooks` branch (a branch of that name already exists locally — + check whether this belongs there). +- **Remaining sdk/service/web edits** → diff each against what the lane already has; + these are the diverged "newer version". Decide per file whether to `but absorb` + into the owning lane or drop. + +### 4. Decide the rivet-harness branch's future (#4782) +- Keep `integration/agenta-rivet-base` as a throwaway integration target, **or** + rebuild the single harness commit `955d1cc92a` as a clean lane on top of the real + stack once the `sandbox_agent` rename lands — and rename the branch off "rivet". +- Until then, expect it to drift; do not treat it as a mergeable PR. + +### 5. Land order once the tree is clean +Bottom-up, each PR's base set to its parent so each shows only its own diff: +1. A: #4771 → #4772 → #4785 +2. B: #4773 → #4778 (then draft #4784) +3. C: #4775 → #4780 +4. D: #4776 +5. E: #4786 → {#4787, #4788, #4789} +6. F: #4779 +7. G: #4782 last (or rebuilt per step 4) + +## One-line answers + +1. **Branches in a PR but not applied locally:** #4774 and #4777 are stale duplicates + → close them. #4773 (runner base), #4782 + integration branch (merge-based harness) + are intentional, not mistakes — keep, but rename #4782 off "rivet". +2. **Local work with no PR:** none among the committed lanes (all pushed, all have + PRs). Only the uncommitted working tree has no PR. +3. **Local changes saved nowhere else:** yes, and it is significant — the + `rivet → sandbox_agent` rename and ~6 design-doc folders exist only in the working + tree. Triage and commit before any risky `but` operation. diff --git a/docs/design/agent-workflows/scratch/branch-pr-cleanup-status.md b/docs/design/agent-workflows/scratch/branch-pr-cleanup-status.md new file mode 100644 index 0000000000..69bb591b86 --- /dev/null +++ b/docs/design/agent-workflows/scratch/branch-pr-cleanup-status.md @@ -0,0 +1,178 @@ +# Agent-workflows branch & PR cleanup — status tracker + +Last updated: 2026-06-22 +Companion to [`branch-pr-cleanup-report.md`](./branch-pr-cleanup-report.md) (full findings). + +Legend: ✅ done · 🔄 in progress · ⬜ not started · 🧭 needs a decision + +## Decisions locked + +- Close **#4774** (feat/agent-runner-engine) as superseded by **#4778**, after + salvaging any still-relevant review context into #4778. +- Close **#4777** (docs/agent-workflows-design) as superseded by **#4779**. +- Close **#4782** (feat/agenta-on-rivet) and abandon `integration/agenta-rivet-base`. + Not worth more investment right now. + +## Progress + +| # | Item | State | Notes | +|---|---|---|---| +| 1 | Carry #4774 context into #4778, then close #4774 | ✅ | #4774 CLOSED. Carry-over [comment](https://github.com/Agenta-AI/agenta/pull/4778#issuecomment-4767220910) on #4778 salvaged 3 live items (see below). | +| 2 | Close #4777 | ✅ | Closed by Mahmoud. | +| 3 | Close #4782 + abandon `integration/agenta-rivet-base` | ✅ | #4782 CLOSED. integration branch abandoned. | +| 4 | Sync #4775 playground lane up to origin | ✅ | Playground lane in sync with origin (`592282099d`). | +| 5 | Re-stack #4780 on the pushed playground head | ✅ | #4780 committed + pushed, in sync. | +| 6 | Tidy #4773 → #4778 stack (duplicate commit) | ⏸️ | Deferred to the parent-branch restack (see runner-stack note). | +| 7 | Triage the uncommitted working-tree work | ✅ | Code rename + test deletions + all docs committed & pushed. Remaining = parked/temp/deferred only. | +| 8 | Push everything; PRs in sync | ✅ | 11 branches pushed (rv/fi force-pushed). All 7 open PRs match local. 4 new docs branches created on origin. | +| 9 | Delete remote branches of closed PRs | ⬜ | `feat/agent-runner-engine`, `docs/agent-workflows-design`, `feat/agenta-on-rivet`, `integration/agenta-rivet-base`. Ready to delete. | +| 10 | Runner stack (#4773 series + apply #4784) | ⏸️ | Deferred to the review phase. In-place apply blocked by rename conflict (see note). | +| 11 | Parent branch `big-agents` (create, retarget, switch target) | ✅ | Done 2026-06-22 (see below). | + +## Parent branch `big-agents` — DONE 2026-06-22 +- Created `big-agents` off main, pushed (`origin/big-agents` at `a97e608369`). +- GitButler target switched `origin/main` → `origin/big-agents` (unapply all → `but config + target` → re-apply each branch). NOTE: `but unapply` has no `--force` flag; and re-applying + a stack base does NOT bring its stacked children — apply each branch explicitly. +- Retargeted the 6 bottom PRs to `big-agents`: #4771, #4773, #4775, #4776, #4779, #4786 + (via `gh api .../pulls/N -X PATCH -f base=big-agents`). Stacked PRs keep their parents. +- Fixed the #4775/#4780 skew: rebased `ha` (chat-ui) onto the playground tip `592282` in a + throwaway worktree, re-applied, force-pushed #4780. +- Final: all 16 project lanes applied (only project lanes), all in sync with origin. +- Next: review each PR vs `big-agents`, assemble the deferred runner stack, merge into + `big-agents`, then `big-agents` → main. + +## What's next (in priority order) + +### A. Finish the closes (cheap, reversible) +- Wait for the subagent to confirm #4774 is closed and the carry-over comment is on #4778. +- Confirm #4777 and #4782 show closed. +- Then delete the four dead remote branches (item 8). Keep the local refs until we are + sure nothing references them. + +### B. Fix the #4775 / #4780 playground stack (correctness) +The report's first draft said this branch was "in sync." That was wrong. Corrected: +- Origin and PR **#4775** are at `592282` = `fix(frontend): address agent playground review`. +- The local GitButler lane is at `7120276` = its **parent**. So the **lane is one commit + BEHIND** origin/PR, missing the pushed review-fix. +- The local **#4780** chat-ui lane is stacked on the behind commit `7120276`, not on the + pushed playground head, so the review-fix is missing underneath it too. +- Fix direction: pull the lane UP to origin (`592282`), then re-stack #4780 on top. Do + NOT push the lane over the PR — that would drop the pushed review commit. +- Low data-loss risk: the extra commit is safe on origin. + +### C. Optional: tidy the #4773 → #4778 runner stack +- Origin `feat/agent-runner-tools` tip (`46062dc6c9`) is not an ancestor of + `feat/agent-runner-engines`. They fork at the wire-protocol commit, and #4778 re-does + the `keep tool bridge secrets runner-side` commit under a new SHA, so that change shows + in both PR diffs. +- Minor. If we want a clean stack, rebase #4778 onto the real tip of #4773. Otherwise + GitHub's merge-base diff keeps it readable. Low priority. + +### D. Triage the uncommitted working-tree work (the real risk) 🧭 + +**Decision taken: Option A — distribute each file's changes into its owning lane.** End +goal is to stack all these PRs against a new parent branch (e.g. a `agents` GitButler +branch), then review and merge there, so per-lane precision matters less than getting the +work committed roughly in the right place. Safety snapshot taken: `but oplog restore +bd31da6592`. + +**Done — code-side `rivet → sandbox-agent` rename distributed (unpushed local commits):** +| Lane / PR | New commit | Files | +|---|---|---| +| #4771 `feat/agent-sdk-runtime` | `2a7c1299b2` | 16 (SDK, incl. `rivet.py → sandbox_agent.py`) | +| #4772 `feat/agent-service` | `490f304ad3` | 4 (`services/oss/src/agent/**`) | +| #4778 `feat/agent-runner-engines` | `348240268e` | 21 (`services/agent/src/**`, incl. `rivet.ts → sandbox_agent.ts`) | +| #4776 `chore/agent-hosting-compose` | `14ab328e6d` | 1 (dev compose) | +| #4780 `fe-feat/agent-chat-ui-slice` | `1da72d5fda` | 1 (`generateId` swap, not a rename) | + +Verified: zero `rivet` refs remain in code; both renames captured atomically. + +**New design-doc folders — decision taken: each on its own parallel branch off main.** +| Branch | Folders | Commit | State | +|---|---|---|---| +| `docs/agent-model-config-and-provider-auth` | `provider-model-auth/` + `model-config/` | `8fa45cd8a0` | ✅ committed | +| `docs/agent-skills-config` | `skills-config/` | `ef5d62e62e` | ✅ committed | +| `docs/agent-code-tool-sandbox` | `code-tool-sandbox/` | `0fa7ee286c` | ✅ committed (30 n8n redacted; home-dir path genericized) | +| `docs/agent-harness-capabilities` | `harness-capabilities/` | `d98415923c` | ✅ committed (no n8n found; scan clean) | + +`n8n` confirmed present in 4 `code-tool-sandbox/` files; subagents redact to "redacted" +and also scan for other sensitive mentions before commit. + +**Existing docs + QA reports → #4779 (done):** +- 28 files committed to `docs/agent-workflows` as `8b07fca4d8` (25 rename-ref edits to + existing docs + `feature-matrix-test.md` + `qa/cleanup-plan.md` + `qa/implementation-plan.md`). + Gotcha hit: `ruff-format` reformatted `qa/scripts/run_matrix.py` and GitButler aborted + the commit; fixed by formatting the file first, then committing. + +**`services/agent/test/` deletions → #4778 (done):** +- 8 old test files removed, committed to `feat/agent-runner-engines` as `8f6e48b9a8` + (`test(agent): remove old test/ files relocated to tests/unit`). Per Mahmoud: if the + deletion is meaningful, delete them — it is (the files were relocated to `tests/unit/` + in #4786). + +**Runner stack assembly (#4773 series + apply #4784) — BLOCKED in-place. 🧭** +- Tried (snapshot `5c3b9d9641` taken first): `but apply chore/agent-runner-test-setup`. + GitButler aborted on conflict (`on_workspace_conflict=AbortAndReportConflictingStacks`) + and left the workspace untouched (15 lanes intact, nothing lost). +- Root cause: #4784 was written for the old `rivet` naming. We just renamed #4778 (its + base) to `sandbox-agent`. So #4784's changes to 6 shared source files (`cli.ts`, + `server.ts`, `tools/dispatch.ts`, `package.json`, `tsconfig.json`, `pnpm-lock.yaml`) no + longer fit on the renamed engines. (The 8 `test/` deletions are NOT a conflict — both + sides delete them.) +- Chicken-and-egg: to apply #4784 it must first carry the rename, but it is unapplied, so + editing it is the awkward path. GitButler won't apply-with-conflict to let us resolve. +- DECISION: assemble the runner stack during the parent-branch restack, where #4784 gets + rebuilt on the new base and the rename folds in once, cleanly. Not worth fragile in-place + surgery now. `typescript-structure/` edits are backed up at `/tmp/ts-structure-backup/` + and still live in the working tree; they fold into #4784 at that point. + +**Still unassigned, parked:** +- **husky/.gitignore (5 files)** — per Mahmoud, GitButler/local hook config. Leave alone. +- **Three session tracker docs** (`branch-cleanup-report.md`, `branch-pr-cleanup-report.md`, + `branch-pr-cleanup-status.md`) — session scratch, left unassigned. + +### (legacy notes from the original plan) +Net +1623/-2504 across 77 tracked files plus 32 untracked, committed to no lane and +pushed nowhere. Assign each cluster to an owner, then commit per lane (never a blanket +`but commit`). Proposed mapping: + +- **`rivet → sandbox_agent` code rename** (new `sandbox_agent.py`, `sandbox_agent.ts`; + `rivet.py`/`rivet.ts` deleted) — exists in no branch. This is the missing other half + of the sandbox-agent rename that #4786–#4789 started on the deployment surface. Decide: + fold into #4786 (`chore/sandbox-agent-core`) or give it its own lane. Must also update + SDK/service references and eventually the harness work. +- **`services/agent/test/*` deletions** — the cleanup tail of the relocation to + `tests/unit/` that #4786 introduced. Fold into #4786. +- **New design-doc folders** (`provider-model-auth/`, `skills-config/`, `model-config/`, + `code-tool-sandbox/`, `harness-capabilities/`, `feature-matrix-test.md`, + `qa/*-plan.md`) — exist in no branch (except `typescript-structure/`, which is in draft + #4784). Fold into #4779 docs lane or a new docs follow-up. +- **`.husky/*-user`, `.gitignore`, husky script edits** — likely local-machine config. + Keep unassigned or move to a small chore branch. Confirm with Mahmoud. +- **Remaining sdk/service/web edits** — diff each against the owning lane; `but absorb` + or drop per file. These are the diverged "newer version" of committed work. + +Before any of this: `but oplog snapshot -m "pre-cleanup 2026-06-22"`. + +## Live findings carried from #4774 into #4778 (worth fixing before merge) +Posted as a [comment on #4778](https://github.com/Agenta-AI/agenta/pull/4778#issuecomment-4767220910): +- CLI `process.exit` in `src/cli.ts` can truncate the JSON result on stdout (Node may + exit before the write flushes). +- Streaming client-disconnect abort in `src/server.ts` reaches `runRivet` only, not + `runPi`, so a disconnected client leaves an in-process Pi run executing. +- Design caveat (keep, do not "fix"): `server.ts` deliberately swallows background + rejections from the rivet SDK so one stray rejection cannot kill the sidecar. + +## Related PRs noticed (not part of this cleanup, no action yet) +- **#4784** (draft) `chore/agent-runner-test-setup` → #4778: vitest suite + CI. Keep, + stacked on #4778. +- **#4783** (draft) `claude/git-butler-agent-prs-b227dz`: sandbox metering design doc. + +## Land order once the tree is clean +1. SDK: #4771 → #4772 → #4785 +2. Runner: #4773 → #4778 (then draft #4784) +3. Frontend: #4775 → #4780 +4. Hosting: #4776 +5. Sandbox-agent: #4786 → {#4787, #4788, #4789} +6. Docs: #4779 diff --git a/docs/design/agent-workflows/scratch/capability-architecture.md b/docs/design/agent-workflows/scratch/capability-architecture.md new file mode 100644 index 0000000000..94b8ce2bd0 --- /dev/null +++ b/docs/design/agent-workflows/scratch/capability-architecture.md @@ -0,0 +1,9 @@ +# Capability configuration: architecture sketch (graduated) + +This scratch exploration became a project on 2026-06-23. The canonical design now lives in +`../projects/capability-config/proposal.md`, with `context.md`, `plan.md`, `research.md`, and +`status.md` alongside it. + +Start at `../projects/capability-config/README.md`. + +The current-state research that pairs with this work stays in `capability-map.md`. diff --git a/docs/design/agent-workflows/scratch/capability-map.md b/docs/design/agent-workflows/scratch/capability-map.md new file mode 100644 index 0000000000..9dd5a69a65 --- /dev/null +++ b/docs/design/agent-workflows/scratch/capability-map.md @@ -0,0 +1,296 @@ +# Harness capability map: web, execute, read, write + +> This is the current-state research for the **capability-config** project. The design and plan +> built on it live in `../projects/capability-config/`. Keep this doc as the deep +> web/exec/read/write reference; the project's `research.md` summarizes and points back here. + +What can the `pi` and `claude` harnesses actually do (access the web, execute code, read +files, write files), what is on by default, what can we configure, and how the sandbox +backend (Daytona vs local sidecar) changes the answer. + +Scope: the **sandbox-agent** runner only (`services/agent/src/engines/sandbox_agent.ts`, +environments E2 local and E3 Daytona). The in-process Pi POC engine (`engines/pi.ts`) is out +of scope, as requested. Note the `pi` *harness* running on sandbox-agent is in scope; only the +separate in-process Pi engine is not. All claims cite code or the installed package source. + +## The one thing to understand first: three independent layers + +A capability like "can run code" is not a single switch. It is the AND of three layers, and +they live in three different places: + +1. **The harness's built-in toolset.** Each coding agent ships its own tools. Pi gives the + model `read`, `write`, `edit`, `bash` by default + (`node_modules/@earendil-works/pi-coding-agent/README.md:96`). Claude (the Claude Agent + SDK) ships `Read`, `Write`, `Edit`, `Bash`, `Glob`, `Grep`, `NotebookEdit`, `WebFetch`, + `WebSearch`, `Task`, `TodoWrite`, `KillShell` + (`node_modules/.pnpm/@anthropic-ai+claude-agent-sdk@0.2.83.../sdk-tools.d.ts`). This layer + decides *which tools the model can call at all*. + +2. **The permission gate.** When a tool wants to run, the harness may raise an ACP permission + request. Our runner answers it with a fixed policy responder + (`responder.ts:44`, `permissions.ts:21`). Default is auto-allow; `permissionPolicy: "deny"` + or `SANDBOX_AGENT_DENY_PERMISSIONS=true` flips it to deny-everything + (`responder.ts:57-62`). This layer decides *whether a call the model made is allowed to + execute*. It is all-or-nothing, not per-tool. + +3. **The sandbox environment.** The tool runs *somewhere*. `bash` can only `curl` the web if + the sandbox has network. `python script.py` only runs if python is installed. The "sandbox" + is the Daytona VM (E3) or the local sidecar host itself (E2). This layer decides *what the + tool's execution can actually reach and do*. + +Capability = (harness ships the tool) AND (permission policy allows it) AND (the environment +can carry it out). Most of the surprises below come from confusing these three. + +## Per-harness default capabilities + +### `pi` harness (and `agenta`, which is `pi` with forced extras) + +| Capability | Default | Mechanism | +| --- | --- | --- | +| Read files | **Yes** | built-in `read` tool (`README.md:96`) | +| Write files | **Yes** | built-in `write` + `edit` tools | +| Execute code / shell | **Yes** | built-in `bash` tool | +| Access the web | **No dedicated tool** | Pi has no `WebFetch`/`WebSearch`. The only web path is `bash` running `curl`/`wget`, which needs network in the sandbox | + +Pi has **no permission gating by design** ("It intentionally does not include ... permission +popups", `docs/usage.md:303`; the probe reports `permissions: false` for pi, +`capabilities.ts:24-35`). So on Pi the permission policy layer is a no-op: `bash` and `write` +run without ever asking, and `permissionPolicy: "deny"` does **not** stop them, because Pi +never raises the request the responder would answer. + +`agenta` is the same engine. It additionally **forces** `read` + `bash` on +(`agenta_builtins.py:52`) and forces a skill, but it cannot remove the default write/edit. + +### `claude` harness + +| Capability | Default | Mechanism | +| --- | --- | --- | +| Read files | **Yes** | `Read`, `Glob`, `Grep` | +| Write files | **Yes** | `Write`, `Edit`, `NotebookEdit` | +| Execute code / shell | **Yes** | `Bash`, `KillShell` | +| Access the web | **Yes** | `WebFetch` **and** `WebSearch` are built in | + +Claude is the richer harness: it has first-class web tools Pi lacks. Claude **does** gate tool +use (probe reports `permissions: true`, `capabilities.ts:24`), and our runner auto-approves +every gate by default (`responder.ts:48`). So with the default policy, Claude behaves as +"all tools on." With `permissionPolicy: "deny"`, every Claude tool call is rejected (a blunt +kill switch, not a selective one). + +The QA run confirms the live behavior: `pi` builtin `bash` passes on E2/E3; `claude` chat + +code-tool + web-capable run passes once an Anthropic key is present +(`../qa/matrix.md:308-320`). + +## What you can configure through our interfaces today + +This is the blunt part. Very little of the per-capability surface is actually wired on the +sandbox-agent path. + +- **Turn individual tools on/off (web off, exec off, read-only, ...):** **Not possible today.** + - The config has a `builtin_names` / `tools` field meant to select Pi built-ins + (`dtos.py:457`, wire field `protocol.ts:216`). But the sandbox-agent runner **never reads + `request.tools`**. Only the out-of-scope in-process `pi.ts:281` honors it. On sandbox-agent, + Pi always launches with its default four tools regardless of what you set. So even Pi's own + tool-selection knob is silently dropped here. + - Claude built-in selection is dropped one layer earlier: `ClaudeHarness` discards + `builtin_names` entirely because built-ins are a Pi concept (`harnesses.py:83-87`). The + Claude Agent SDK *does* support `allowedTools` / `disallowedTools` / `permissionMode`, but + our runner sets **none** of them: it creates the session with only `cwd` and `mcpServers` + (`sandbox_agent.ts:195-199`). Passing them as session-creation options is also blocked, + because sandbox-agent strips `_meta` from `sessionInit` + (`Omit`, `sandbox-agent/dist/index.d.ts:2778`), so the + `_meta.claudeCode.options` channel never reaches the Claude ACP adapter. So **today** there + is no wired path to say "Claude without WebSearch" or "Claude read-only." But a clean path + DOES exist and we simply do not use it yet: a `.claude/settings.json` written into the + session `cwd`. See the 2026-06-23 update below. +- **Block all tool execution:** **Yes, but only for Claude.** `permissionPolicy: "deny"` (per + run) or `SANDBOX_AGENT_DENY_PERMISSIONS=true` (per deployment) rejects every gated call + (`responder.ts:57`). On Pi it does nothing (Pi does not gate). +- **Add tools (gateway, code, MCP):** Yes, this is the wired direction. Resolved custom tools + reach Pi natively through the bundled extension (`extensions/agenta.ts`) and reach Claude + over an MCP stdio bridge (`mcp.ts:50-75`), gated on the probed `mcpTools` capability. MCP + user-servers are delivered to Claude, dropped for Pi (`mcp.ts:61-67`), and remote/http MCP + is skipped (`mcp.ts:21`). +- **Pick the model:** partially. Aliases work; a full model id often silently falls back to the + harness default (F-007, `../qa/matrix.md:321`, `model.ts:46-70`). + +Net: today the product exposes **add tools** and **deny-all (Claude)**. It does **not** expose +"disable web," "disable code execution," "read-only," or even Pi's own built-in selection on +the sandbox-agent path. The capability descriptors the daemon reports +(`commandExecution`, `fileChanges`, `mcpTools`, `permissions`, ... in `AgentCapabilities`, +`sandbox-agent/dist/index.d.ts:30-49`) are **descriptive** (what the harness can do), not +**controls** (they do not turn anything off). The runner reads them only to branch tool +delivery, not to restrict the harness. + +## The backend dimension: Daytona vs local sidecar + +The harness toolset is identical across backends (same Pi, same Claude). What changes is the +**environment layer**: isolation, network reach, and what is installed to execute code. + +### Local sidecar (E2): the "sandbox" is the host + +The local provider spawns `sandbox-agent server` as a **child process on the sidecar host**, +inheriting `process.env` and binding `127.0.0.1` +(`sandbox-agent/dist/providers/local.js`, `provider.ts:42`). There is **no isolation**: + +- **Read/write** happen on the host filesystem, in a throwaway temp cwd + (`run-plan.ts:54-56`, cleaned up in the `finally`, `sandbox_agent.ts:296`). But `bash` is not + jailed to that cwd; the agent runs with the sidecar process's privileges and can read/write + what that user can. +- **Web/network** = whatever the host has. No allowlist, no block. If the sidecar can reach the + internet, so can the agent's `curl`. +- **Code execution** = whatever interpreters are installed in the sidecar image. (This is + exactly where F-006 bit: `python3` was missing from the image, so python code tools failed + with ENOENT until it was added.) +- There is **no per-run network or filesystem control knob** for local. The only lever is the + deny-all permission policy (Claude only). + +So local is fast and simple, but it trades away the sandbox. Treat E2 as "the agent runs +inside our sidecar," not "the agent runs in a sandbox." + +### Daytona (E3): a real isolated VM, with controls we do not yet use + +Daytona provisions a separate ephemeral sandbox per run +(`provider.ts:21-37`, `ephemeral: true`). Read/write/exec happen **inside that VM**, not on our +host. Code execution depends on what the snapshot bakes: our `agenta-sandbox-pi` snapshot is +`rivetdev/sandbox-agent:...-full` (daemon + Claude + CA certs) plus the `pi` CLI +(`sandbox-images/daytona/build_snapshot.py:42-73`), sized cpu=2/mem=4/disk=8. + +Crucially, **Daytona exposes network and resource controls that our runner does not surface.** +The provider passes a `create` overrides object straight to the Daytona SDK +(`provider.ts:26-37`, sandbox-agent `daytona({ create })`), and the SDK's create params include +(`@daytonaio/sdk/cjs/Daytona.d.ts:115-160`): + +- `networkBlockAll?: boolean` - block **all** network access for the sandbox. +- `networkAllowList?: string` - comma-separated **CIDR allowlist** (egress only to named + ranges). +- `resources` / `memory` / `disk` / `gpuType` - compute envelope. +- `volumes`, `autoStopInterval`, `user`, `language`, etc. + +Today `buildSandboxProvider` sets only `snapshot`/`image`/`target`/`envVars`/`ephemeral`. It +passes **no** network params, so a Daytona run has **full egress by default**. We *could* make +web access a real per-config control on Daytona by threading `networkBlockAll` / +`networkAllowList` into that `create` object. That lever exists at the backend and is unused. + +This is the sharp asymmetry: **Daytona can enforce "no web" or "web only to these hosts" at +the sandbox boundary; local cannot enforce anything** (it is the host). If "configurable web +access" is a product goal, Daytona is the backend that can deliver it cleanly, and the change +is in the runner's provider wiring, not in the harness. + +### The daemon's own primitives (a separate plane, not wired to the harness) + +Independently of the harness's tools, the sandbox-agent daemon exposes its own HTTP API over +the sandbox: `/v1/fs/*` (read, write, list, delete, move, upload), `/v1/process/*` (run a +command, stream logs), and `/v1/desktop/*` (full computer-use: mouse, keyboard, screenshot, +recording) (`sandbox-agent/dist/index.d.ts`). We use this control plane only for provisioning +(upload the extension, install pi, write AGENTS.md, run the usage readback). It is **not** +exposed to the model as tools. So "computer use" is available at the substrate but unused by +our agents today. Worth noting as a latent capability, not a current one. + +## Summary table + +| Question | `pi` (on sandbox-agent) | `claude` (on sandbox-agent) | +| --- | --- | --- | +| Read files (default) | yes (`read`) | yes (`Read`/`Glob`/`Grep`) | +| Write files (default) | yes (`write`/`edit`) | yes (`Write`/`Edit`) | +| Execute code (default) | yes (`bash`) | yes (`Bash`) | +| Web access (default) | only via `bash`+curl (no web tool) | yes (`WebFetch`+`WebSearch`) | +| Permission gating | none (Pi never gates) | yes; runner auto-approves | +| Selectively disable a tool | no interface today | no interface today | +| Block all tool exec | no (Pi ignores deny) | yes (`permissionPolicy: deny`) | +| Add tools (code/gateway/MCP) | yes (native) | yes (over MCP bridge) | + +| Backend | Isolation | Web by default | Web configurable? | Exec depends on | +| --- | --- | --- | --- | --- | +| Local sidecar (E2) | none (runs on host) | yes (host network) | no knob | sidecar image | +| Daytona (E3) | per-run ephemeral VM | yes (full egress) | **yes, but unused** (`networkBlockAll`/`networkAllowList` exist) | snapshot image | + +## Gaps and opportunities (if we want capabilities to be real controls) + +1. **No per-capability control exists on the sandbox-agent path.** "Disable web," "disable + exec," "read-only" are not configurable for either harness today. Adding them means wiring + the harness's own knobs: Pi's `--tools` / `--no-builtin-tools` (and actually honoring + `request.tools`, which the runner drops), and for Claude, writing a `.claude/settings.json` + into the session `cwd` (NOT session-creation options, which sandbox-agent strips). See the + 2026-06-23 update below for the settings.json mechanism. +2. **Web access is the cleanest thing to make configurable, via Daytona network params.** + `networkBlockAll` / `networkAllowList` are already accepted by the provider's `create` + object; the runner just needs to pass them from config. This gates web at the sandbox + boundary regardless of which tools the harness ships, so it works for both Pi (curl) and + Claude (WebFetch). +3. **Local cannot be made safe by config.** Because the local provider is the host, no + per-run network or filesystem confinement is possible there. If untrusted configs ever run, + they should run on Daytona, not local. +4. **Pi's missing web tool vs Claude's web tools** is a real product difference to surface: a + "give the agent web access" toggle means different things per harness (curl-in-bash for Pi, + first-class WebFetch/WebSearch for Claude). +5. The capability **descriptors** the daemon already reports (`AgentCapabilities`) are the + natural place to *display* what a harness can do, and the static capability table proposed + in `proposal.md` is the natural place to declare what we *let* the user configure. This doc + is the web/exec/read/write cut of that same framework. + +## Update (2026-06-23): the Claude config path (settings.json), Composio hints, MCP permissions + +Three findings from a follow-up dig that change the "what you can configure" picture above. Full +design in `capability-architecture.md` (Revision 2); this is the current-state summary. + +### Claude permission mode AND per-tool rules are deliverable via `.claude/settings.json` + +The "no path to restrict Claude" claim above is true only for session-creation options. There +is a clean path the runner does not use yet: write `.claude/settings.json` into the session +`cwd` before the session starts. The Claude ACP adapter builds the underlying SDK query with +`settingSources: ["user", "project", "local"]` (`@zed-industries/claude-agent-acp` +`acp-agent.js:954`), so the SDK reads `/.claude/settings.json` and honors its +`permissions.allow` / `deny` / `ask` rules. The adapter also reads `permissions.defaultMode` and +uses it as the session's initial permission mode (`acp-agent.js:935`). The `_meta` option +channel is ignored for `permissionMode` and stripped by sandbox-agent anyway, so the settings +file is THE path. For mode alone there is also a native ACP control: `session.setMode(modeId)` +over sandbox-agent (`sandbox-agent/dist/index.d.ts:3064`; modes +`default` / `acceptEdits` / `plan` / `bypassPermissions`). + +Consequence: "Claude read-only," "Claude without WebSearch/WebFetch," and a permission mode are +all enforceable over sandbox-agent today with no upstream change, by injecting one settings +file. The runner owns `cwd` (a temp dir, `run-plan.ts:54`), so it can write the file before +`createSession`. Pi has no settings.json analog over ACP (`permissions: false`); its only lever +stays `builtin_names`. + +### Composio tools carry read/write hints, which we strip + +Composio returns MCP behavioral hint tags per action: `readOnlyHint`, `destructiveHint`, +`updateHint`, `idempotentHint`, and others +(`api/oss/src/core/tools/providers/composio/catalog.py:278`). Our parser filters them out as +noise (`catalog.py:362`), and `ToolCatalogActionDetails` keeps no mutation field +(`api/oss/src/core/tools/dtos.py:51`). So the read-vs-write signal exists upstream and we +discard it. Carrying it through would let us default read-only tools to auto-allow and mutating +tools to ask, instead of hand-labeling each tool. + +### MCP permissions are namespaced per server and per tool (Claude only) + +Claude names MCP tools `mcp____`, so settings.json permission rules can target a +whole server (`mcp__`) or one tool (`mcp____`), at allow/deny/ask +granularity, the same mechanism as builtins. The runner currently accepts a per-server `tools` +allowlist on `McpServerConfig` but does not enforce it over ACP +(`engines/sandbox_agent/mcp.ts:26`, "not enforced over ACP (v1)"); the decision is to express +that allowlist as settings.json `mcp__` rules rather than a separate unenforced field. Pi has no +MCP today, so MCP permissions are Claude-only; when Pi MCP lands it follows the same pattern. + +## Sources + +- Runner: `services/agent/src/engines/sandbox_agent.ts` (session create `:195-199`, permission + wiring `:232-238`, no tool allowlist), `engines/sandbox_agent/provider.ts` (Daytona create + overrides), `engines/sandbox_agent/daemon.ts` (local daemon env), `responder.ts` (permission + policy), `engines/sandbox_agent/capabilities.ts` (probe), `engines/sandbox_agent/mcp.ts` + (tool/MCP delivery gate), `engines/sandbox_agent/run-plan.ts` (cwd, `request.tools` unused). +- Harness toolsets: `node_modules/@earendil-works/pi-coding-agent/README.md:96`, + `docs/usage.md:303` (Pi built-ins, no MCP/permissions); + `node_modules/.pnpm/@anthropic-ai+claude-agent-sdk@0.2.83.../sdk-tools.d.ts` (Claude tools); + `@zed-industries/claude-agent-acp` README (ACP adapter, "tool calls with permission + requests"). +- SDK adapters: `sdks/python/agenta/sdk/agents/adapters/harnesses.py` (Claude drops + `builtin_names`), `adapters/agenta_builtins.py` (forced `read`+`bash`), `dtos.py:457` + (`builtin_names` field). +- Daytona controls: `node_modules/.pnpm/@daytonaio+sdk@0.187.0.../cjs/Daytona.d.ts:115-160` + (`networkBlockAll`, `networkAllowList`, resources); snapshot recipe + `services/agent/sandbox-images/daytona/build_snapshot.py`. +- Daemon API: `node_modules/sandbox-agent/dist/index.d.ts` (`/v1/fs`, `/v1/process`, + `/v1/desktop`, `AgentCapabilities`). +- Live behavior: `../qa/matrix.md:299-344` (E2/E3 run results). diff --git a/docs/design/agent-workflows/scratch/dead-code-report.md b/docs/design/agent-workflows/scratch/dead-code-report.md new file mode 100644 index 0000000000..9b6a889f11 --- /dev/null +++ b/docs/design/agent-workflows/scratch/dead-code-report.md @@ -0,0 +1,295 @@ +# Agent-workflows dead-code report + +Date: 2026-06-23. Read-only investigation. No code changed. + +## Actions taken (2026-06-23, after review) + +Mahmoud reviewed this report inline. Done in this pass: + +- Deleted: `shutdownTracing` (otel.ts), `is_import_safe` (running/sandbox.py), + `engines/running/registry.py` (whole file), `tools/wire.py` (`tool_spec_to_wire` / + `tool_specs_to_wire`, whole file), `parse_tool_configs` (parsing.py), + `agents/ui_messages.py` (whole file), and `services/oss/src/agent/client.py` (whole file). + All `__init__` re-exports for these were removed too. +- `InProcessPiBackend`: removed from the public SDK (it was a confusing POC "reference + backend"). The class moved to a test-only helper + (`sdks/python/oss/tests/pytest/integration/agents/_in_process_backend.py`) so the transport + round-trip integration test still runs. Public exports, the two unit tests, and the design + docs were updated. If you want it gone entirely (dropping that integration test), say so. +- Kept on request: the `engines/sandbox_agent.ts:74-75` test re-exports, `LocalBackend`, + `ClaudeHarness` / `AgentaHarness`. +- Left for later (your question, not a delete): the service re-export shims `secrets.py`, + `tools/secrets.py`, `tools/gateway.py`. Confirmed the agent does NOT use them at runtime + (`app.py` resolves via `agenta.sdk.agents.platform` and `tools/resolver`); they are + backward-compat shims used only by tests. Deletable once those tests repoint. +- Not touched (unmarked low/cosmetic): `mcp_server_to_wire` singular, `MessageContent`, + the `coerce_tool_configs` diagnostics surface. + +The original report follows. + +## What "the code is not really doing anything" means here + +The premise is partly true and partly false. The live runtime path is wired and +reached. The service (`services/oss/src/agent/app.py`) always selects +`SandboxAgentBackend` + the `pi` harness, resolves tools/MCP/secrets through the SDK +`platform` package, and streams through the vercel adapter. That whole spine is alive. + +What is genuinely dead is the scaffolding left around the spine: leftover re-export shims +from the PR #4772 refactor, compat wrappers that duplicate a canonical name, two backend +adapters that only tests or nothing reach, and one broken module that cannot even import. +The breadth of files (31 TS, ~40 SDK) makes it look like a large system. Most of those +files are live; a focused minority is dead. + +## Counts + +- High confidence (delete): 7 findings. +- Medium confidence (reachable only via a non-default flag, tests, or unimplemented): 6 findings. +- Low confidence (cosmetic / public-surface-only orphans): 3 findings. + +## How I checked (shared method) + +For each symbol I ran `git grep -n ""` across `services/`, `sdks/`, `api/`, `web/` +excluding `.pyc`, then classified the hits: only-definition, only-`__init__`-re-export, +only-tests, or live caller. For files I grepped inbound imports of the basename. The live +entry points are `app.py` (service), `cli.ts`/`server.ts` (runner), and the public SDK +surface `agenta/__init__.py`. + +--- + +## SERVICE - `services/oss/src/agent/` + +### DEAD (high): `client.py` whole file [[[delete]]] + +- File: `services/oss/src/agent/client.py` (`agenta_api_base`, `request_authorization`, + `TOOLS_TIMEOUT`). +- Verdict: dead. Zero importers anywhere. +- How I checked: `git grep -n "agent.client\|agenta_api_base\|request_authorization"` over + `services/oss/src/` returns only the definitions in this file. `app.py` imports + `config`, `schemas`, `tools`, `tracing` only. The backend base-URL and authorization + logic now lives in `agenta.sdk.agents.platform.connection` (`PlatformConnection`, + `DEFAULT_TOOLS_TIMEOUT`). The conftest references to `agenta_api_base` / + `request_authorization` patch the SDK platform module passed into `_install`, not this + file. +- Action: delete. + +### DEAD (medium): `secrets.py` and `tools/secrets.py` and `tools/gateway.py` shims (tests-only) [[[doesnt the agent use these?]]] + +- Files: `services/oss/src/agent/secrets.py` (`resolve_harness_secrets`, + `_PROVIDER_ENV_VARS`), `services/oss/src/agent/tools/secrets.py` + (`VaultToolSecretProvider`, `resolve_named_secrets`), `services/oss/src/agent/tools/gateway.py` + (`AgentaGatewayToolResolver`, `_to_gateway_reference`, `_normalize_reference`). +- Verdict: thin re-export shims of the SDK `platform` package. No LIVE importer. `app.py` + imports none of them. The only non-shim importers are tests. +- How I checked: `git grep -n` for each symbol. `resolve_harness_secrets` and + `_PROVIDER_ENV_VARS`: only `secrets.py` + two test files. `VaultToolSecretProvider`: + only the shim + `tools/__init__.py` re-export, never constructed (`VaultToolSecretProvider(` + returns nothing). `_to_gateway_reference`/`AgentaGatewayToolResolver` via + `tools/__init__`: only `test_gateway_mapping.py`. `app.py` resolves via + `agenta.sdk.agents.platform` (`resolve_secrets`) and `oss.src.agent.tools` + (`resolve_tools`/`resolve_mcp_servers`), which themselves call the SDK platform, not + these shims. +- Nuance: `tools/__init__.py` re-exports `AgentaGatewayToolResolver` and + `VaultToolSecretProvider` in `__all__`, but nothing imports those names from it at + runtime. `_gateway_ref = _to_gateway_reference` in `tools/__init__.py:7` is assigned and + never read. +- Action: needs-human-decision. These keep test imports green and preserve a + backward-compatible import path. If the tests are repointed at + `agenta.sdk.agents.platform`, all four shim files plus the `__init__` re-exports can go. + `resolver.py` and the `resolve_tools`/`resolve_mcp_servers` it exposes are LIVE; keep them. + +### NOT DEAD (checked): `config.py`, `schemas.py`, `tracing.py`, `tools/resolver.py` + +- `config.py`: all three `AgentConfig` fields (`agents_md`, `model`, `tools`) are read by + `app.py` `_default_agent_config`. No decorative config. +- `schemas.py`: `AGENT_SCHEMAS` consumed by `app.py:144`; harness default is `"pi"` + (`schemas.py:50`), matching the live selection. +- `tracing.py`: `record_usage`, `trace_context` imported by `app.py:36`. +- `tools/resolver.py`: `resolve_tools`, `resolve_mcp_servers` imported by `app.py:35`. The + MCP gate `AGENTA_AGENT_ENABLE_MCP` defaults off, so MCP resolution is gated-but-reachable, + not dead. + +--- + +## RUNNER - `services/agent/src/` (TypeScript sandbox-agent) + +Entry points confirmed via `package.json`: `cli.ts` (`run:cli`) and `server.ts` (`serve`). +Engine dispatch is `backend === "pi" ? runPi(...) : runSandboxAgent(...)` at +`server.ts:47-50` and `cli.ts:31-34`, default `sandbox-agent`. Both engines are live: the +SDK `InProcessPiBackend` sets `AGENT_BACKEND=pi`, `SandboxAgentBackend` sets +`sandbox-agent`. Keep both engines, both tool executors, all of `tools/`, `protocol.ts`, +`responder.ts`. + +### DEAD (high): `shutdownTracing` [[[delete]]] + +- File: `services/agent/src/tracing/otel.ts:179`, function `shutdownTracing`. +- Verdict: dead. Zero callers in `src`, `tests`, or the Python side. +- How I checked: `grep -rn "shutdownTracing" services/agent` returns only the definition. + The runner flushes per-run via `flushTrace` (`otel.ts:583,1020`); there is no + process-level shutdown-flush path. The only other repo hit is an archived POC under + `docs/.../archive/wp-1-pi-tracing/poc/`, a different file. +- Action: delete. + +### DEAD (medium): test-only re-export aliases on the engine surface [[dont delete]] + +- File: `services/agent/src/engines/sandbox_agent.ts:74-75`. Re-exports `buildTurnText`, + `messageTranscript` (from `./sandbox_agent/transcript.ts`) and `toAcpMcpServers` (from + `./sandbox_agent/mcp.ts`). +- Verdict: the underlying functions are LIVE (production imports them directly from their + defining modules). The re-export aliases on the engine are consumed only by + `tests/unit/continuation.test.ts` and `tests/unit/mcp-servers.test.ts`. +- How I checked: grep for each name scoped to `sandbox_agent.ts` import source; only the + two test files import through the engine. +- Action: needs-human-decision. Either delete the three re-exports and repoint the two + tests at the defining modules, or keep them as an intentional "test through the engine's + public surface" seam. Not runtime-dead. + +### NOT DEAD (checked, do not re-investigate) + +- `tools/mcp-server.ts`: looks orphaned (no static import) but is spawned as a `tsx` + subprocess by `mcp-bridge.ts:26`. Live. +- `extensions/agenta.ts`: no static import, but esbuild-bundled to + `dist/extensions/agenta.js` and loaded by Pi at runtime (`pi-assets.ts:24`, Dockerfiles + run `build:extension`). Live. +- `engines/sandbox_agent.ts` (file) is NOT superseded by `engines/sandbox_agent/` (folder). + The file is the orchestrator that imports the folder modules. +- `version.ts` (`PROTOCOL_VERSION`/`RUNNER_VERSION`/`ENGINES`/`HARNESSES`): served by + `/health` via `runnerInfo()`. +- `provider.ts`, `transcript.ts`, `usage.ts`, `model.ts`, `daytona.ts`, `pi-assets.ts`, + `public-spec.ts`, `workspace.ts`: all reached through their orchestrators. + +--- + +## SDK - `sdks/python/agenta/sdk/agents/` and `sdk/engines/running/` + +### DEAD (high): broken `engines/running/registry.py` [[[check who added it and why]]] + +- File: `sdks/python/agenta/sdk/engines/running/registry.py` (only symbol + `exact_match_v1`). +- Verdict: dead and unimportable. Line 5 does + `from agenta.sdk.engines.running.types import Data`, but `running/types.py` does not + exist, so importing the module raises `ModuleNotFoundError`. +- How I checked: `ls running/types.py` (no such file). `git grep "running.registry\|from .registry import exact_match_v1"` + finds no importer of THIS module. The `exact_match_v1` hits elsewhere are an unrelated + function in `sdk.workflows.handlers` and in manual test scripts. Note: `running/` is the + OLDER workflow-engine subsystem, separate from the agent path; the agent code never + imports `engines.running`. +- Action: delete file. + +### DEAD (high): `is_import_safe` [[[delete]]] + +- File: `sdks/python/agenta/sdk/engines/running/sandbox.py:9`, function `is_import_safe`. +- Verdict: dead. Zero callers. +- How I checked: `git grep "is_import_safe"` returns only the definition. The live member + in that file is `execute_code_safely` (called from `handlers.py`). +- Action: delete function. + +### DEAD (high): `tool_spec_to_wire` and `tool_specs_to_wire` [[[[deelete]]]] + +- File: `sdks/python/agenta/sdk/agents/tools/wire.py:10,14`. +- Verdict: dead standalone functions. The live serialization path uses the + `ToolSpec.to_wire()` METHOD (`dtos.py:479,484`), not these module functions. +- How I checked: `git grep "tool_specs\?_to_wire"` returns only the defs plus their + re-export in `tools/__init__.py:38,65-66`. No real caller. +- Action: delete the functions and the `__init__` re-exports. + +### DEAD (high): `ui_messages.py` whole module [[[this is strange i thought this was our internal represenation]]] + +- File: `sdks/python/agenta/sdk/agents/ui_messages.py`. +- Verdict: dead compat shim re-exporting `from_ui_messages`/`to_ui_message`/ + `ui_message_stream` from `adapters.vercel`. Zero importers of the module. +- How I checked: `git grep "agents.ui_messages\|from .ui_messages\|from agenta.sdk.agents.ui_messages"` + returns nothing. The live service imports the canonical `agent_run_to_vercel_parts` + directly (`app.py:29`). +- Action: delete file. The flat aliases `from_ui_messages`, `to_ui_message`, + `ui_message_stream = agent_run_to_vercel_parts` in `adapters/vercel/messages.py:218-219` + and `adapters/vercel/stream.py:216` have no real callers either and can go with it. + +### DEAD (high): `parse_tool_configs` (plural-of-the-wrong-name) [[[[double check but then delete if so ]]]] + +- File: `sdks/python/agenta/sdk/agents/tools/parsing.py`. +- Verdict: dead. Zero references anywhere, not even tests. +- How I checked: `git grep "parse_tool_configs"` finds only the def. The live parse path + uses `coerce_tool_configs` (`dtos.py:331`, `platform/resolve.py:52`, + `api/oss/.../tools/models.py:113`). +- Action: delete. Note the siblings `coerce_tool_config` (singular) and `parse_tool_config` + (singular) are tests-only plus internal `compat.py` use; keep for now or fold into test + fixtures (medium, human call). + +### DEAD-ish (medium): `InProcessPiBackend` (tests-only, but a public export) [[[lets remove that part of the code it was a poc and it is now confusing]]] + +- File: `sdks/python/agenta/sdk/agents/adapters/in_process.py`, class `InProcessPiBackend`. +- Verdict: never selected by the service. Constructed only in tests + (`test_transport_roundtrip.py`, `test_harness_adapters.py`, `test_runner_adapter_config.py`). + It is a near-duplicate of `SandboxAgentBackend`. +- How I checked: `git grep "InProcessPiBackend\|InProcessPi"` excluding tests finds only + its definition plus public-API re-exports in `agenta/__init__.py:63` and + `agents/__init__.py`. `select_backend` in `app.py` always returns `SandboxAgentBackend`. +- Action: needs-human-decision. It is exported as public SDK API ("the reference backend") + but only tests and explicit non-default callers reach it. Keep as a documented reference + backend or demote to a test fixture. + +### DEAD (medium): `LocalBackend` (never instantiated, unimplemented) [[[keep]]] + +- File: `sdks/python/agenta/sdk/agents/adapters/local.py`, class `LocalBackend`. +- Verdict: never instantiated anywhere; every method raises `NotImplementedError`. +- How I checked: `git grep "LocalBackend("` finds only the class definition. Methods at + `local.py:35,50` raise `NotImplementedError`. +- Action: keep-but-wire (a tracked Phase 3/4 stub) or delete if no longer planned. Dead + today by design. + +### REACHABLE-BUT-NEVER-DEFAULT (medium): `ClaudeHarness`, `AgentaHarness` (+ `agenta_builtins.py`) [[[keeep]]] + +- File: `sdks/python/agenta/sdk/agents/adapters/harnesses.py:77,105`, plus the forced + tools/skills machinery in `adapters/agenta_builtins.py`. +- Verdict: registered in the harness registry (`harnesses.py:127-129`) and listed in + `SandboxAgentBackend.supported_harnesses` (`sandbox_agent.py:121-122`), so they ARE + reachable if a user sets `harness: "claude"` or `harness: "agenta"` in playground config. + The default everywhere is `"pi"` (`schemas.py:50`, `dtos.py:369,378`). Outside explicit + config they run only in unit tests. +- Action: keep (config-gated feature). Flag that AGENTA/CLAUDE are exercised only via tests + plus explicit non-default config, so they are easy to break unnoticed. + +### LOW / cosmetic + +- `mcp_server_to_wire` (singular) in `mcp/wire.py`: no non-test, non-`__init__` caller + (live path uses plural `mcp_servers_to_wire`, `dtos.py:439`). Delete singular helper. +- `MCPSecretProvider` in `mcp/interfaces.py`: Protocol/typing surface, no constructor. + Keep. +- `MessageContent` type alias `dtos.py:179`: used only in-file, not in `__all__`. Cosmetic. +- `ToolConfigDiagnostic` / `ToolConfigParseResult` / `coerce_tool_configs(on_error="collect")` + in `tools/compat.py:20,27`: the diagnostics/collect branch is tests-only (live callers + use the default `on_error="raise"`). Public structured-error surface; human call. + +### NOT DEAD (checked, do not re-investigate) + +- `platform.resolve` does NOT supersede `tools.resolver` / `mcp.resolver`. It WRAPS them: + `platform/resolve.py:48,62` constructs `ToolResolver(...)` and `MCPResolver(...)`. One + resolution stack, not two. All of `tools/resolver.py`, `mcp/resolver.py`, + `platform/{gateway,secrets,connection}.py` are live. +- `engines/running/` is a separate, OLDER workflow/evaluator engine + (`completion_v0`/`chat_v0`/`echo_v0`, code runners, catalog, templates). The agent path + never imports it. It is heavily used by `api/`, the completion/chat services, SDK + decorators, and DB migrations. Out of scope for agent-workflows but mostly live; the only + dead spots inside it are `registry.py` and `is_import_safe` above. `DaytonaRunner` + (`runners/daytona.py`) is env-gated (`AGENTA_SERVICES_CODE_SANDBOX_RUNNER=daytona`), not + dead; `LocalRunner` is the default. +- `dtos.py`, `interfaces.py`, `streaming.py` (`AgentRun`), `_runner_config.py`, + `utils/ts_runner.py` (all `deliver_*`), `utils/wire.py`: all have live callers in the + service or adapters. +- vercel adapter `routing.py`/`sse.py`/`stream.py`/`messages.py`: reached via + `decorators/routing.py:518` (`register_agent_message_routes`), gated by the `is_agent` + flag that `app.py:146` sets. The FE `AgentChatSlice` consumes `/messages` through + `NEXT_PUBLIC_AGENT_CHAT_API`. + +--- + +## Suggested cleanup order (lowest risk first) + +1. `shutdownTracing` (otel.ts), `is_import_safe` (sandbox.py), `running/registry.py`, + `tool_spec(s)_to_wire`, `parse_tool_configs`, `ui_messages.py` + flat vercel aliases, + `mcp_server_to_wire` singular. All zero-caller, high confidence. +2. Service shims (`client.py` then, after repointing tests, `secrets.py`, + `tools/secrets.py`, `tools/gateway.py` + `__init__` re-exports). +3. The runner test-only re-exports (`sandbox_agent.ts:74-75`) once tests are repointed. +4. Human decisions: `InProcessPiBackend`, `LocalBackend`, `ClaudeHarness`/`AgentaHarness`, + the `coerce_tool_configs` diagnostics surface. diff --git a/docs/design/agent-workflows/feature-matrix-test.md b/docs/design/agent-workflows/scratch/feature-matrix-test.md similarity index 100% rename from docs/design/agent-workflows/feature-matrix-test.md rename to docs/design/agent-workflows/scratch/feature-matrix-test.md diff --git a/docs/design/agent-workflows/implementation-review.md b/docs/design/agent-workflows/scratch/implementation-review.md similarity index 100% rename from docs/design/agent-workflows/implementation-review.md rename to docs/design/agent-workflows/scratch/implementation-review.md diff --git a/docs/design/agent-workflows/meeting-alignment.md b/docs/design/agent-workflows/scratch/meeting-alignment.md similarity index 100% rename from docs/design/agent-workflows/meeting-alignment.md rename to docs/design/agent-workflows/scratch/meeting-alignment.md diff --git a/docs/design/agent-workflows/scratch/notes-architecture.md b/docs/design/agent-workflows/scratch/notes-architecture.md new file mode 100644 index 0000000000..cb651756f9 --- /dev/null +++ b/docs/design/agent-workflows/scratch/notes-architecture.md @@ -0,0 +1,86 @@ +# Architecture doc notes (open questions and follow-ups) + +These are items I could not fully close while reconciling the architecture / sidecar / +sessions / protocol / ground-truth docs against the code on 2026-06-23. Each is written so you +can act on it cold. File:line citations are from the working tree at that date. + +## Corrections I made (so you can spot-check) + +- The deployed service ALWAYS uses `SandboxAgentBackend`. `select_backend` + (`services/oss/src/agent/app.py:49`) hard-codes it and does not branch on harness. The old + docs implied the service picks between `InProcessPiBackend` and `SandboxAgentBackend`. It + does not. `InProcessPiBackend` is reference-only and is exercised by tests / standalone + scripts, not the running service. Confirmed by `services/oss/tests/pytest/unit/agent/test_select_backend.py`. +- `SandboxAgentBackend.supported_harnesses` is `{pi, claude, agenta}` + (`sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py:121`). Old architecture/ports docs + said `{pi, claude}` and claimed `agenta` was in-process-only or unsupported on sandbox-agent. + Stale. `agenta` maps to the `pi` ACP agent (`engines/sandbox_agent/run-plan.ts:78`). +- Pi `systemPrompt` / `appendSystemPrompt` ARE delivered on the sandbox-agent path now + (`engines/sandbox_agent/pi-assets.ts:71-107`, called from `prepareLocalPiAssets` line 181 and + the Daytona path in `daytona.ts`). The old docs and QA matrix said "dropped on sandbox-agent + (F-001)". `projects/qa/findings.md` F-001 is marked **resolved** and the code confirms it. + NOTE: `projects/qa/matrix.md` still shows `append_system / pi` as `known-fail (F-001)` / + `fail (F-001)` and references `sandbox_agent.ts:875`. That matrix is STALE relative to the + code and findings.md. The matrix is owned by the QA project, not by me, so I did not edit it. + RECOMMEND: have the QA owner flip those matrix cells to pass and drop the `:875` line ref + (the monolithic `sandbox_agent.ts` was split into `engines/sandbox_agent/*`, so old line + numbers like `:875`, `:933-949`, `:961` in findings.md and matrix.md no longer resolve). + +## Stale line numbers across QA docs (not mine to edit) + +`projects/qa/findings.md` and `projects/qa/matrix.md` cite line numbers in a now-split file: +- `sandbox_agent.ts:875` (F-001, append_system) - file was refactored into + `services/agent/src/engines/sandbox_agent/` (run-plan, pi-assets, model, mcp, etc.). +- `sandbox_agent.ts:961` (F-007, applyModel) - now `engines/sandbox_agent/model.ts` + + `applyModel`. +- `sandbox_agent.ts:933-949` (F-009, MCP) - now `engines/sandbox_agent/mcp.ts`. +These still point at the right concepts but the wrong locations. A QA-owner pass should refresh +them. I cite the new files in the docs I own. + +## Open question: is `agenta` harness genuinely first-class on sandbox-agent, or pi-with-extras? + +The runner maps `harness: "agenta"` to `acpAgent = "pi"` and layers forced skills + prompt +extras (`run-plan.ts:78`). So on sandbox-agent, `agenta` is "pi ACP agent + Agenta forced +config", not a distinct ACP agent. I described it that way. Confirm this is the intended +long-term model (vs. a real `agenta` ACP agent) before the agent-template doc hardens it. + +## Open question: model override on sandbox-agent Pi + +QA F-007 says pi-acp accepts only `default` for the model category, so a real model id is +silently dropped on the Pi-over-sandbox-agent path. I documented this as a current gap in +architecture.md and ground-truth.md. I did NOT independently re-verify against pi-acp source +(it lives in the `sandbox-agent` npm package, not this repo). If you can confirm whether pi-acp +exposes any non-default model channel, that resolves whether F-007 is "wire it" or "fail loud". + +## Open question: sidecar.md vs folding into architecture.md + +I folded the sidecar story into `architecture.md` (sections "The Sidecar", "Licensing and +images", "Daytona sandbox") rather than creating `documentation/sidecar.md`. Reason: `README.md` +(not mine to edit) lists the doc reading order and has no `sidecar.md` entry; a new unreferenced +file would be a dangling doc. If you prefer a dedicated `sidecar.md`, move those three sections +out and add a README link. The content is self-contained enough to lift cleanly. + +## Open question: `LocalBackend` plan path + +`sdks/python/agenta/sdk/agents/adapters/local.py:16` points readers to +`docs/design/agent-workflows/scratch/sdk-local-backend/plan.md`. After the restructure that +content is at `docs/design/agent-workflows/archive/sdk-local-backend/` (and the active +workstream is `projects/sdk-local-tools/`). The code comment's doc path is now wrong. That is a +code comment, not a doc I own, so I left it. RECOMMEND a one-line fix in `local.py` to the new +path, or to `projects/sdk-local-tools/`. + +## Not verified live + +I did not run the stack. All claims about runtime behavior are read from code plus the existing +QA captures (`projects/qa/findings.md`, `projects/qa/matrix.md`, +`scratch/feature-matrix-test.md`). The most load-bearing un-rerun claims: +- system-prompt delivery on Daytona (read from `daytona.ts` + `pi-assets.ts`; QA F-001 verified + local and Daytona on 2026-06-20). +- `InMemorySessionPersistDriver` not surviving across turns (read from the cold per-`/run` + lifecycle in `engines/sandbox_agent.ts`; no cross-process store is constructed). + +## Minor: SDK `interfaces.py` docstring lists only Pi/Claude harnesses + +`sdks/python/agenta/sdk/agents/interfaces.py:14-15` names `PiHarness` / `ClaudeHarness` but not +`AgentaHarness`. Cosmetic staleness in a code docstring (not a doc I own). Worth a one-word fix +when someone touches that file. diff --git a/docs/design/agent-workflows/scratch/notes-config-runsh.md b/docs/design/agent-workflows/scratch/notes-config-runsh.md new file mode 100644 index 0000000000..f9e6f0677f --- /dev/null +++ b/docs/design/agent-workflows/scratch/notes-config-runsh.md @@ -0,0 +1,84 @@ +# Scratch notes: agent configuration and run.sh + +Working notes from documenting agent configuration and run.sh on 2026-06-23. Open questions, +things I could not verify, and the search for the "morning research." + +## The morning research on run.sh: NOT FOUND + +I could not find the lost run.sh research from "this morning." Here is what I checked and +ruled out: + +- Searched all scratchpad dirs under `/tmp/claude-1000/`. Only reorg artifacts there + (`reorg_paths.txt`, `reorg-pr-body.md`), nothing about run.sh. +- Searched `~/.claude/` and `~/.codex/memories/`. Nothing run.sh specific. +- Searched the agent-workflows docs tree for `run.sh`. Only incidental hits in archive WP + docs (for example `archive/wp-2-agent-service/implementation-plan.md:230` mentions + `./hosting/docker-compose/run.sh --oss --dev --build`). No dedicated run.sh research doc. +- Checked the worktree `.claude/worktrees/agent-a438aa3a2fe3880c0/`. Its `run.sh` is + byte-identical to main. It does have edits to `hosting/AGENTS.md`, `hosting/CLAUDE.md`, and + `docs/packs/hosting.md`, but those are about run.sh usage, not a research doc, and they + match what is already on the main checkout. +- `git log` shows no recent commit titled like run.sh research. + +Conclusion: if the morning research exists, it is in a session transcript or an +unsaved buffer, not on disk in this repo or the scratchpads I can read. I wrote +`running-the-agent.md` from the actual scripts instead. If the research turns up, fold it in +and reconcile against that doc. + +## The run-sh skill is stale + +`.claude/skills/run-sh/SKILL.md` documents an older flag set. It mentions `--stage`, `--gh` +as a stage alias, `--ssl`, and `--web-domain`. The current `hosting/docker-compose/run.sh` +uses `--image gh|dev`, `--local`, `--down`, `--web-mode`, `--web-url`, and derives the stage +internally. The skill's "Defaults" and "Options" sections do not match the script. I noted +this in `running-the-agent.md` and pointed readers at the script and `docs/packs/hosting.md`. + +Open question: should someone update the run-sh skill to match the current script? Out of +scope for this task (skill files are not mine to edit here), but worth a follow-up. + +## There is no agent-specific run.sh + +Confirmed. The only `run.sh` scripts in the repo are +`hosting/docker-compose/run.sh` and `hosting/kubernetes/run.sh` (plus the worktree copies). +The agent runs as the `sandbox-agent` compose service, started by the docker-compose run.sh +with everything else. The Node runner's own entrypoints are `pnpm run serve` and +`pnpm run run:cli`, not a shell script. + +## Config: things I am confident about + +- Three distinct `AgentConfig`-named objects. Schema (`AgentConfigSchema`, types.py:1065), + neutral runtime (`dtos.py:308`), file-default dataclass (`config.py:30`). All verified. +- The "loose runtime" belief needs a caveat. The neutral `AgentConfig` is NOT `extra="allow"`. + Its `model_config` is `populate_by_name=True`. The looseness is in before-validators and + `from_params` multi-shape coercion, plus the file-default dataclass `tools: List[Any]`. I + documented it this way. If the memory note meant "permissive about input shapes," that is + right. If it meant "open Pydantic model," that is wrong. +- `skills` and `persona` are not author config. They are forced injections of the Agenta + harness only. No schema field, no neutral-config field, no playground control. +- `permission_policy` is only read by the Claude harness. Decorative for pi and agenta. + +## Config: open questions and unverified items + +- I relied on a subagent for the exact FE line numbers in `AgentConfigControl.tsx`, + `SchemaPropertyRenderer.tsx`, and the molecule/store/api enrichment chain. The file paths + are confirmed to exist, but I cite the FE line numbers as "around line N" because I did not + open every FE file myself. If precise FE line numbers matter, re-verify + `web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/`. +- `_DEFAULT_AGENT_MODEL` is `"gpt-5.5"` per the subagent (types.py:1057). I did not open that + exact line. Low risk, but flag it. +- The `harness_options` escape hatch (Pi `system`/`append_system`) is on the neutral config + but absent from `AgentConfigSchema`. So the playground cannot set it through the standard + form. I documented this as a quirk. Worth confirming whether any UI path sets it at all, or + whether it is API-only today. +- `AGENTA_AGENT_ENABLE_MCP` defaults to `false`. So MCP servers in the config are accepted by + the schema and form but not resolved unless the flag is on. This is a wired-but-gated case. + I mentioned it in both docs. Confirm the exact gate location in + `services/oss/src/agent/tools/` if precise behavior matters. + +## Cross-references the new docs assume + +- `agent-template.md` already documents the request surface fields and the missing-work list. + My `agent-configuration.md` complements it with the live FE-to-runtime path. No overlap + edits needed; I left `agent-template.md` unchanged because it was already accurate. +- `tools.md`, `architecture.md`, `ports-and-adapters.md`, `sessions.md` are owned by other + agents. I only reference them, I did not edit them. diff --git a/docs/design/agent-workflows/scratch/notes-model-auth.md b/docs/design/agent-workflows/scratch/notes-model-auth.md new file mode 100644 index 0000000000..636b3bb2e0 --- /dev/null +++ b/docs/design/agent-workflows/scratch/notes-model-auth.md @@ -0,0 +1,295 @@ +# Notes: current model / provider / auth code (agent-workflows) + +Review date: 2026-06-23. Reviewer: subagent, read-only. +Scope: is the model/provider/auth code that exists RIGHT NOW correct? Findings cite real +`file:line`. "Current reality" is what the code does today. "Proposed" is the +`provider-model-auth/` redesign (not yet built). + +## Overall verdict + +The current path **works for the happy case** (one provider key per project, the chosen +model's provider key present in the vault) but is **not correct as a security or +multi-account design**. Two real problems stand out: + +1. It injects the **entire project vault** of provider keys into the harness on every run, + not the one key the chosen model needs. (over-broad credential exposure) +2. There is **no provider concept** anywhere. The model is a bare string. Key selection is + "dump them all and hope the harness picks the right one." No provider routing, no + account selection, no custom endpoint. + +Per-user/per-request scoping is **correct** (authorization is resolved per request, never +cached globally). Model override is **mostly correct** (it is applied and verified, with a +labelled fallback), not silently dropped. So the redesign is justified, but the current code +is not catastrophically broken; it is the loose, single-account MVP the redesign tightens. + +--- + +## How the model is chosen and passed (Q1) + +**Current reality: a bare string, no provider concept, applied post-hoc with fallback.** + +- Config default `model` is a plain string, e.g. `"gpt-5.5"` + (`services/oss/src/agent/config.py:21`, `:70-76`). `AgentConfig.model: Optional[str]` + (`sdks/python/agenta/sdk/agents/dtos.py:323`). No `provider` field anywhere in the agent + config or DTOs (grep for `ModelSpec`/`provider` in `sdk/agents/*.py` finds only tool + providers and the `provider_key` vault kind, never a model provider). +- The string flows: request `parameters.agent.model` + (`dtos.py:687` `_parse_agent_fields`) -> `AgentConfig.model` -> harness adapter copies it + verbatim into `PiAgentConfig.model` / `ClaudeAgentConfig.model` + (`adapters/harnesses.py:65`, `:90`, `:114`) -> wire field `"model"` + (`utils/wire.py:50`) -> TS `request.model` (`services/agent/src/protocol.ts:210-211`). +- The runner applies it AFTER the session exists with `applyModel` + (`services/agent/src/engines/sandbox_agent.ts:205`, + `engines/sandbox_agent/model.ts:46-70`): it calls `session.setModel(wanted)`, and on + failure parses the harness's allowed-values error and tries a suffix match + (`model.ts:7-16`). If nothing matches, it logs and returns `undefined`, and **the harness + keeps its own default model** (`model.ts:67-69`). + +**Is there any provider concept? No.** The only place "provider" enters model routing is an +implicit harness->key-var guess in the runner: `harnessKeyVar = acpAgent === "claude" ? +"ANTHROPIC_API_KEY" : "OPENAI_API_KEY"` (`engines/sandbox_agent/run-plan.ts:91`). That guess +is used only to compute `hasApiKey` (whether to upload Pi's OAuth fallback), not to select +which key to inject. So a Pi run targeting a Gemini or Anthropic model still gets every key +dumped and relies on the harness to pick. + +Verdict: **correct enough for single-provider use, structurally wrong for routing.** The +model is "provider-blind." A model like `claude-opus-4-8` selected under the Pi harness has +no path that says "this needs the Anthropic key"; it works only because the Anthropic key is +in the dumped env anyway. + +--- + +## How credentials are resolved and injected (Q2) + +**Current reality: whole-vault dump. Over-broad. Confirmed end-to-end.** + +Resolution (Python, service side): + +- `app.py:83` calls `resolve_secrets()` with no arguments. +- `resolve_secrets` == `resolve_provider_keys` + (`sdks/python/agenta/sdk/agents/platform/resolve.py:35`, + `platform/secrets.py:105-141`). +- It does `GET /secrets/` (`platform/secrets.py:121`), iterates **every** secret in the + response, and for each `kind == "provider_key"` maps the provider kind to an env var via + `_PROVIDER_ENV_VARS` and collects `{ENV_VAR: key}` (`secrets.py:132-141`). The chosen + `model` is **never passed in and never consulted**. There is no model or provider filter. +- Dedup is "first wins": `env.setdefault(env_var, key)` (`secrets.py:140`). So two OpenAI + keys -> the second is silently dropped (matches the redesign's "duplicate-key landmine," + though the line moved from the old `agent/secrets.py:71` into `platform/secrets.py:140`). + +Backend side, what `GET /secrets/` returns: + +- `list_secrets` (`api/oss/src/apis/fastapi/vault/router.py:101-141`) returns the **entire + project vault** as `List[SecretResponseDTO]`, scoped only by + `request.state.project_id`, cached per project. No model/provider filter parameter exists. +- The values are **decrypted**: `VaultService.list_secrets` runs under + `set_data_encryption_key(...)` (`api/oss/src/core/secrets/services.py:52-59`) and the DTO + carries the plaintext `provider.key` (`api/oss/src/core/secrets/dtos.py:17-23`, + `StandardProviderSettingsDTO.key: str`). So the agent service pulls every plaintext + provider key for the project on every run. + +Injection into the harness (TS runner): + +- The full `secrets` map rides the `/run` wire as `secrets: Record` + (`utils/wire.py:52`, `protocol.ts:194-195`). +- sandbox-agent backend: `Object.assign(env, plan.secrets)` puts **all** keys into the local + daemon env (`services/agent/src/engines/sandbox_agent.ts:119`). For Daytona, the same map + is spread into the sandbox env vars (`engines/sandbox_agent/daytona.ts:33-39`, + `buildSandboxProvider` passes `plan.secrets` at `provider.ts:34`). The harness process + therefore sees OpenAI + Anthropic + Gemini + ... keys regardless of the model it runs. + +**Severity: HIGH.** A run for an OpenAI model still has the project's Anthropic, Gemini, +Groq, OpenRouter, etc. keys in its environment. A compromised or prompt-injected harness, a +custom code-tool subprocess, or a misbehaving MCP server can read all of them. This is the +single most important current-correctness/security issue. Evidence: +`platform/secrets.py:132-141`, `sandbox_agent.ts:119`, `daytona.ts:33-39`, +`vault/router.py:130`. + +**One thing that IS correctly scoped:** code-tool and MCP env get only their **named** +secrets via `resolve_named_secrets` (`POST /secrets/resolve`, +`platform/secrets.py:29-78`), restricted to the requested set (`secrets.py:72-78`). That +path is least-privilege. The over-broad behavior is specifically the **provider-key** +(model auth) path, not the tool-secret path. + +--- + +## Per-user vs global auth (Q3) + +**Current reality: correct. Per-request, never global.** + +- The backend credential is resolved per request: `PlatformConnection.authorization()` + resolves lazily on each call, never caches (`platform/connection.py:108-110`, `:131-133`), + and reads `inject({}).get("Authorization")` (`connection.py:86-93`). +- `inject` reads `TracingContext.get()` (`sdks/python/agenta/sdk/engines/tracing/ + propagation.py:74`, `:94-96`), which is a request-scoped context (ContextVar), so one + caller's Authorization does not bleed into another's run. The fallback to the process + `AGENTA_API_KEY` (`connection.py:95-97`) is the standalone-SDK case (the env key is the + user's own). +- The backend enforces project scope from `request.state.project_id`, not from the body + (`vault/router.py:130-132`). EE adds an explicit `VIEW_SECRET` permission check + (`vault/router.py:103-115`). So a caller only ever reads their own project's vault. + +The `list_secrets` cache is keyed by `project_id` (`vault/router.py:117-139`), which is a +project-scoped cache, not a cross-user leak. + +**Caveat (runner-side, not backend-side):** the in-process Pi engine mutates +**process-global** `process.env` to inject keys, but it serializes runs and restores prior +env in a `finally` (`services/agent/src/engines/pi.ts:69-99`), so request A's vault keys do +not leak into request B. That is correct as written. The risk there is the inherited +baked-in dev key (see Q5, finding 3), not cross-request vault leakage. + +Verdict: **per-user/per-request auth is implemented correctly.** This is a current behavior +to PRESERVE. + +--- + +## Claude vs Pi auth differences (Q4) + +**Current reality: API-key first, OAuth/login fallback. Mostly correct, some sharp edges.** + +- The runner copies a fixed allowlist of provider auth from the sidecar process env into the + daemon env: `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, + `CLAUDE_CODE_OAUTH_TOKEN`, `CLAUDE_CONFIG_DIR`, `GEMINI_API_KEY` + (`services/agent/src/engines/sandbox_agent/daemon.ts:78-88`). Vault keys are then overlaid + on top (`sandbox_agent.ts:119`). So Claude can authenticate via `ANTHROPIC_API_KEY` (vault + or baked) or a subscription token (`CLAUDE_CODE_OAUTH_TOKEN`/`ANTHROPIC_AUTH_TOKEN`) baked + into the sidecar. +- Pi auth: if no provider key is available for the harness's key var + (`hasApiKey = !!secrets[harnessKeyVar]`, `run-plan.ts:113`), the Daytona path uploads the + dev's local Pi OAuth login (`auth.json` + `settings.json`) into the sandbox + (`engines/sandbox_agent/daytona.ts:88-105`, `:127`). Local runs use the host's + `PI_CODING_AGENT_DIR` login (`daemon.ts:71-74`). +- **File-based auth that rotates:** the redesign's research is right that Pi/Claude rotate + their OAuth credential files. Today the code uploads a **snapshot** of `auth.json` + (`daytona.ts:90-101`). For a short-lived Daytona run this is fine, but it is a frozen + copy; it is never written back, and if the token has expired it is stale. This is a known + limitation, not a crash. Severity: LOW-MEDIUM (only the self-managed-OAuth-in-sandbox + case). + +Verdict: **functionally correct for API-key auth.** The OAuth-file-snapshot upload is the +weak spot the redesign's `source: runtime` / "never store the rotating file" addresses. + +--- + +## Concrete current-correctness risks (Q5), prioritized + +### R1 - Whole-vault provider-key dump (over-broad exposure). Severity: HIGH. +Every project provider key is decrypted and injected into the harness env on every run, +regardless of the chosen model. +Evidence: `platform/secrets.py:132-141` (no model filter, returns all provider_key envs), +`sandbox_agent.ts:119` / `daytona.ts:33-39` (all keys into the run env), +`vault/router.py:130` (`GET /secrets/` returns the whole project vault, decrypted via +`core/secrets/services.py:52-59`). +Fix in redesign: `ResolvedModelAccess.env` carries one provider's vars only; service-side +`POST /vault/model-access/resolve` replaces the dump. + +### R2 - No provider concept / provider-blind model routing. Severity: MEDIUM-HIGH. +The model is a bare string with no provider. Nothing maps "model X needs provider Y's key." +It works only because R1 dumps every key. Selecting a non-default-provider model under a +harness has no first-class routing; it depends on the harness's own resolution plus the +dumped env. +Evidence: `AgentConfig.model: Optional[str]` (`dtos.py:323`); the only provider inference is +the harness-name guess `run-plan.ts:91`; `applyModel` is a post-hoc `setModel` with a string +suffix match (`model.ts:7-16`, `:46-70`). +Fix in redesign: `ModelSpec { provider, model, params }` committed in the config; provider is +first-class and the resolver matches account provider to model provider. + +### R3 - Inherited provider env is not cleared before applying the plan. Severity: MEDIUM. +On the sandbox-agent path the daemon env starts with the sidecar's baked provider keys +(`daemon.ts:78-88`), and vault keys are overlaid (`sandbox_agent.ts:119`). A baked dev key +for a provider the vault does NOT have stays visible to the run. There is no clear-then-apply +step on this path. (The in-process Pi engine DOES restore/delete per run at `pi.ts:80-92`, +but only for the keys present in `secrets`; a baked key absent from `secrets` is untouched.) +Evidence: `daemon.ts:78-88`, `sandbox_agent.ts:119`, contrast `pi.ts:69-99`. +Fix in redesign: security non-negotiable #5, "clear inherited provider env before applying." + +### R4 - Duplicate keys for one provider: first silently wins (no forced choice). Severity: LOW-MEDIUM. +`env.setdefault(env_var, key)` means a project with two OpenAI keys silently uses the first +encountered. The completion path does the opposite (last wins, +`sdks/python/agenta/sdk/managers/secrets.py` provider loop), so the two paths disagree. +Evidence: `platform/secrets.py:140`. +Fix in redesign: multi-account by slug; error (do not guess) when multiple accounts and no +default/binding. + +### R5 - Silent model fallback can mislead (degraded, not data-incorrect). Severity: LOW. +When `setModel` cannot honor the requested model, the run proceeds on the harness's default +model. This is intentional and is handled honestly for tracing: `applyModel` returns +`undefined` and the chat span is labelled generically rather than claiming the requested +model (`sandbox_agent.ts:202-205`, `:209`; `model.ts:67-69`). So it is NOT a silent +mislabel. But the user still gets a different model than asked, with only a stderr log +(`model.ts:67`). Not surfaced to the caller. This is a UX/observability gap, not a +correctness bug. Note: this is the opposite of "silently-dropped model override claimed as +applied"; the code is careful here. + +### R6 - `AGENTA_CRYPT_KEY` defaults to `"replace-me"`. Severity: HIGH if shipped, but PRE-EXISTING / OUT OF SCOPE. +`api/oss/src/utils/env.py:410`. The vault data-encryption key has a weak default. Not +introduced by the agent feature; flagged by the redesign too (security non-negotiable #8). +Call out for a separate security follow-up. + +--- + +## What is already CORRECT and should be preserved + +- **Per-request, per-user authorization** (Q3). Lazy, never cached, request-scoped context, + project scope from `request.state`, EE permission check. `connection.py:108-133`, + `propagation.py:74/94`, `vault/router.py:103-132`. +- **Named tool/MCP secret resolution is already least-privilege** (only requested names, + restricted to the requested set). `platform/secrets.py:29-78`. The model-auth path should + move to this same shape. +- **Honest model labelling on fallback** (R5): the trace does not claim a model the harness + did not run. `sandbox_agent.ts:202-205`, `model.ts:67-69`. Preserve this. +- **In-process Pi env restore discipline**: serialized runs + `finally` restore prevent + cross-request vault-key leakage. `pi.ts:69-99`. The redesign should keep this and extend + it to clear-then-apply. +- **Best-effort optionality**: an empty vault is valid (the harness falls back to its own + login); a vault outage returns empty rather than failing the run. + `platform/secrets.py:109-130`. Keep this for the self-managed (`source: runtime`) case. +- **The three-way split already exists in the ports** (agent identity / harness config / + runtime). `RunSelection` is deliberately not part of the neutral `AgentConfig` + (`dtos.py:364-387`). The redesign's `ModelSpec` (committed) vs `ModelAccessBinding` (on the + run) lands cleanly on this existing seam. + +--- + +## How the redesign maps to the current problems + +| Current problem (this doc) | Redesign fix | +| --- | --- | +| R1 whole-vault dump | `ResolvedModelAccess.env` = one provider's vars; `POST /vault/model-access/resolve` replaces `resolve_provider_keys` | +| R2 provider-blind model string | `ModelSpec { provider, model, params }` committed; provider first-class; provider-match security rule | +| R3 inherited env not cleared | security non-negotiable #5: clear-then-apply on the runner | +| R4 first-wins dedup | multi-account by slug; error on ambiguity, no guessing | +| R5 silent model fallback | `getModel(provider, id)` exact match, no silent fallback (Pi/Codex/Claude table) | +| R6 weak crypt key default | explicitly flagged, OUT OF SCOPE (same call as this doc) | +| OAuth file snapshot (Q4) | `source: runtime` self-managed; never store the rotating file | + +Behaviors the redesign explicitly preserves (and so should NOT regress): per-request auth, +the additive nature (prompts/completions untouched), best-effort optionality, the +agent-config-vs-run split. + +--- + +## Doc-vs-code drift to be aware of (for whoever implements) + +The redesign's `status.md` / `design.md` cite OLDER line numbers, because the code was +refactored after those docs were written: +- "`services/oss/src/agent/secrets.py:71`" (first-wins dedup) is now + `sdks/python/agenta/sdk/agents/platform/secrets.py:140`. The service `secrets.py` is now a + thin re-export (`services/oss/src/agent/secrets.py:1-12`). +- "`services/agent/src/engines/sandbox_agent.ts:309` / `:530`" (env copy / Daytona spread) + are now split into `engines/sandbox_agent/daemon.ts:78-88` (process-env copy), + `sandbox_agent.ts:119` (vault overlay), and `engines/sandbox_agent/daytona.ts:33-39` + (Daytona spread). +- "`services/oss/src/agent/secrets.py:26-35`" (provider->env map, "incomplete and partly + dead") is now `_PROVIDER_ENV_VARS` at `platform/secrets.py:93-102`. +The substance of every claim still holds against the current code; only the locations moved. + +## Open questions for the user + +1. Is the whole-vault dump (R1) acceptable as a stopgap until the resolver lands, or should + a quick model-scoped filter be patched in first? A minimal fix is feasible without the + full redesign: filter `resolve_provider_keys` to the chosen model's provider env var. +2. R3 (clear inherited env) and R6 (`replace-me` crypt key) are security items independent of + the resolver redesign. Should they be split into their own fix now? +3. Is the OAuth-file snapshot upload (`daytona.ts`) used in any shipping path, or only the + dev Daytona POC? If only POC, R4/OAuth concerns are lower urgency. diff --git a/docs/design/agent-workflows/scratch/notes-tools-mcp-capabilities.md b/docs/design/agent-workflows/scratch/notes-tools-mcp-capabilities.md new file mode 100644 index 0000000000..66aad53fdf --- /dev/null +++ b/docs/design/agent-workflows/scratch/notes-tools-mcp-capabilities.md @@ -0,0 +1,309 @@ +# Notes: tools, MCP, code tools, sandbox capabilities + +Scratch findings + recommendations. Investigation date 2026-06-23. Everything below is cited +to `file:line` against the working tree. Marks: VERIFIED (read the code), STALE (memory hint +that no longer holds), DEAD (code exists but nothing reaches it). + +## TL;DR verdict + +- **Builtin tools**: live. Pi-only. A bare name added to the session allowlist. +- **Gateway (callback) tools**: live on every path (in-process Pi, Pi-over-ACP, Claude-over-MCP, + local + Daytona). This is the real, exercised tool path. +- **Code tools**: live and reachable. `python3` IS in the prod image now (the old ENOENT is + fixed). Caveat: the child env is a tight allowlist, so a code tool that imports a third-party + package will fail (no pip/venv, no inherited env). +- **Client tools**: plumbed end to end on the runner side (throws in-sandbox, emitted as + `interaction_request`), but full browser fulfillment is a frontend-egress concern, not + verified here as working UI. +- **User MCP servers** (`mcp_servers` config): effectively dead on the deployed path. + Gated OFF by `AGENTA_AGENT_ENABLE_MCP` (default false) at the service, AND gated off for Pi + in the runner. So today it reaches nobody by default, and even with the flag on it reaches + Claude only. Pi/agenta is the default harness, so in practice user MCP is dead. +- **Sandbox-side MCP machinery** (`mcp-server.ts` + the `agenta-tools` synthetic server in + `mcp-bridge.ts`): only used to deliver GATEWAY/CODE tools to a non-Pi (Claude) harness that + reports `mcpTools`. It is NOT used for user `mcp_servers`. It is reachable only on the Claude + path. On the default Pi path it is never launched. +- **Capability advertisement**: `HarnessCapabilities` exists in the wire, is probed in the + runner, gates tool delivery internally, and is returned on the `/run` result. But it is a + DEAD read on the consume side: parsed into `AgentResult.capabilities` (`dtos.py:297`, + `wire.py:87`) and then never read by the service, `/inspect`, or the frontend. `/health` + advertises `engines`/`harnesses` but NOT capabilities. + +## End-to-end trace (deployed = sandbox-agent path) + +Config -> service resolution -> wire -> runner -> harness. + +### 1. Config (SDK) + +`AgentConfig.tools` is a list of 4 discriminated configs: `builtin` / `gateway` / `code` / +`client` (`sdks/python/agenta/sdk/agents/tools/models.py:22-77`). `AgentConfig.mcp_servers` +is a SIBLING field, not a tool type (`sdks/python/agenta/sdk/agents/mcp/models.py`). + +Three orthogonal axes per tool: `type`/`kind` (executor), `needs_approval`, `render` +(`models.py:22-29` ToolConfigBase; `protocol.ts:52-76`). + +"4 executors" = `builtin` (a name, not a spec) + 3 spec kinds: `callback` / `code` / `client` +(`models.py:131-153`). NOTE: the resolved `kind` for a gateway tool is **`callback`**, not +`gateway`. There is no `gateway` kind on the wire. The config `type` `gateway` -> resolved +`kind` `callback` (`resolver.py:162-167`, `models.py:131-137`). + +### 2. Service resolution + +`services/oss/src/agent/app.py:78-80`: +``` +resolved_tools = await resolve_tools(agent_config.tools) +resolved_mcp = await resolve_mcp_servers(agent_config.mcp_servers) +``` +Both are thin re-exports of SDK platform entrypoints. The service files are now shims: +- `services/oss/src/agent/tools/resolver.py` re-exports `resolve_tools` and adds the MCP gate. +- `gateway.py`, `secrets.py`, `__init__.py` are re-export shims to + `agenta.sdk.agents.platform.*`. + +Real resolution: `sdks/python/agenta/sdk/agents/platform/resolve.py:40-65` -> +`ToolResolver.resolve` (`sdks/python/agenta/sdk/agents/tools/resolver.py:102-177`). + +Per type: +- `builtin` -> name lands in `builtin_names`, no network (`resolver.py:103-107`). +- `code` -> declared `secrets` resolved by name via the named-secret provider, injected into + spec `env` (`resolver.py:124-154`). Script not run here. +- `client` -> pass-through to `ClientToolSpec` (`resolver.py:156-159`). +- `gateway` -> `AgentaGatewayToolResolver` posts to API `/tools/resolve`, gets a `call_ref` + slug, wraps in `CallbackToolSpec` + one `ToolCallback` -> `/tools/call` + (`resolver.py:161-167`; gateway impl now in `platform/gateway.py`). + +MCP gate (THE key gate): `services/oss/src/agent/tools/resolver.py:22-37`. If +`AGENTA_AGENT_ENABLE_MCP` not truthy -> returns `[]`. Default off. So `resolved_mcp` is empty +by default and `mcpServers` is omitted from the wire. + +### 3. Wire + +`request_to_wire` -> `mcpServers` only when non-empty (`utils/wire.py:54-56`, gated by +`config.wire_mcp()`). `customTools` = resolved specs, `toolCallback` = the callback, +`tools` = builtin names. Wire contract: `protocol.ts` (TS) mirrored by `utils/wire.py`. + +### 4. Runner delivery (the fork) + +Engine selected by `server.ts:38-49`: default `sandbox-agent`, request `backend:"pi"` picks +the in-process engine. Deployed = `sandbox-agent` (`runSandboxAgent`). + +Delivery decision in `engines/sandbox_agent/mcp.ts:50-75` (`buildSessionMcpServers`): +- If `isPi` OR `!capabilities.mcpTools` -> return `[]` (no MCP servers attached). For Pi this + means NO MCP at all (neither agenta-tools nor user servers). Tools for Pi are delivered the + Pi-native way via the extension, NOT through this function. +- Else (Claude, `mcpTools` true) -> attach `buildToolMcpServers(...)` (the synthetic + `agenta-tools` server carrying gateway/code specs) + `toAcpMcpServers(userMcpServers)`. + +So: +- **Pi-native delivery**: the bundled extension (`extensions/agenta.ts:38-75`) reads + `AGENTA_TOOL_PUBLIC_SPECS` + `AGENTA_TOOL_RELAY_DIR` and calls `pi.registerTool` per spec. + Execution goes through `runResolvedTool` (`tools/dispatch.ts:104`) -> relay file -> + runner-side `startToolRelay` (`tools/relay.ts:121`) -> `/tools/call` (gateway) or local + `python3`/`node` (code). The extension env carries PUBLIC metadata only; private specs/auth + stay in runner memory (`pi-assets.ts:31-50`). +- **Claude MCP delivery**: `mcp-bridge.ts:63` builds the `agenta-tools` ACP stdio server; + `mcp-server.ts` is the bridge process; it relays calls back via `runResolvedTool` with a + `relayDir`. User `mcp_servers` (if the flag were on) would be ADDITIONAL ACP stdio servers + via `toAcpMcpServers` (`mcp.ts:15-36`), but pi-acp does not forward those and they are + gated off for Pi. + +### 5. In-process engine (reference only) + +`engines/pi.ts:150-198` (`buildCustomTools`) branches on kind directly: code -> local +subprocess, callback -> `/tools/call`, client -> skipped. Ignores `request.mcpServers` +ENTIRELY (`PI_CAPABILITIES.mcpTools = false`, `pi.ts:60`). Not the deployed path. + +## What is live / gated / dead, with evidence + +| Thing | State | Evidence | +| --- | --- | --- | +| Builtin tools (Pi) | LIVE | `resolver.py:103-107`; allowlist `pi.ts:280-283` | +| Gateway/callback tools | LIVE all paths | `callback.ts:32`; relay `relay.ts:103-112`; Pi ext `agenta.ts:60-71` | +| Code tools | LIVE; `python3` in image | `code.ts:115`; `Dockerfile:27` installs `python3` | +| Client tools | PLUMBED (runner); FE unverified | throws `dispatch.ts:112-115`; filtered `mcp-server.ts:63`, `public-spec.ts:17` | +| User `mcp_servers` | GATED OFF (default) + Pi-dead | service gate `resolver.py:22-37`; runner gate `mcp.ts:61` | +| `agenta-tools` synthetic MCP server | LIVE only on Claude path | `mcp-bridge.ts:63`, `mcp-server.ts`; never built for Pi `mcp.ts:61` | +| `HarnessCapabilities` probe | LIVE in runner, gates delivery | `capabilities.ts:42-52`, used `sandbox_agent.ts:183-193` | +| `result.capabilities` consume | DEAD | parsed `wire.py:87`/`dtos.py:297`, read by nobody downstream | +| `needs_approval` | Claude-only honored | responder `responder.ts`; Pi no-op | +| `render` | runner copies hint; FE projection partial | `protocol.ts:133-136`, copied onto events | + +## STALE memory hints, corrected + +- "missing python3 in the agent image (python code tools ENOENT)" -> STALE/FIXED. The prod + Dockerfile installs `python3` (`services/agent/docker/Dockerfile:26-28`) with a comment that + names exactly this failure mode. Code tools with `runtime: python` work in the prod image. + (Caveat below: only the interpreter, no third-party packages.) +- "stale Pi extension bundle (custom tools silently undelivered on rivet)" -> partially + current as a CLASS of risk. The extension is a baked esbuild bundle + (`pi-assets.ts:24-25`, `Dockerfile:48`). If the image is built without `build:extension`, + or `SANDBOX_AGENT_EXTENSION_BUNDLE` points at a stale file, tools silently do not register + (`installPiExtensionLocal` logs and returns, `pi-assets.ts:53-65`). The prod Dockerfile does + run `build:extension`, so the prod image is fine; the risk is dev/compose images that + override CMD or skip the build step. This is a build-hygiene risk, not a code bug. +- "MCP gated behind AGENTA_AGENT_ENABLE_MCP, claude-only" -> VERIFIED, still true. + +## Real, current gaps and oddities (the "does not make sense" list) + +1. **User MCP is dead by default and Pi-impossible.** `AGENTA_AGENT_ENABLE_MCP` defaults off. + Even on, `buildSessionMcpServers` drops user MCP for Pi (`mcp.ts:61`), and Pi is the default + harness. So the entire `mcp_servers` config field is a silent no-op for the common case. The + field is accepted, serialized only when the flag is on, then dropped at the runner. This is + the silent-drop F-009 the harness-capabilities project is about. + +2. **Two MCP machineries that do different things share the word "MCP".** (a) The synthetic + `agenta-tools` server (`mcp-bridge.ts` + `mcp-server.ts`) is an internal TOOL DELIVERY + vehicle for Claude - it has nothing to do with user-declared MCP. (b) `toAcpMcpServers` + delivers user `mcp_servers`. Both live under "MCP" and both are off on the default path. + This conflation is most of the confusion. + +3. **`HarnessCapabilities` is half a feature.** The runner probes it and gates on it, which is + good, but the probe almost always falls back to the STATIC per-harness guess + (`capabilities.ts:24-39`) because `sandbox.getAgent(...).capabilities` is usually absent. And + the result it returns is read by nobody. So we pay for a probe whose only real effect is the + internal `mcpTools` branch, which a static `harness === "pi"` check would do identically. + +4. **Code tools cannot import packages.** `buildChildEnv` (`code.ts:99-108`) gives the child + only PATH/HOME/locale/temp + the tool's own secrets. The image has `python3` and `node` but + no `pip install`/`npm install` of arbitrary deps at tool time, and no `NODE_PATH` to the + runner's `node_modules`. So a code tool is limited to the stdlib. Fine for glue, surprising + for anything real. Worth documenting as a constraint, not necessarily removing. + +## Removal proposal: take user-MCP out of the sandbox + +User said: "the way we implement it does not make sense; remove it at least from the sandbox." +Reading: remove the user-declared MCP plumbing from the sandbox-agent runner (NOT the +gateway/code tool delivery, which happens to also use an MCP server for Claude). Below is a +precise, code-free plan (other sessions own the code; this is a plan). + +### What is safe to remove (sandbox/runner side) + +The user-MCP path is small and isolated: + +- `services/agent/src/engines/sandbox_agent/mcp.ts`: `toAcpMcpServers` (the user-MCP -> ACP + stdio converter) and its call inside `buildSessionMcpServers` (the `...toAcpMcpServers(...)` + spread, `mcp.ts:73`). Keep `buildToolMcpServers` (that is the Claude tool-delivery vehicle). +- The `userMcpServers` parameter threaded into `buildSessionMcpServers` + (`sandbox_agent.ts:189`, `mcp.ts:43,60,62`). +- `McpServerConfig` on the wire (`protocol.ts:89-97`) and `mcpServers` on `AgentRunRequest` + (`protocol.ts:227`) - ONLY if we also drop the field service-side; otherwise leave the wire + field but stop consuming it. + +### What depends on it / what breaks + +- Nothing in the deployed path breaks, because it is already gated off + (`AGENTA_AGENT_ENABLE_MCP` default false). Removing it changes behavior only for someone who + set the flag AND used Claude AND declared `mcp_servers`. That is a near-empty set. +- The golden wire-contract fixtures pin `mcpServers` (`services/agent/CLAUDE.md` wire rules). + Removing the field means updating `protocol.ts` + `utils/wire.py` + both golden fixtures + + both contract tests, deliberately, together. This is the only real cost. +- `toAcpMcpServers` is re-exported (`sandbox_agent.ts:75`) and has unit tests; those go too. + +### Recommended shape (simplest honest end state) + +Two clean options. Prefer **A** if we want to keep the door open, **B** if we want it gone. + +**Option A - keep the field, stop pretending it works on the default path; make the drop loud.** +Leave `mcp_servers` in config and on the wire, but: +- Delete `toAcpMcpServers` user-MCP delivery from the runner (it only ever reached Claude, off + by default). +- Make the SERVICE reject a non-empty `mcp_servers` for a harness that cannot honor it (fail + loud, per the harness-capabilities proposal slice 1), instead of silently dropping at the + runner. This is the smallest change that removes the silent no-op. +- Result: the sandbox no longer carries user-MCP code; the boundary tells the user "this + harness does not support MCP" up front. + +**Option B - remove user MCP entirely (config + wire + runner).** +- Drop `AgentConfig.mcp_servers`, the `MCPResolver`, `resolve_mcp_servers`, + `AGENTA_AGENT_ENABLE_MCP`, the `mcpServers` wire field, `toAcpMcpServers`, and the + `agenta.sdk.agents.mcp` package's user-server half. +- Keep `buildToolMcpServers`/`mcp-server.ts` (Claude tool delivery) untouched - it is not user + MCP. +- Update the golden wire fixtures + contract tests in the same change. +- Result: the only "MCP" left in the tree is the internal Claude tool-delivery server, which + could even be renamed away from "MCP" (e.g. `tool-bridge`) to kill the conflation. + +### What NOT to remove + +- `mcp-server.ts` / `mcp-bridge.ts` `buildToolMcpServers` / the relay: these deliver GATEWAY + and CODE tools to Claude. Removing them breaks tools on the Claude harness. They are + mislabeled (they are a tool bridge that happens to speak MCP), not dead. +- The Pi extension tool path: that is the main tool delivery for the default harness. + +### My recommendation + +Option A now (cheap, removes the silent failure, shrinks the sandbox), Option B later if the +product decides user-MCP is not a near-term feature. If Part 1 of the harness-capabilities +proposal (MCP on Pi via the extension) is actually wanted, that is the OPPOSITE of removal and +the two should not both be in flight - decide first. + +## Capability advertisement proposal + +### Current state (verified) + +- `/health` returns `{ status, runner, protocol, engines, harnesses }` + (`version.ts:27-35`). No capabilities. `HARNESSES = ["pi","claude","agenta"]` is a flat list. +- `HarnessCapabilities` is probed per RUN inside the runner (`capabilities.ts`), used only to + gate tool delivery (`sandbox_agent.ts:183`), and returned on the result. The probe is mostly + the static fallback because the daemon rarely fills `info.capabilities`. +- The consume side is dead: `AgentResult.capabilities` is parsed and dropped. No `/inspect` + surface, no FE gate, no service gate. +- There is a substantial design already: `projects/harness-capabilities/proposal.md` argues for + a static per-harness capability table in `sdks/python/agenta/sdk/agents/capabilities.py`, with + the runtime probe as a narrowing Layer 2, surfaced via `/inspect` as a `harness_capabilities` + map, and a fail-loud backend reject. `capability-map.md` documents the actual web/exec/read/ + write matrix per harness x sandbox. + +### What the runner SHOULD advertise (and how) + +Two grains, both worth having: + +1. **Static, run-independent, on `/health`** (the version-skew sibling). Extend `runnerInfo()` + so `harnesses` is not a flat list but a map: per harness, the static capability set the + runner believes it can drive (`mcpTools`, `permissions`, `images`, `planMode`, plus a + `toolDelivery` tag: `pi_native` | `acp_mcp`). This is the "what MAY run" contract a schema + and a form can read before any run. It is the runner half of the harness-capabilities + static table; pin it against the SDK table with a golden contract test (same discipline as + the wire contract). + +2. **Dynamic, per-run, on the `/run` result** (already exists as `capabilities`). Keep it, but + make it CONSUMED: the service should (a) compare probed vs static and log drift, (b) + optionally fold a small subset into the `/invoke` response or a span attribute so the + product can see what actually ran. Today this field is wasted. + +### How the service consumes it + +- At schema/`inspect` time: read the static map (from the SDK table, mirrored from `/health`) + and emit a `harness_capabilities` document so the FE can show/hide `mcp_servers`, + `permission_policy`, and gate `model`. This is proposal Part 2 slice 2. +- At invoke time (fail loud): before starting the runner, reject a non-empty config field the + selected harness cannot honor (`mcp_servers` on pi/agenta; an unsettable `model`). This is + proposal Part 2 slice 1 and the single highest-value change - it converts the silent drop + into an honest error. It does not need the runner change to land; the SDK static table is + enough. +- At result time: intersection check. If the probe reports LESS than the static table for a + capability the user asked for, fail or warn loudly; if MORE, log drift. + +### Minimal first step + +Land the SDK static capability table + the backend fail-loud reject (proposal slice 1). It +needs no runner change, kills the worst silent failures (user MCP on Pi, model on sandbox-agent), +and gives the FE something to read. The `/health` capability map and the consume-the-probe work +are good follow-ups but not the bottleneck. + +## Open questions (for the user) + +1. Is user-declared `mcp_servers` a real near-term product feature, or scratch? If scratch, + Option B (remove entirely) is cleanest. If real, the right move is the harness-capabilities + Part 1 (MCP on Pi via extension), which is the opposite of removal. These conflict - pick one. +2. Should the internal Claude tool-delivery server keep the name "MCP"? Renaming it (e.g. + `tool-bridge`) would end the conflation that makes all of this confusing. It speaks MCP on + the wire to the harness, but it is an Agenta tool relay, not a user MCP server. +3. Do we want `result.capabilities` consumed at all, or should it be removed too? It is dead + today. Either wire it into `/inspect`/the FE (per the proposal) or drop it from the result. +4. Code tools are stdlib-only (no package install). Is that the intended contract, or do we + want a provisioning story (a base image with common libs, or a per-tool deps manifest)? +5. The capability probe is mostly the static fallback. Is it worth keeping the probe at all + before the daemon actually fills `info.capabilities`, or should we ship the static table now + and add the probe when there is real data to probe? + + diff --git a/docs/design/agent-workflows/open-issues.md b/docs/design/agent-workflows/scratch/open-issues.md similarity index 100% rename from docs/design/agent-workflows/open-issues.md rename to docs/design/agent-workflows/scratch/open-issues.md diff --git a/docs/design/agent-workflows/pr-stack.md b/docs/design/agent-workflows/scratch/pr-stack.md similarity index 100% rename from docs/design/agent-workflows/pr-stack.md rename to docs/design/agent-workflows/scratch/pr-stack.md diff --git a/docs/design/agent-workflows/status.md b/docs/design/agent-workflows/scratch/status.md similarity index 100% rename from docs/design/agent-workflows/status.md rename to docs/design/agent-workflows/scratch/status.md diff --git a/docs/design/agent-workflows/sessions.md b/docs/design/agent-workflows/sessions.md deleted file mode 100644 index efe12702a9..0000000000 --- a/docs/design/agent-workflows/sessions.md +++ /dev/null @@ -1,129 +0,0 @@ -# Sessions - -The agent runtime has session ids today. It does not have durable server-owned session -history yet. - -## Today: Cold Replay - -Each turn is cold: - -1. The service creates a harness session. -2. The backend sends one `/run` request to the TypeScript runner. -3. The runner starts the needed process tree. -4. The harness completes one turn. -5. The session is destroyed. - -Nothing warm is kept between turns. The model sees prior conversation only because the -client sends message history again. - -On `/invoke`, that history is read from `data.inputs.messages`. - -On `/messages`, that history is read from `data.messages` in Vercel `UIMessage` shape, then -converted to neutral runtime messages before the same handler runs. - -## What The Session Id Does - -`session_id` is an opaque conversation id. `/messages` accepts it at the top level. If the -client omits it, the route mints one with a `sess_` prefix. If the client sends one, the -route validates the charset and length and echoes it. - -The id flows into: - -- `WorkflowInvokeRequest.session_id` -- `_agent(..., session_id=...)` -- `SessionConfig.session_id` -- the `/run` `sessionId` field -- the runner result -- the Vercel stream `start.messageMetadata.sessionId` -- the batch `WorkflowBatchResponse.session_id` - -The id groups turns, but it does not make the server authoritative for context yet. The -message history on the request is still what the model sees. - -## Intended Id Semantics - -The intended behavior is create-or-resume: - -- If the client omits `session_id`, the server creates one and returns it. -- If the client supplies a known `session_id`, the server resumes that session. -- If the client supplies an unknown but valid `session_id`, the server creates a session - using that id. - -The current implementation only validates and propagates the id. Because there is no -durable store, it cannot distinguish known from unknown ids yet. - -There should not be a required `create-session` endpoint for the normal chat path. The same -implicit creation pattern should cover pre-message operations too. For example, a file -upload before the first typed message can create a session and return the id that later -chat turns use. - -If a client already knows a session id and needs to render history, it should call -`/load-session` before sending the first message. - -## Streaming - -Streaming is implemented without changing the cold lifecycle. - -The runner emits live NDJSON records internally. The Python `AgentRun` turns those records -into live `AgentEvent` objects. The Vercel adapter projects each event into Vercel UI -Message Stream parts and the route frames them as SSE. - -This means the browser can see text, reasoning, tool calls, tool results, data parts, files, -errors, and finish metadata as they happen. It does not mean the session is warm or -persisted. - -## `/load-session` - -The route exists and calls a `SessionStore` port. The default store is `NoopSessionStore`. -It returns an empty list: - -```json -{ "session_id": "sess_abc", "messages": [] } -``` - -That makes the protocol testable, but it does not restore history. A production store still -needs to be selected and wired. - -## Missing Durable History - -To make sessions real, the platform needs: - -- A production `SessionStore` implementation. -- A call to `save_turn` after each completed `/messages` turn. -- Ownership checks keyed by project and caller. -- A load path that returns persisted Vercel `UIMessage` history. -- A policy for failed, cancelled, and partially streamed turns. - -Until that lands, clients must keep sending full history. - -## Missing Session Snapshots - -Durable chat history is only the MVP path. Stateful harnesses may also need their own -session state saved before teardown and loaded during setup. This is separate from storing -Vercel `UIMessage` history. - -Examples of state that may not be recoverable from messages alone: - -- sandbox-agent or ACP session blobs. -- Tool or harness state created during setup. -- Filesystem or process metadata needed to resume a warm-ish session after a cold restart. - -The interface is not designed yet. It likely needs explicit `save_session` and -`load_session` semantics around harness cleanup/setup, plus a storage decision after we -understand the size and shape of sandbox-agent/ACP session data. Small JSON blobs may fit in -Postgres. Large opaque blobs may need object storage. - -Retention should be short by default, measured in days. Traces may have a different -retention policy. - -## Later: Warm Sessions - -Warm sessions are separate from durable cold history. A warm model would keep the daemon or -harness state alive and use ACP `session/load` or equivalent state restoration. That can -recover state a transcript cannot, but it also needs a filesystem jail, per-session secret -channels, and clear multi-tenant isolation. - -The likely order remains: - -1. Add server-owned history while keeping cold replay. -2. Add warm daemon sessions only if long-running stateful agents need them. diff --git a/docs/design/agent-workflows/trash/.gitkeep b/docs/design/agent-workflows/trash/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sdks/python/agenta/__init__.py b/sdks/python/agenta/__init__.py index 15d1af84a4..f01ef2c141 100644 --- a/sdks/python/agenta/__init__.py +++ b/sdks/python/agenta/__init__.py @@ -60,7 +60,6 @@ AgentConfig, ClaudeHarness, Environment, - InProcessPiBackend, LocalBackend, PiHarness, SandboxAgentBackend, diff --git a/sdks/python/agenta/sdk/agents/__init__.py b/sdks/python/agenta/sdk/agents/__init__.py index 534ca0f650..fc5159d68c 100644 --- a/sdks/python/agenta/sdk/agents/__init__.py +++ b/sdks/python/agenta/sdk/agents/__init__.py @@ -5,7 +5,7 @@ - ``dtos.py`` — data contracts (``AgentConfig``, ``SessionConfig``, ``Message``, ...). - ``interfaces.py`` — the ports (ABCs): ``Backend``, ``Environment``, ``Sandbox``, ``Session``, ``Harness``. -- ``adapters/`` — implementations: ``SandboxAgentBackend`` / ``InProcessPiBackend`` / ``LocalBackend`` +- ``adapters/`` — implementations: ``SandboxAgentBackend`` / ``LocalBackend`` and ``PiHarness`` / ``ClaudeHarness``. - ``utils/`` — shared plumbing (the ``/run`` wire and the transports to the TS runner). @@ -23,12 +23,34 @@ from .adapters import ( AgentaHarness, ClaudeHarness, - InProcessPiBackend, LocalBackend, PiHarness, SandboxAgentBackend, make_harness, ) +from .capabilities import ( + HARNESS_CONNECTION_CAPABILITIES, + HarnessConnectionCapabilities, + harness_allows_mode, + harness_allows_provider, +) +from .connections import ( + AgentConnectionError, + AmbiguousConnectionError, + Connection, + ConnectionNotFoundError, + ConnectionResolutionError, + ConnectionResolver, + Endpoint, + EnvConnectionResolver, + ModelRef, + ProviderMismatchError, + ResolvedConnection, + RuntimeAuthContext, + StaticConnectionResolver, + UnsupportedConnectionModeError, + UnsupportedProviderError, +) from .dtos import ( AgentaAgentConfig, AgentConfig, @@ -40,9 +62,11 @@ HarnessCapabilities, HarnessType, Message, + NetworkEgress, PermissionPolicy, PiAgentConfig, RunSelection, + SandboxPermission, SessionConfig, ToolCallback, TraceContext, @@ -70,6 +94,16 @@ MissingMCPSecretError, ResolvedMCPServer, ) +from .skills import ( + SkillConfig, + SkillConfigurationError, + SkillError, + SkillFile, + parse_skill_config, + parse_skill_configs, + skill_to_wire, + skills_to_wire, +) from .streaming import AgentRun from .tools import ( BuiltinToolConfig, @@ -98,7 +132,6 @@ coerce_tool_config, coerce_tool_configs, parse_tool_config, - parse_tool_configs, ) from .adapters.vercel import ( from_ui_messages, @@ -130,6 +163,8 @@ "TraceContext", "ToolCallback", "PermissionPolicy", + "SandboxPermission", + "NetworkEgress", # Canonical tools API "ToolConfig", "BuiltinToolConfig", @@ -148,7 +183,6 @@ "EnvironmentToolSecretProvider", "MissingSecretPolicy", "parse_tool_config", - "parse_tool_configs", "coerce_tool_config", "coerce_tool_configs", "ToolError", @@ -165,6 +199,36 @@ "MCPError", "MCPConfigurationError", "MissingMCPSecretError", + # Skills are a sibling subsystem + "SkillConfig", + "SkillFile", + "parse_skill_config", + "parse_skill_configs", + "skill_to_wire", + "skills_to_wire", + "SkillError", + "SkillConfigurationError", + # Connections are a sibling subsystem (provider / model / auth) + "ModelRef", + "Connection", + "Endpoint", + "ResolvedConnection", + "RuntimeAuthContext", + "ConnectionResolver", + "EnvConnectionResolver", + "StaticConnectionResolver", + "AgentConnectionError", + "ConnectionResolutionError", + "ConnectionNotFoundError", + "AmbiguousConnectionError", + "ProviderMismatchError", + "UnsupportedProviderError", + "UnsupportedConnectionModeError", + # Minimal per-harness connection-capability table (subset; harness-capabilities owns the full one) + "HarnessConnectionCapabilities", + "HARNESS_CONNECTION_CAPABILITIES", + "harness_allows_provider", + "harness_allows_mode", # Interfaces (ports) "Backend", "Sandbox", @@ -179,7 +243,6 @@ "ToolResolutionError", # Adapters "SandboxAgentBackend", - "InProcessPiBackend", "LocalBackend", "PiHarness", "ClaudeHarness", diff --git a/sdks/python/agenta/sdk/agents/adapters/__init__.py b/sdks/python/agenta/sdk/agents/adapters/__init__.py index 9cce3f7240..769a22d1b3 100644 --- a/sdks/python/agenta/sdk/agents/adapters/__init__.py +++ b/sdks/python/agenta/sdk/agents/adapters/__init__.py @@ -1,7 +1,7 @@ """Adapters: concrete implementations of the agent runtime ports. -- Backend adapters: ``SandboxAgentBackend`` (sandbox-agent over ACP), ``InProcessPiBackend`` (in-process Pi, - the reference backend), ``LocalBackend`` (standalone SDK runs; not yet implemented). +- Backend adapters: ``SandboxAgentBackend`` (sandbox-agent over ACP), + ``LocalBackend`` (standalone SDK runs; not yet implemented). - Harness adapters: ``PiHarness``, ``ClaudeHarness``, ``AgentaHarness`` (+ ``make_harness``). - HTTP/browser protocol adapters live in subpackages, e.g. ``adapters.vercel``. @@ -9,13 +9,11 @@ """ from .harnesses import AgentaHarness, ClaudeHarness, PiHarness, make_harness -from .in_process import InProcessPiBackend from .local import LocalBackend from .sandbox_agent import SandboxAgentBackend __all__ = [ "SandboxAgentBackend", - "InProcessPiBackend", "LocalBackend", "PiHarness", "ClaudeHarness", diff --git a/sdks/python/agenta/sdk/agents/adapters/agenta_builtins.py b/sdks/python/agenta/sdk/agents/adapters/agenta_builtins.py index b5fae23bd2..28817ba4c6 100644 --- a/sdks/python/agenta/sdk/agents/adapters/agenta_builtins.py +++ b/sdks/python/agenta/sdk/agents/adapters/agenta_builtins.py @@ -5,13 +5,14 @@ - a base **persona** appended to Pi's system prompt (``AGENTA_FORCED_APPEND_SYSTEM``), - a base **AGENTS.md preamble** the author's instructions are appended to (``AGENTA_PREAMBLE``), -- a set of **forced tools** (``AGENTA_FORCED_TOOLS``), and -- a set of **forced skills** (``AGENTA_FORCED_SKILLS``). +- a set of **forced tools** (``AGENTA_FORCED_TOOLS``). -The forced *policy* lives here (harness knowledge). The forced skill *files* live with the -runner that runs Pi, under ``services/agent/skills//``; the contract between the two is -the skill directory **name**, so each entry in ``AGENTA_FORCED_SKILLS`` must match a committed -directory there. +Forced skills are *not* a constant here. They are platform default skills served from a +code-defined ``PlatformWorkflowCatalog`` under the reserved ``_agenta.*`` slug namespace and +embedded into the agent config (resolved server-side into concrete +:class:`~agenta.sdk.agents.skills.SkillConfig` packages before the runner). By the time this +harness runs, those defaults already ride ``AgentConfig.skills``, so the adapter needs no name +list. The catalogue is a separate workstream (see the skills-config proposal). Two layers, kept distinct on purpose (matching Pi's own split, see :class:`PiAgentConfig`): the *persona* is an ``append_system`` (changes Pi's base prompt), while *project conventions* @@ -51,12 +52,6 @@ # ``read`` tool is available. ``bash`` lets skills run their helper scripts. AGENTA_FORCED_TOOLS: List[str] = ["read", "bash"] -# Built-in skills every Agenta run forces on. Each name must match a committed directory under -# the runner's ``services/agent/skills//`` (the runner resolves names to those dirs). -# -# TODO(product): grow this with the real Agenta skill set. -AGENTA_FORCED_SKILLS: List[str] = ["agenta-getting-started"] - def _join(*parts: Optional[str]) -> Optional[str]: """Join the non-empty parts with a blank line, or ``None`` when nothing remains.""" diff --git a/sdks/python/agenta/sdk/agents/adapters/harnesses.py b/sdks/python/agenta/sdk/agents/adapters/harnesses.py index e718c1db2b..8b93847893 100644 --- a/sdks/python/agenta/sdk/agents/adapters/harnesses.py +++ b/sdks/python/agenta/sdk/agents/adapters/harnesses.py @@ -9,7 +9,10 @@ - **Claude** has no built-in tools (they are a Pi concept), delivers tools over MCP, and gates tool use, so the permission policy applies. - **Agenta** is Pi with an opinion: the same engine and config shape, plus a fixed set of - forced tools, skills, a base AGENTS.md preamble, and a persona (see :mod:`.agenta_builtins`). + forced tools, a base AGENTS.md preamble, and a persona (see :mod:`.agenta_builtins`). + Skills ride the neutral config as resolved inline packages. Pi and Agenta install them + through Pi skill dirs; Claude carries them so the runner can write project-local + `.claude/skills` packages. Seeding platform default skills is a separate workstream. The backend below stays pure plumbing; this layer owns the harness knowledge. """ @@ -30,7 +33,6 @@ from ..interfaces import Environment, Harness from ..tools.models import ToolSpec, coerce_tool_spec from .agenta_builtins import ( - AGENTA_FORCED_SKILLS, compose_append_system, compose_instructions, force_tools, @@ -64,10 +66,14 @@ def _to_harness_config(self, config: SessionConfig) -> PiAgentConfig: return PiAgentConfig( agents_md=config.agent.instructions, model=config.agent.model, + resolved_connection=config.resolved_connection, builtin_names=list(config.builtin_names), tool_specs=list(config.tool_specs), tool_callback=config.tool_callback, mcp_servers=list(config.mcp_servers), + skills=list(config.agent.skills), + sandbox_permission=config.agent.sandbox_permission, + harness_options=config.agent.harness_options, system=_opt_str(pi_options.get("system")), append_system=_opt_str(pi_options.get("append_system")), ) @@ -85,12 +91,23 @@ def _to_harness_config(self, config: SessionConfig) -> ClaudeAgentConfig: "ClaudeHarness ignores %d built-in tool(s); built-ins are a Pi concept", len(config.builtin_names), ) + # Skills stay on the harness config; the runner materializes them under `.claude/skills` + # in the session cwd so Claude ACP can load the same resolved inline packages. + # The whole neutral harness_options bag (plus sandbox_permission + mcp_servers) is threaded + # onto the ClaudeAgentConfig; the config's `wire_harness_files` (the Python claude adapter) + # parses the `claude.permissions` slice and renders `.claude/settings.json` as a generic + # `harnessFiles` entry. No claude-specific parsing happens here; the runner just writes the + # files into the cwd. return ClaudeAgentConfig( agents_md=config.agent.instructions, model=config.agent.model, + resolved_connection=config.resolved_connection, tool_specs=list(config.tool_specs), tool_callback=config.tool_callback, mcp_servers=list(config.mcp_servers), + skills=list(config.agent.skills), + sandbox_permission=config.agent.sandbox_permission, + harness_options=config.agent.harness_options, permission_policy=config.permission_policy, ) @@ -98,9 +115,10 @@ def _to_harness_config(self, config: SessionConfig) -> ClaudeAgentConfig: class AgentaHarness(Harness): """Pi with an Agenta opinion. Same engine as :class:`PiHarness`, but every run carries the forced Agenta extras (see :mod:`.agenta_builtins`): a base AGENTS.md preamble the author's - instructions are appended to, a forced persona ``append_system``, forced tools, and forced - skills. The author's own Pi ``harness_options`` (``system`` / ``append_system``) still - apply, layered after the forced bits.""" + instructions are appended to, a forced persona ``append_system``, and forced tools. The + author's own Pi ``harness_options`` (``system`` / ``append_system``) still apply, layered + after the forced bits. Skills come from the neutral config as resolved inline packages; + seeding platform default skills is a separate project-creation workstream.""" harness_type = HarnessType.AGENTA @@ -111,15 +129,18 @@ def _to_harness_config(self, config: SessionConfig) -> AgentaAgentConfig: return AgentaAgentConfig( agents_md=compose_instructions(config.agent.instructions), model=config.agent.model, + resolved_connection=config.resolved_connection, builtin_names=force_tools(list(config.builtin_names)), tool_specs=list(config.tool_specs), tool_callback=config.tool_callback, mcp_servers=list(config.mcp_servers), + skills=list(config.agent.skills), + sandbox_permission=config.agent.sandbox_permission, + harness_options=config.agent.harness_options, system=_opt_str(pi_options.get("system")), append_system=compose_append_system( _opt_str(pi_options.get("append_system")) ), - skills=list(AGENTA_FORCED_SKILLS), ) diff --git a/sdks/python/agenta/sdk/agents/adapters/vercel/stream.py b/sdks/python/agenta/sdk/agents/adapters/vercel/stream.py index 6d0e1526b2..4d59b0db9d 100644 --- a/sdks/python/agenta/sdk/agents/adapters/vercel/stream.py +++ b/sdks/python/agenta/sdk/agents/adapters/vercel/stream.py @@ -2,13 +2,47 @@ from __future__ import annotations -from typing import Any, AsyncIterator, Dict, Optional +from typing import Any, AsyncIterator, Dict, Iterator, Optional from ...dtos import AgentResult from ...streaming import AgentRun from .messages import TOOL_APPROVAL_REQUEST +# The AI SDK UI message stream (`ai@6`) validates the `finish` frame's +# `finishReason` against this closed set. The runner surfaces the model's raw +# stop reason (e.g. Anthropic `end_turn`, OpenAI `length`), so map it on the way +# out; an unmapped reason falls back to `unknown` rather than failing validation. +_AI_SDK_FINISH_REASONS = frozenset( + {"stop", "length", "content-filter", "tool-calls", "error", "other", "unknown"} +) + +_FINISH_REASON_MAP = { + "end_turn": "stop", + "stop_sequence": "stop", + "max_tokens": "length", + "tool_use": "tool-calls", + "tool_calls": "tool-calls", + "function_call": "tool-calls", + "refusal": "content-filter", + "content_filter": "content-filter", +} + + +def _map_finish_reason(stop_reason: Optional[str]) -> Optional[str]: + """Map a raw model stop reason onto the AI SDK ``finishReason`` enum. + + Returns ``None`` when there is no stop reason (the frame then omits it). + Already-valid values pass through; unknown reasons become ``"unknown"``. + """ + if stop_reason is None: + return None + normalized = stop_reason.strip().lower() + if normalized in _AI_SDK_FINISH_REASONS: + return normalized + return _FINISH_REASON_MAP.get(normalized, "unknown") + + async def agent_run_to_vercel_parts( run: AgentRun, *, @@ -27,6 +61,9 @@ async def agent_run_to_vercel_parts( reasoning_seq = 0 usage: Optional[Dict[str, Any]] = None stop_reason: Optional[str] = None + # Tool-call ids already surfaced as a tool part. An approval request attaches + # to its tool part by id, so we synthesize one only when none preceded it. + seen_tool_calls: set = set() try: async for event in run: @@ -72,20 +109,20 @@ async def agent_run_to_vercel_parts( elif etype == "tool_call": tool_call_id = data.get("id") tool_name = data.get("name") + seen_tool_calls.add(tool_call_id) yield { "type": "tool-input-start", "toolCallId": tool_call_id, "toolName": tool_name, } - available: Dict[str, Any] = { + yield { "type": "tool-input-available", "toolCallId": tool_call_id, "toolName": tool_name, "input": data.get("input"), } if data.get("render") is not None: - available["render"] = data["render"] - yield available + yield _render_part(tool_call_id, data["render"]) elif etype == "tool_result": tool_call_id = data.get("id") if data.get("denied"): @@ -102,16 +139,16 @@ async def agent_run_to_vercel_parts( else: structured = data.get("data") out = structured if structured is not None else data.get("output") - available = { + yield { "type": "tool-output-available", "toolCallId": tool_call_id, "output": out, } if data.get("render") is not None: - available["render"] = data["render"] - yield available + yield _render_part(tool_call_id, data["render"]) elif etype == "interaction_request": - yield _interaction_part(data) + for part in _interaction_parts(data, seen_tool_calls): + yield part elif etype == "data": part: Dict[str, Any] = { "type": f"data-{data.get('name', 'data')}", @@ -148,8 +185,9 @@ async def agent_run_to_vercel_parts( yield {"type": "finish-step"} finish: Dict[str, Any] = {"type": "finish"} - if stop_reason is not None: - finish["finishReason"] = stop_reason + finish_reason = _map_finish_reason(stop_reason) + if finish_reason is not None: + finish["finishReason"] = finish_reason metadata: Dict[str, Any] = {} if usage: metadata["usage"] = usage @@ -160,27 +198,71 @@ async def agent_run_to_vercel_parts( yield finish -def _interaction_part(data: Dict[str, Any]) -> Dict[str, Any]: - """Project a neutral ``interaction_request`` event to a Vercel stream part.""" +def _interaction_parts( + data: Dict[str, Any], seen_tool_calls: set +) -> Iterator[Dict[str, Any]]: + """Project a neutral ``interaction_request`` event to Vercel stream parts. + + A ``permission`` request becomes the AI SDK ``tool-approval-request`` chunk, + which is a strict object (only ``type``/``approvalId``/``toolCallId``) and + attaches to the tool part with the same ``toolCallId``. The runner normally + emits that tool call first; if it didn't, synthesize a tool part from the + request payload so the approval has something to render against. + """ kind = data.get("kind") payload = data.get("payload") or {} if kind == "permission": - return { + tool_call_id = _approval_tool_call_id(payload) + tool_call = payload.get("toolCall") + if ( + tool_call_id is not None + and tool_call_id not in seen_tool_calls + and isinstance(tool_call, dict) + ): + seen_tool_calls.add(tool_call_id) + tool_name = ( + tool_call.get("name") or tool_call.get("title") or tool_call.get("kind") + ) + yield { + "type": "tool-input-start", + "toolCallId": tool_call_id, + "toolName": tool_name, + } + yield { + "type": "tool-input-available", + "toolCallId": tool_call_id, + "toolName": tool_name, + "input": tool_call.get("rawInput") or tool_call.get("input"), + } + yield { "type": TOOL_APPROVAL_REQUEST, "approvalId": data.get("id"), - "toolCallId": _approval_tool_call_id(payload), - "availableReplies": payload.get("availableReplies"), - "toolCall": payload.get("toolCall"), + "toolCallId": tool_call_id, } + return if kind == "input": - return {"type": "data-input-request", "id": data.get("id"), "data": payload} - return { + yield {"type": "data-input-request", "id": data.get("id"), "data": payload} + return + yield { "type": "data-interaction", "id": data.get("id"), "data": {"kind": kind, "payload": payload}, } +def _render_part(tool_call_id: Any, render: Any) -> Dict[str, Any]: + """Carry an agenta render hint as a custom ``data-render`` part. + + The AI SDK ``tool-input/output-available`` chunks are strict objects with no + ``render`` field, so the hint rides a sibling data part keyed by + ``toolCallId`` instead of inline on the tool part. + """ + return { + "type": "data-render", + "data": {"toolCallId": tool_call_id, "render": render}, + } + + def _approval_tool_call_id(payload: Dict[str, Any]) -> Optional[Any]: tool_call_id = payload.get("toolCallId") if tool_call_id is not None: diff --git a/sdks/python/agenta/sdk/agents/dtos.py b/sdks/python/agenta/sdk/agents/dtos.py index 44629c3bb9..76913aed7a 100644 --- a/sdks/python/agenta/sdk/agents/dtos.py +++ b/sdks/python/agenta/sdk/agents/dtos.py @@ -13,14 +13,23 @@ from enum import Enum from typing import Any, Callable, ClassVar, Dict, List, Literal, Optional, Tuple, Union -from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator +from pydantic import ( + AliasChoices, + BaseModel, + ConfigDict, + Field, + field_validator, + model_validator, +) +from .connections import ModelRef, ResolvedConnection from .mcp import ( MCPServerConfig, ResolvedMCPServer, mcp_servers_to_wire, parse_mcp_server_configs, ) +from .skills import SkillConfig, parse_skill_configs, skills_to_wire from .tools import ToolCallback, ToolConfig, ToolSpec, coerce_tool_configs from .tools.models import coerce_tool_spec @@ -50,6 +59,43 @@ def coerce(cls, value: "HarnessType | str") -> "HarnessType": PermissionPolicy = Literal["auto", "deny"] +# --------------------------------------------------------------------------- +# Sandbox permission (Layer 2: the sandbox security boundary) +# --------------------------------------------------------------------------- + + +class NetworkEgress(BaseModel): + """The sandbox's outbound-network policy. ``mode`` is ``on`` (allow all egress, the + default), ``off`` (block all egress), or ``allowlist`` (allow only the CIDR ranges in + ``allowlist``). This is *declared* config; the runner enforces it on the sandbox provider + in a later slice.""" + + mode: Literal["on", "off", "allowlist"] = "on" + allowlist: List[str] = Field( + default_factory=list + ) # CIDR ranges; mode == "allowlist" + + +class SandboxPermission(BaseModel): + """The sandbox security boundary an agent runs inside (authoring config, versioned). + + ``network`` is the outbound-egress policy; ``filesystem`` is declared but not enforced + today; ``enforcement`` picks ``strict`` (fail the run when the boundary cannot be applied) + or ``best_effort``. Optional on :class:`AgentConfig`: an unset value never reaches the wire, + so existing configs are unaffected.""" + + network: NetworkEgress = Field(default_factory=NetworkEgress) + filesystem: Optional[Literal["on", "readonly", "off"]] = ( + None # declared, NOT enforced + ) + enforcement: Literal["strict", "best_effort"] = "strict" + + def to_wire(self) -> Dict[str, Any]: + """The nested camelCase ``sandboxPermission`` object for the ``/run`` payload. ``filesystem`` + is dropped when unset (it is declared, not enforced) so an unset field never rides the wire.""" + return self.model_dump(mode="json", by_alias=True, exclude_none=True) + + # --------------------------------------------------------------------------- # Capabilities # --------------------------------------------------------------------------- @@ -320,10 +366,23 @@ class AgentConfig(BaseModel): model_config = ConfigDict(populate_by_name=True) instructions: Optional[str] = None + # ``model`` stays the back-compat plain string every caller reads and hands to a harness. + # ``model_ref`` is the structured provider/model/connection ref, populated only when the + # incoming ``model`` is structured (a dict or a ``ModelRef``); a plain string leaves it + # ``None`` so a string-only config's wire is byte-identical to before. See + # ``_split_model_ref`` and the provider-model-auth design (Concern 1). model: Optional[str] = None + model_ref: Optional[ModelRef] = None tools: List[ToolConfig] = Field(default_factory=list) mcp_servers: List[MCPServerConfig] = Field(default_factory=list) + skills: List[SkillConfig] = Field(default_factory=list) harness_options: Dict[str, Dict[str, Any]] = Field(default_factory=dict) + sandbox_permission: Optional[SandboxPermission] = None + + @model_validator(mode="before") + @classmethod + def _coerce_model_ref(cls, data: Any) -> Any: + return _split_model_ref(data) @field_validator("tools", mode="before") @classmethod @@ -335,6 +394,11 @@ def _coerce_tools(cls, value: Any) -> List[ToolConfig]: def _coerce_mcp_servers(cls, value: Any) -> List[MCPServerConfig]: return parse_mcp_server_configs(_as_list(value)) + @field_validator("skills", mode="before") + @classmethod + def _coerce_skills(cls, value: Any) -> List[SkillConfig]: + return parse_skill_configs(_as_list(value)) + @classmethod def from_params( cls, @@ -357,7 +421,9 @@ def from_params( model=model, tools=_as_list(tools), mcp_servers=_parse_mcp_servers_raw(params, base), + skills=_parse_skills_raw(params, base), harness_options=_parse_harness_options(params, base), + sandbox_permission=_parse_sandbox_permission(params, base), ) @@ -408,9 +474,32 @@ class HarnessAgentConfig(BaseModel): harness: ClassVar[HarnessType] agents_md: Optional[str] = None + # ``model`` stays the back-compat plain string the adapter hands to the harness. + # ``model_ref`` carries the structured ref when one is supplied; it is populated only from + # structured input (a dict / a ``ModelRef``), so a plain-string ``model`` leaves it + # ``None`` and the wire is unchanged. See :meth:`wire_model_ref`. model: Optional[str] = None + model_ref: Optional[ModelRef] = None + # ``resolved_connection`` carries the least-privilege output of a ``ConnectionResolver`` + # (threaded down from ``SessionConfig``). It is the authoritative source of the non-secret + # provider/model descriptor on the wire when present; unset leaves the wire unchanged (the + # golden contract). Its ``env`` is the secret channel and never reaches the wire here (it + # rides ``secrets``). See :meth:`wire_resolved_connection`. + resolved_connection: Optional[ResolvedConnection] = None tool_callback: Optional[ToolCallback] = None mcp_servers: List[ResolvedMCPServer] = Field(default_factory=list) + skills: List[SkillConfig] = Field(default_factory=list) + sandbox_permission: Optional[SandboxPermission] = None + # The neutral per-harness options bag (a map keyed by harness name), carried verbatim from + # ``AgentConfig.harness_options`` by the harness adapter. The active harness's CONFIG translates + # its own slice into rendered files for the wire (see :meth:`wire_harness_files`); the raw bag + # itself does not ride the wire anymore. + harness_options: Dict[str, Dict[str, Any]] = Field(default_factory=dict) + + @model_validator(mode="before") + @classmethod + def _coerce_model_ref(cls, data: Any) -> Any: + return _split_model_ref(data) @field_validator("mcp_servers", mode="before") @classmethod @@ -422,6 +511,14 @@ def _coerce_resolved_mcp_servers(cls, value: Any) -> List[ResolvedMCPServer]: for item in value or [] ] + @field_validator("skills", mode="before") + @classmethod + def _coerce_skills(cls, value: Any) -> List[SkillConfig]: + return [ + item if isinstance(item, SkillConfig) else SkillConfig.model_validate(item) + for item in value or [] + ] + def wire_tools(self) -> Dict[str, Any]: """The tool + permission fields this harness contributes to the ``/run`` payload.""" raise NotImplementedError @@ -438,6 +535,78 @@ def wire_mcp(self) -> Dict[str, Any]: return {} return {"mcpServers": mcp_servers_to_wire(self.mcp_servers)} + def wire_skills(self) -> Dict[str, Any]: + """The ``skills`` field for the ``/run`` payload. Skills are not tools, so they ride + their own seam (sibling of :meth:`wire_mcp`). Omitted when none are declared so a + skill-free run's payload is unchanged (the golden wire contract). Every entry is a + resolved inline package by the time the wire is built.""" + if not self.skills: + return {} + return {"skills": skills_to_wire(self.skills)} + + def wire_sandbox_permission(self) -> Dict[str, Any]: + """The ``sandboxPermission`` field for the ``/run`` payload. Omitted when unset so a + run without a declared boundary is unchanged (the golden wire contract). Plumbing only: + the runner does not enforce it yet (a later slice applies it on the sandbox provider).""" + if self.sandbox_permission is None: + return {} + return {"sandboxPermission": self.sandbox_permission.to_wire()} + + def wire_harness_files(self) -> Dict[str, Any]: + """The generic ``harnessFiles`` field for the ``/run`` payload: files this harness's config + renders to drop in the session cwd before the session starts. Empty by default (Pi/Agenta + render none), so a config that produces no files is unchanged (the golden wire contract). + + This is where the per-harness translation of the generic ``harness_options`` bag happens in + Python (it used to happen in the TS runner). A harness that turns its own options slice into + a config file overrides this; the runner is then a dumb writer that materializes each + ``{path, content}`` entry into the cwd (``path`` relative to cwd) and has no harness + knowledge.""" + return {} + + def wire_model_ref(self) -> Dict[str, Any]: + """The non-secret provider/connection fields for the ``/run`` payload. + + Empty when ``model_ref`` is unset, so a string-only config's payload is byte-identical + to before (the golden wire contract). When a structured ref is present this emits only + the fields known at config-build time: ``provider`` (when set) and ``connection`` (when + it carries non-default info). ``deployment`` / ``endpoint`` / ``credentialMode`` come + from a :class:`ResolvedConnection`, which Slice 1 does not yet thread, so they are not + emitted here. The plain ``model`` string still rides the wire separately for back-compat. + """ + if self.model_ref is None: + return {} + out: Dict[str, Any] = {} + if self.model_ref.provider: + out["provider"] = self.model_ref.provider + connection = self.model_ref.connection + # Two modes only: the project default is ``agenta`` with no slug and carries no info + # beyond the model, so it is omitted (byte-identical wire). Emit the connection only when + # it is ``self_managed`` or names a slug. + is_default = connection.mode == "agenta" and connection.slug is None + if not is_default: + wire_connection: Dict[str, Any] = {"mode": connection.mode} + if connection.slug is not None: + wire_connection["slug"] = connection.slug + out["connection"] = wire_connection + return out + + def wire_resolved_connection(self) -> Dict[str, Any]: + """The non-secret resolved-connection descriptor for the ``/run`` payload. + + Empty when ``resolved_connection`` is unset, so a config without a resolved connection + is byte-identical to before (the golden wire contract). When a resolved connection is + present this is the AUTHORITATIVE source of the provider/model descriptor: it emits + ``provider``, ``model`` (the resolved exact model), ``deployment``, ``credentialMode``, + and ``endpoint`` (via :meth:`ResolvedConnection.to_wire`, which NEVER emits ``env``). It + is spread AFTER the base ``model`` and after :meth:`wire_model_ref` in + ``request_to_wire``, so the resolved ``provider``/``model`` win over the config-build + values while ``connection`` (the author's ``{mode, slug}`` intent) is preserved. The + secret ``env`` rides the existing ``secrets`` wire field, never here.""" + if self.resolved_connection is None: + return {} + return self.resolved_connection.to_wire() + class PiAgentConfig(HarnessAgentConfig): """Pi's config. Built-in tools by name plus resolved specs delivered natively (Pi has no @@ -528,23 +697,33 @@ def wire_tools(self) -> Dict[str, Any]: "permissionPolicy": self.permission_policy, } + def wire_harness_files(self) -> Dict[str, Any]: + """Render the Claude harness's permission settings into a ``.claude/settings.json`` file + the runner drops in the cwd. This is the claude adapter (Layer 1 translation), done in + Python: parse the author's ``harness_options["claude"]["permissions"]`` slice, merge the + Layer-2 ``sandbox_permission`` derivation and the per-MCP-server Layer-3 permissions, and + emit one ``harnessFiles`` entry. Omitted when Claude has nothing to write (no author options + and no derived rules), so a boundary-free Claude run is byte-identical to before.""" + # Lazy import: ``adapters.claude_settings`` is light, but importing it at module top would + # run ``adapters/__init__`` (which imports the harness adapters, which import this module), + # so it is imported here to keep ``dtos`` free of that cycle. + from .adapters.claude_settings import build_claude_settings_files + + files = build_claude_settings_files( + self.harness_options, self.sandbox_permission, self.mcp_servers + ) + if not files: + return {} + return {"harnessFiles": files} + class AgentaAgentConfig(PiAgentConfig): """The Agenta harness's config. It *is* a Pi config (same engine, same tool delivery and - system-prompt layers), plus the forced ``skills`` the Agenta harness always ships. - - ``skills`` are skill directory names the runner resolves against its bundled - ``services/agent/skills/`` root and loads into Pi's resource loader, so they appear in the - system prompt on every run.""" + system-prompt layers). ``skills`` ride the inherited :meth:`wire_skills` seam as resolved + inline packages, not through ``wire_tools`` (skills are not tools).""" harness: ClassVar[HarnessType] = HarnessType.AGENTA - skills: List[str] = Field(default_factory=list) - - def wire_tools(self) -> Dict[str, Any]: - # Same tool fields as Pi, plus the forced skill names the runner loads. - return {**super().wire_tools(), "skills": list(self.skills)} - # --------------------------------------------------------------------------- # The session bundle @@ -564,6 +743,10 @@ class SessionConfig(BaseModel): agent: AgentConfig secrets: Dict[str, str] = Field(default_factory=dict) + # ``resolved_connection`` carries the least-privilege output of a ``ConnectionResolver``. + # ``secrets`` is the compatibility alias for ``resolved_connection.env`` during the + # transition: Slice 1 still ships the credential through ``secrets`` on the wire. + resolved_connection: Optional[ResolvedConnection] = None permission_policy: PermissionPolicy = "auto" trace: Optional[TraceContext] = None session_id: Optional[str] = None @@ -615,6 +798,34 @@ def _as_list(raw: Any) -> List[Any]: return [] +def _split_model_ref(data: Any) -> Any: + """Populate ``model_ref`` from a structured ``model`` and keep ``model`` a plain string. + + Shared ``mode="before"`` validator body for :class:`AgentConfig` and + :class:`HarnessAgentConfig`. The lowest-risk wiring (no behavior change in Slice 1): + + - ``model`` is a dict or a :class:`ModelRef` -> set ``model_ref`` from it and project + ``model`` to its plain ``provider/model`` string. A structured config gains a typed ref + and a back-compat string at once. + - ``model`` is a plain string (bare or ``"provider/model"``) -> leave it as-is and leave + ``model_ref`` ``None``. A string-only config is unchanged, so its wire stays + byte-identical (the golden contract). + + An explicit ``model_ref`` already supplied is respected and never overwritten. + """ + if not isinstance(data, dict): + return data + if data.get("model_ref") is not None: + return data + model = data.get("model") + if isinstance(model, (ModelRef, dict)): + ref = ModelRef.coerce(model) + data = dict(data) + data["model_ref"] = ref + data["model"] = ref.to_model_string() + return data + + def _parse_mcp_servers_raw( params: Dict[str, Any], defaults: AgentConfig, @@ -631,6 +842,24 @@ def _parse_mcp_servers_raw( return _as_list(raw) +def _parse_skills_raw( + params: Dict[str, Any], + defaults: AgentConfig, +) -> List[Any]: + """Pull the raw ``skills`` list from a request/config dict, falling back to defaults. + + Reads ``skills`` from the ``agent`` element when present, else the flat request. Mirrors + the MCP path so an unparsed ``skills`` is not silently dropped; canonical validation happens + on :class:`AgentConfig` construction. Each entry is a concrete inline ``SkillConfig`` by the + time the request is built (any ``@ag.embed`` reference resolved server-side first).""" + agent = params.get("agent") + source = agent if isinstance(agent, dict) else params + raw = source.get("skills") + if raw is None: + return list(defaults.skills) + return _as_list(raw) + + def _parse_harness_options( params: Dict[str, Any], defaults: AgentConfig, @@ -653,6 +882,27 @@ def _parse_harness_options( return options or dict(defaults.harness_options) +def _parse_sandbox_permission( + params: Dict[str, Any], + defaults: AgentConfig, +) -> Optional[SandboxPermission]: + """Pull the sandbox permission object from a request/config dict, falling back to defaults. + + Reads ``sandbox_permission`` from the ``agent`` element when present, else the flat request. + Validates the loose dict into a :class:`SandboxPermission`; an absent value stays ``None`` so + it never reaches the wire (existing configs are unaffected).""" + agent = params.get("agent") + source = agent if isinstance(agent, dict) else params + raw = source.get("sandbox_permission") + if raw is None: + return defaults.sandbox_permission + if isinstance(raw, SandboxPermission): + return raw + if isinstance(raw, dict): + return SandboxPermission.model_validate(raw) + return defaults.sandbox_permission + + def _system_text(messages: Optional[List[Any]]) -> str: """Join the system-message content of a prompt-template into AGENTS.md text.""" parts: List[str] = [] diff --git a/sdks/python/agenta/sdk/agents/interfaces.py b/sdks/python/agenta/sdk/agents/interfaces.py index e03fb646a6..05752b9560 100644 --- a/sdks/python/agenta/sdk/agents/interfaces.py +++ b/sdks/python/agenta/sdk/agents/interfaces.py @@ -5,7 +5,7 @@ - ``Backend`` is the engine. It declares which harnesses it can drive (``supported_harnesses``), owns sandbox + session lifecycle, and is pure plumbing: it takes an already-harness-shaped config and launches it. Adapters: ``SandboxAgentBackend``, - ``InProcessPiBackend``, ``LocalBackend``. + ``LocalBackend``. - ``Sandbox`` is where a session's process tree lives, plus the provisioning verb (``add_files``). - ``Session`` is one conversation (``prompt``, ``destroy``). diff --git a/sdks/python/agenta/sdk/agents/skills/__init__.py b/sdks/python/agenta/sdk/agents/skills/__init__.py new file mode 100644 index 0000000000..d896cda077 --- /dev/null +++ b/sdks/python/agenta/sdk/agents/skills/__init__.py @@ -0,0 +1,21 @@ +"""Public skill configuration API. + +A skill is one inline shape (:class:`SkillConfig`); references to skills that live elsewhere +ride the existing ``@ag.embed`` mechanism and resolve into this same shape before the runner. +""" + +from .errors import SkillConfigurationError, SkillError +from .models import SkillConfig, SkillFile +from .parsing import parse_skill_config, parse_skill_configs +from .wire import skill_to_wire, skills_to_wire + +__all__ = [ + "SkillConfig", + "SkillFile", + "parse_skill_config", + "parse_skill_configs", + "skill_to_wire", + "skills_to_wire", + "SkillError", + "SkillConfigurationError", +] diff --git a/sdks/python/agenta/sdk/agents/skills/errors.py b/sdks/python/agenta/sdk/agents/skills/errors.py new file mode 100644 index 0000000000..267b2b0c51 --- /dev/null +++ b/sdks/python/agenta/sdk/agents/skills/errors.py @@ -0,0 +1,22 @@ +"""Errors raised while parsing skill configuration.""" + +from __future__ import annotations + +from typing import Any, Optional + + +class SkillError(RuntimeError): + """Base error for the agent skills subsystem.""" + + +class SkillConfigurationError(SkillError): + def __init__( + self, + message: str, + *, + index: Optional[int] = None, + value: Any = None, + ) -> None: + super().__init__(message) + self.index = index + self.value = value diff --git a/sdks/python/agenta/sdk/agents/skills/models.py b/sdks/python/agenta/sdk/agents/skills/models.py new file mode 100644 index 0000000000..5e96b5ebbc --- /dev/null +++ b/sdks/python/agenta/sdk/agents/skills/models.py @@ -0,0 +1,117 @@ +"""Canonical inline-skill declarations for the neutral agent config. + +A skill is one shape: an inline package (the SKILL.md frontmatter fields, a Markdown body, +and optional bundled files). There is no ``source``/``type`` discriminator and no "curated" +variant; a skill that lives elsewhere is referenced through ``@ag.embed`` and resolves, +server-side and before the runner, into a value of exactly this shape. +""" + +from __future__ import annotations + +from pathlib import PurePosixPath +from typing import Any, Dict, List + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +# Harness skill-name rule (Pi/Claude/OpenCode/Antigravity): lowercase, digits, single +# hyphens, <=64 chars. +_SKILL_NAME = Field(min_length=1, max_length=64, pattern=r"^[a-z0-9]+(-[a-z0-9]+)*$") + + +def _validate_safe_skill_file_path(path: str) -> str: + """Reject a bundled-file ``path`` that is absolute, escapes the skill dir, or collides with + the composed ``SKILL.md``. Enforced on the model itself (not only in ``parsing.py``) so every + construction path — direct ``SkillFile(...)`` / ``SkillConfig(...)`` included — is safe.""" + if path.startswith("/") or path.startswith("\\"): + raise ValueError( + f"Skill file path must be relative, got absolute path: {path!r}" + ) + # Reject backslash separators outright: they are not a valid relative POSIX path and a + # `\\` segment would not be caught by the PurePosixPath parts check below. + if "\\" in path: + raise ValueError( + f"Skill file path must use '/' separators, got backslash: {path!r}" + ) + parts = PurePosixPath(path).parts + if ".." in parts: + raise ValueError( + f"Skill file path must not escape the skill directory: {path!r}" + ) + # A bundled file at the skill-dir root named SKILL.md (case-insensitive) would overwrite the + # frontmatter the runner composes from name/description. + if len(parts) == 1 and parts[0].lower() == "skill.md": + raise ValueError( + f"Skill file path may not be SKILL.md (reserved for the composed frontmatter): {path!r}" + ) + return path + + +class SkillFile(BaseModel): + """One bundled file laid beside SKILL.md, by relative path. ``content`` is inline text + (UTF-8); a future ``uri`` variant can reference blob storage for binary assets. ``path`` is + validated to a safe relative path (no leading ``/``, no ``..``, not ``SKILL.md``) so a file + cannot escape the skill dir or clobber the composed frontmatter on materialize. ``content`` is + untrusted author code; see the proposal's Security section.""" + + model_config = ConfigDict(extra="forbid") + + path: str = Field( + min_length=1, max_length=255 + ) # safe relative path, e.g. "scripts/foo.py" + content: str = Field(max_length=200_000) # UTF-8 (binary -> a later uri variant) + executable: bool = False # chmod +x only if policy allows it + + @field_validator("path") + @classmethod + def _check_path(cls, value: str) -> str: + return _validate_safe_skill_file_path(value) + + def to_wire(self) -> Dict[str, Any]: + return { + "path": self.path, + "content": self.content, + "executable": self.executable, + } + + +class SkillConfig(BaseModel): + """An inline skill package. The SKILL.md frontmatter + body and any bundled files ride the + wire as content; the runner materializes them into a skill dir at run time. ``name`` and + ``description`` are the two portable frontmatter fields; ``body`` is this skill's own + SKILL.md Markdown content written after the composed frontmatter. + + To reference a skill instead of writing it inline, place an ``@ag.embed`` object in the + ``skills`` list (or in any field below). The embed resolves, server-side and before the + runner, into a value of exactly this shape.""" + + model_config = ConfigDict(extra="forbid") + + name: str = _SKILL_NAME + description: str = Field( + min_length=1, max_length=1024 + ) # the trigger; required everywhere + body: str = Field( + min_length=1, max_length=50_000 + ) # this skill's SKILL.md content after frontmatter + files: List[SkillFile] = Field(default_factory=list) # bundled scripts / references + disable_model_invocation: bool = ( + False # Pi/Claude: hide from prompt, only /skill:name + ) + allow_executable_files: bool = False # default deny; sandbox policy must also allow + + def to_wire(self) -> Dict[str, Any]: + """Serialize to the ``WireSkill`` shape (camelCase to match ``protocol.ts``). Optional + flags and ``files`` are emitted only when set so a minimal skill stays minimal on the + wire.""" + wire: Dict[str, Any] = { + "name": self.name, + "description": self.description, + "body": self.body, + } + if self.files: + wire["files"] = [file.to_wire() for file in self.files] + if self.disable_model_invocation: + wire["disableModelInvocation"] = True + if self.allow_executable_files: + wire["allowExecutableFiles"] = True + return wire diff --git a/sdks/python/agenta/sdk/agents/skills/parsing.py b/sdks/python/agenta/sdk/agents/skills/parsing.py new file mode 100644 index 0000000000..8f23a1de4e --- /dev/null +++ b/sdks/python/agenta/sdk/agents/skills/parsing.py @@ -0,0 +1,90 @@ +"""Strict parsing of inline skill configuration. + +Parses a raw ``skills`` list (entries are either :class:`SkillConfig` or plain dicts, the +latter being the post-embed-resolution shape) into validated :class:`SkillConfig` objects. + +The actual rules — the name pattern, field bounds, and the safe-relative-file-path / +``SKILL.md`` checks — live on the Pydantic models (:mod:`.models`), so *every* construction path +(including a direct ``SkillConfig(...)``) enforces them. This module only adapts the model's +:class:`~pydantic.ValidationError` into a :class:`SkillConfigurationError` that carries the +offending list index. +""" + +from __future__ import annotations + +from typing import Any, Mapping, Sequence + +from pydantic import ValidationError + +from .errors import SkillConfigurationError +from .models import SkillConfig + +# Embed markers the server-side resolver inlines before the runner. If one survives to here, +# resolution was skipped (e.g. `flags.resolve=False`), so we raise a clear, typed error rather +# than letting the strict model dump a confusing `extra="forbid"` ValidationError. +_AG_EMBED_MARKER = "@ag.embed" +_AG_SNIPPET_MARKER = "@{{" + + +def _unresolved_embed_message(value: Any) -> str | None: + """Return an error message if ``value`` still contains an unresolved embed, else ``None``. + + Walks nested mappings and sequences so an embed buried in a field (e.g. ``{"body": "@{{...}}"}`` + or a bundled file's ``content``) is caught here and surfaces the clear, typed error rather than + slipping past into a confusing strict-model ``ValidationError``. + """ + if isinstance(value, Mapping): + if _AG_EMBED_MARKER in value: + return ( + "Skill entry is an unresolved @ag.embed reference. Embeds resolve server-side " + "before parsing; this usually means resolution was opted out (flags.resolve=False) " + "or no resolver ran. Resolve embeds first, or pass an inline skill package." + ) + for nested in value.values(): + message = _unresolved_embed_message(nested) + if message is not None: + return message + elif isinstance(value, (list, tuple)): + for nested in value: + message = _unresolved_embed_message(nested) + if message is not None: + return message + elif isinstance(value, str) and ( + _AG_EMBED_MARKER in value or _AG_SNIPPET_MARKER in value + ): + return ( + "Skill entry contains an unresolved embed token. Embeds resolve server-side before " + "parsing; this usually means resolution was opted out (flags.resolve=False) or no " + "resolver ran. Resolve embeds first, or pass an inline skill package." + ) + return None + + +def parse_skill_config(value: SkillConfig | Mapping[str, Any]) -> SkillConfig: + message = _unresolved_embed_message(value) + if message is not None: + raise SkillConfigurationError(message, value=value) + try: + return SkillConfig.model_validate(value) + except ValidationError as exc: + raise SkillConfigurationError( + "Invalid skill configuration: " + f"{exc.errors(include_url=False, include_input=False)}", + value=value, + ) from exc + + +def parse_skill_configs( + values: Sequence[SkillConfig | Mapping[str, Any]], +) -> list[SkillConfig]: + parsed: list[SkillConfig] = [] + for index, value in enumerate(values): + try: + parsed.append(parse_skill_config(value)) + except SkillConfigurationError as exc: + raise SkillConfigurationError( + str(exc), + index=index, + value=value, + ) from exc + return parsed diff --git a/sdks/python/agenta/sdk/agents/skills/wire.py b/sdks/python/agenta/sdk/agents/skills/wire.py new file mode 100644 index 0000000000..421f44e2a3 --- /dev/null +++ b/sdks/python/agenta/sdk/agents/skills/wire.py @@ -0,0 +1,20 @@ +"""Serialization of resolved skills to the runner contract. + +By the time the wire is built every entry is a concrete :class:`SkillConfig` (references +resolved server-side via ``@ag.embed``), so there is one shape to emit: ``WireSkill`` (see +``services/agent/src/protocol.ts``). +""" + +from __future__ import annotations + +from typing import Any, Dict, Sequence + +from .models import SkillConfig + + +def skill_to_wire(skill: SkillConfig) -> Dict[str, Any]: + return skill.to_wire() + + +def skills_to_wire(skills: Sequence[SkillConfig]) -> list[Dict[str, Any]]: + return [skill_to_wire(skill) for skill in skills] diff --git a/sdks/python/agenta/sdk/agents/tools/__init__.py b/sdks/python/agenta/sdk/agents/tools/__init__.py index 2b40dc082e..91d36f0a46 100644 --- a/sdks/python/agenta/sdk/agents/tools/__init__.py +++ b/sdks/python/agenta/sdk/agents/tools/__init__.py @@ -33,9 +33,8 @@ ToolConfigBase, ToolSpec, ) -from .parsing import parse_tool_config, parse_tool_configs +from .parsing import parse_tool_config from .resolver import EnvironmentToolSecretProvider, ToolResolver -from .wire import tool_spec_to_wire, tool_specs_to_wire __all__ = [ "ToolConfigBase", @@ -57,13 +56,10 @@ "GatewayToolResolver", "EnvironmentToolSecretProvider", "parse_tool_config", - "parse_tool_configs", "coerce_tool_config", "coerce_tool_configs", "ToolConfigDiagnostic", "ToolConfigParseResult", - "tool_spec_to_wire", - "tool_specs_to_wire", "ToolError", "ToolConfigError", "ToolConfigurationError", diff --git a/sdks/python/agenta/sdk/agents/tools/parsing.py b/sdks/python/agenta/sdk/agents/tools/parsing.py index b5779caa19..add561323f 100644 --- a/sdks/python/agenta/sdk/agents/tools/parsing.py +++ b/sdks/python/agenta/sdk/agents/tools/parsing.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Mapping, Sequence +from typing import Any, Mapping from pydantic import ValidationError @@ -20,20 +20,3 @@ def parse_tool_config(value: ToolConfig | Mapping[str, Any]) -> ToolConfig: f"{exc.errors(include_url=False, include_input=False)}", value=value, ) from exc - - -def parse_tool_configs( - values: Sequence[ToolConfig | Mapping[str, Any]], -) -> list[ToolConfig]: - """Parse canonical tool mappings and report the failing item index.""" - parsed: list[ToolConfig] = [] - for index, value in enumerate(values): - try: - parsed.append(parse_tool_config(value)) - except ToolConfigurationError as exc: - raise ToolConfigurationError( - str(exc), - index=index, - value=value, - ) from exc - return parsed diff --git a/sdks/python/agenta/sdk/agents/tools/wire.py b/sdks/python/agenta/sdk/agents/tools/wire.py deleted file mode 100644 index 1f716b503d..0000000000 --- a/sdks/python/agenta/sdk/agents/tools/wire.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Serialization of resolved tool specifications to the runner contract.""" - -from __future__ import annotations - -from typing import Any, Dict, Sequence - -from .models import ToolSpec - - -def tool_spec_to_wire(tool_spec: ToolSpec) -> Dict[str, Any]: - return tool_spec.to_wire() - - -def tool_specs_to_wire(tool_specs: Sequence[ToolSpec]) -> list[Dict[str, Any]]: - return [tool_spec_to_wire(tool_spec) for tool_spec in tool_specs] diff --git a/sdks/python/agenta/sdk/agents/ui_messages.py b/sdks/python/agenta/sdk/agents/ui_messages.py deleted file mode 100644 index 2dc1f5e39b..0000000000 --- a/sdks/python/agenta/sdk/agents/ui_messages.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Compatibility imports for the Vercel UI Message adapter. - -New code should import from :mod:`agenta.sdk.agents.adapters.vercel`. -""" - -from __future__ import annotations - -from .adapters.vercel import ( - from_ui_messages, - to_ui_message, - ui_message_stream, -) - -__all__ = [ - "from_ui_messages", - "to_ui_message", - "ui_message_stream", -] diff --git a/sdks/python/agenta/sdk/agents/utils/wire.py b/sdks/python/agenta/sdk/agents/utils/wire.py index 1b203ed287..9deb8a73f9 100644 --- a/sdks/python/agenta/sdk/agents/utils/wire.py +++ b/sdks/python/agenta/sdk/agents/utils/wire.py @@ -39,7 +39,25 @@ def request_to_wire( ``config.wire_prompt()`` adds any system-prompt overrides the harness exposes (Pi's ``systemPrompt`` / ``appendSystemPrompt``); it is empty for harnesses that have none. ``config.wire_mcp()`` adds user-declared MCP servers, omitted when there are none so a - tool-free run's payload is unchanged. + tool-free run's payload is unchanged. ``config.wire_skills()`` adds resolved inline skill + packages, likewise omitted when there are none (skills ride their own seam, not the tool + wire). ``config.wire_sandbox_permission()`` adds the declared sandbox security boundary, + omitted when unset (plumbing only; the runner does not enforce it yet). + ``config.wire_model_ref()`` adds the non-secret provider/connection fields, omitted when no + structured ``model_ref`` is set so a string-only config's payload is unchanged (the secret + still rides ``secrets``; ``model`` stays the plain string). + ``config.wire_resolved_connection()`` adds the resolved-connection descriptor + (``provider`` / ``model`` / ``deployment`` / ``credentialMode`` / ``endpoint``), omitted when + no ``resolved_connection`` is threaded so a config without one is unchanged. It is spread + LAST among the model fields so the resolved ``provider``/``model`` override the base ``model`` + and ``wire_model_ref``'s ``provider`` (its ``env`` never reaches the wire; the secret rides + ``secrets``). + ``config.wire_harness_files()`` adds the generic ``harnessFiles`` array: files the active + harness's config rendered from its own ``harness_options`` slice, to materialize in the session + cwd before the session starts (``path`` relative to cwd, ``content`` the file text). Omitted + unless the config produced any files. This is where the per-harness translation happens in + Python (e.g. the claude config renders ``.claude/settings.json``); the runner is a dumb writer + that drops each entry into the cwd with no harness knowledge. """ return { "backend": engine, @@ -54,6 +72,11 @@ def request_to_wire( **config.wire_tools(), **config.wire_prompt(), **config.wire_mcp(), + **config.wire_skills(), + **config.wire_sandbox_permission(), + **config.wire_model_ref(), + **config.wire_resolved_connection(), + **config.wire_harness_files(), } diff --git a/sdks/python/agenta/sdk/engines/running/registry.py b/sdks/python/agenta/sdk/engines/running/registry.py deleted file mode 100644 index 2d66e62d2a..0000000000 --- a/sdks/python/agenta/sdk/engines/running/registry.py +++ /dev/null @@ -1,33 +0,0 @@ -from typing import Union -from json import dumps - -from agenta.sdk.utils.logging import get_module_logger -from agenta.sdk.engines.running.types import Data - - -log = get_module_logger(__name__) - - -async def exact_match_v1( - *, - parameters: Data, - inputs: Data, - outputs: Union[Data, str], -) -> Data: - success = False - - try: - reference_key = parameters.get("reference_key", None) - reference_outputs = inputs.get(reference_key, None) - - if isinstance(outputs, str) and isinstance(reference_outputs, str): - success = outputs == reference_outputs - elif isinstance(outputs, dict) and isinstance(reference_outputs, dict): - outputs = dumps(outputs, sort_keys=True) - reference_outputs = dumps(reference_outputs, sort_keys=True) - success = outputs == reference_outputs - - except Exception: # pylint: disable=bare-except - log.error("Error in exact_match_v1", exc_info=True) - - return {"success": success} diff --git a/sdks/python/agenta/sdk/engines/running/sandbox.py b/sdks/python/agenta/sdk/engines/running/sandbox.py index 2e013b5f3f..74a9ae219a 100644 --- a/sdks/python/agenta/sdk/engines/running/sandbox.py +++ b/sdks/python/agenta/sdk/engines/running/sandbox.py @@ -6,23 +6,6 @@ _runner = None -def is_import_safe(python_code: Text) -> bool: - """Checks if the imports in the python code contains a system-level import. - - Args: - python_code (str): The Python code to be executed - - Returns: - bool - module is secured or not - """ - - disallowed_imports = ["os", "subprocess", "threading", "multiprocessing"] - for import_ in disallowed_imports: - if import_ in python_code: - return False - return True - - def execute_code_safely( app_params: Dict[str, Any], inputs: Dict[str, Any], diff --git a/sdks/python/agenta/sdk/engines/running/utils.py b/sdks/python/agenta/sdk/engines/running/utils.py index a55a7069ca..9a5b3444f3 100644 --- a/sdks/python/agenta/sdk/engines/running/utils.py +++ b/sdks/python/agenta/sdk/engines/running/utils.py @@ -677,10 +677,14 @@ def infer_flags_from_data( is_application = flags.is_application is_evaluator = flags.is_evaluator is_snippet = flags.is_snippet + is_skill = flags.is_skill + is_platform = flags.is_platform else: is_application = default_application is_evaluator = default_evaluator is_snippet = default_snippet + is_skill = False + is_platform = False return WorkflowFlags( # uri-derived @@ -701,6 +705,8 @@ def infer_flags_from_data( is_evaluator=is_evaluator, is_application=is_application, is_snippet=is_snippet, + is_skill=is_skill, + is_platform=is_platform, ) diff --git a/sdks/python/agenta/sdk/middlewares/running/resolver.py b/sdks/python/agenta/sdk/middlewares/running/resolver.py index 556dc21b61..ea2783cba1 100644 --- a/sdks/python/agenta/sdk/middlewares/running/resolver.py +++ b/sdks/python/agenta/sdk/middlewares/running/resolver.py @@ -570,22 +570,30 @@ async def __call__( _merge_tracing_selector(retrieval_selector) revision = hydrated_revision or existing_revision - # Resolve embeds in parameters if enabled (via flags.resolve) + if not request.data: + request.data = WorkflowRequestData() + + # Resolve @ag.embed references in the parameters that actually drive the handler. + # The effective source is the inline `request.data.parameters` when the caller sent + # them (the playground running an unsaved config — `revision` is None there), otherwise + # the revision's. Handle each source explicitly and write back only what was resolved. + # The embed resolver walks arrays, so an `@ag.embed` inside `parameters.skills[i]` + # resolves on either path. resolve_flag = (request.flags or {}).get("resolve", True) - if ( - resolve_flag - and revision - and revision.parameters - and _has_embed_markers(revision.parameters) - ): - try: - resolved_params = await resolve_embeds( + + if request.data.parameters: + if resolve_flag and _has_embed_markers(request.data.parameters): + request.data.parameters = await resolve_embeds( + parameters=request.data.parameters, + credentials=ctx.credentials or request.credentials, + ) + elif revision and revision.parameters: + if resolve_flag and _has_embed_markers(revision.parameters): + revision.parameters = await resolve_embeds( parameters=revision.parameters, credentials=ctx.credentials or request.credentials, ) - revision.parameters = resolved_params - except Exception: - raise + request.data.parameters = revision.parameters handler = await resolve_handler(uri=(revision.uri if revision else None)) @@ -596,12 +604,6 @@ async def __call__( ) ctx.handler = handler - if not request.data: - request.data = WorkflowRequestData() - - if revision: - request.data.parameters = request.data.parameters or revision.parameters - TracingContext.get().parameters = request.data.parameters return await call_next(request) diff --git a/sdks/python/agenta/sdk/models/workflows.py b/sdks/python/agenta/sdk/models/workflows.py index 0cb751e9dc..dd55de24c3 100644 --- a/sdks/python/agenta/sdk/models/workflows.py +++ b/sdks/python/agenta/sdk/models/workflows.py @@ -90,6 +90,10 @@ class WorkflowFlags(BaseModel): is_application: bool = False is_evaluator: bool = False is_snippet: bool = False + is_skill: bool = False + # platform-owned (read-only): served from the PlatformWorkflowCatalog under the reserved + # `_agenta.*` slug namespace, never the database. A client must not edit or delete it. + is_platform: bool = False class WorkflowQueryFlags(BaseModel): @@ -118,6 +122,8 @@ class WorkflowQueryFlags(BaseModel): is_application: Optional[bool] = None is_evaluator: Optional[bool] = None is_snippet: Optional[bool] = None + is_skill: Optional[bool] = None + is_platform: Optional[bool] = None class WorkflowRevisionData(BaseModel): diff --git a/sdks/python/agenta/sdk/utils/types.py b/sdks/python/agenta/sdk/utils/types.py index 994c781aa4..a0c2951773 100644 --- a/sdks/python/agenta/sdk/utils/types.py +++ b/sdks/python/agenta/sdk/utils/types.py @@ -8,6 +8,7 @@ from pydantic import Field, model_validator, AliasChoices +from agenta.sdk.agents.dtos import SandboxPermission from agenta.sdk.agents.mcp import MCPServerConfig from agenta.sdk.agents.tools import ToolConfig from agenta.sdk.utils.assets import supported_llm_models, model_metadata @@ -1127,6 +1128,138 @@ class AgentConfigSchema(AgSchemaMixin): "in this headless run: auto-approve or deny." ), ) + sandbox_permission: Optional[SandboxPermission] = Field( + default=None, + title="Sandbox permission", + description=( + "The sandbox security boundary the agent runs inside: outbound network egress " + "(on / off / allowlist of CIDR ranges), filesystem access (declared), and " + "enforcement (strict or best-effort). Optional; unset means no declared boundary." + ), + ) + skills: List[Union["SkillConfigSchema", "_SkillEmbedRefSchema"]] = Field( + default_factory=list, + title="Skills", + description=( + "Skills the agent ships: each is an inline SKILL.md package (name, description, " + "body, optional bundled files) or an @ag.embed reference to a stored skill the " + "backend inlines into that same shape before the runner sees it." + ), + ) + + +class _SkillFileSchema(BaseModel): + """Strict twin of :class:`agenta.sdk.agents.skills.SkillFile` for schema generation. + + Re-declared (not imported) so the catalog editor describes one bundled file without + pulling the runtime model's validators into the playground's JSON Schema. + """ + + model_config = ConfigDict(extra="forbid") + + path: str = Field( + min_length=1, + max_length=255, + # Mirror the runtime SkillFile safe-path rules (skills/models.py): a relative POSIX path + # only. Reject a leading '/' (absolute), any backslash (Windows separator), and a '..' + # segment (dir escape), so the catalog/editor cannot accept a path the runtime rejects. + # Built from '/'-joined segments where each segment excludes '/' and '\' and is never + # exactly '..' (look-around free, since pydantic_core's regex engine rejects look-ahead). + pattern=( + r"^(?:[^/\\]|[^./\\][^/\\]*|\.[^./\\][^/\\]*|\.\.[^/\\]+)" + r"(?:/(?:[^/\\]|[^./\\][^/\\]*|\.[^./\\][^/\\]*|\.\.[^/\\]+))*$" + ), + title="Path", + description=( + "Relative path beside SKILL.md, e.g. 'scripts/foo.py'. Must be relative: no leading " + "'/', no backslashes, no '..' segment, and not SKILL.md (reserved for the frontmatter)." + ), + ) + content: str = Field( + max_length=200_000, + title="Content", + description="Inline UTF-8 file content.", + json_schema_extra={"x-ag-type": "textarea"}, + ) + executable: bool = Field( + default=False, + title="Executable", + description="Mark +x; only honored when the sandbox policy allows executable files.", + ) + + +class SkillConfigSchema(AgSchemaMixin): + """The playground's editable inline-skill package (one ``skills`` entry), as one semantic type. + + Schema-generation counterpart to the runtime :class:`agenta.sdk.agents.SkillConfig`: it emits + a rich JSON Schema for the ``skill_config`` control. The runtime model coerces the loose shapes + the playground emits; this strict twin describes them. A skill that lives elsewhere is authored + as an ``@ag.embed`` reference instead, which the backend inlines into this same shape. + """ + + model_config = ConfigDict(extra="forbid") + + __ag_type__ = "skill_config" + + name: str = Field( + min_length=1, + max_length=64, + pattern=r"^[a-z0-9]+(-[a-z0-9]+)*$", + title="Name", + description="Skill name (lowercase, digits, single hyphens, <=64 chars).", + ) + description: str = Field( + min_length=1, + max_length=1024, + title="Description", + description="The trigger the model matches; read by every harness.", + ) + body: str = Field( + min_length=1, + max_length=50_000, + title="Body", + description="The SKILL.md Markdown body written after the composed frontmatter.", + json_schema_extra={"x-ag-type": "textarea"}, + ) + files: List[_SkillFileSchema] = Field( + default_factory=list, + title="Files", + description="Bundled scripts / references laid beside SKILL.md by relative path.", + ) + disable_model_invocation: bool = Field( + default=False, + title="Disable model invocation", + description="Hide from the prompt; invoke only via /skill:name (Pi/Claude).", + ) + allow_executable_files: bool = Field( + default=False, + title="Allow executable files", + description="Default deny; the sandbox policy must also allow execution.", + ) + + +class _SkillEmbedRefSchema(BaseModel): + """An ``@ag.embed`` reference standing in for one ``skills`` entry. + + The seeded default config and the playground both keep skills the user references (rather than + writes inline) as a bare ``{"@ag.embed": {...}}`` object; the backend's embed resolver inlines + it into a :class:`SkillConfigSchema` shape before the runner sees it. So the raw/advanced + schema must accept this reference form alongside the inline package, or a valid default would + fail validation. The embed body is intentionally permissive (``Dict[str, Any]``) — its inner + ``@ag.references`` / ``@ag.selector`` keys are the embed resolver's contract, not this schema's. + """ + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + embed: Dict[str, Any] = Field( + alias="@ag.embed", + title="Embed reference", + description="An @ag.embed reference resolved server-side into an inline skill package.", + ) + + +# Resolve the forward references on AgentConfigSchema.skills (inline + embed-ref variants). +AgentConfigSchema.model_rebuild() CATALOG_TYPES = { @@ -1145,4 +1278,7 @@ class AgentConfigSchema(AgSchemaMixin): AgentConfigSchema.ag_type(): _dereference_schema( AgentConfigSchema.model_json_schema() ), + SkillConfigSchema.ag_type(): _dereference_schema( + SkillConfigSchema.model_json_schema() + ), } diff --git a/sdks/python/agenta/sdk/agents/adapters/in_process.py b/sdks/python/oss/tests/pytest/integration/agents/_in_process_backend.py similarity index 87% rename from sdks/python/agenta/sdk/agents/adapters/in_process.py rename to sdks/python/oss/tests/pytest/integration/agents/_in_process_backend.py index 114d0aa79f..7999ce621e 100644 --- a/sdks/python/agenta/sdk/agents/adapters/in_process.py +++ b/sdks/python/oss/tests/pytest/integration/agents/_in_process_backend.py @@ -1,12 +1,10 @@ -"""InProcessPiBackend: drive Pi in-process through the TS runner, no sandbox-agent daemon. +"""Test-only in-process backend: drive Pi in-process through the TS runner over a +subprocess, no sandbox-agent daemon. -This was the first backend implementation and stays as the simplest one: a single harness -(Pi), a single place (local), the legacy in-process Pi engine (``engines/pi.ts``). It is the -reference to read when writing a new backend. - -It is its own class and hard-codes its differences (the ``pi`` engine, Pi-only support, -local-only). It is deliberately NOT a subclass of ``SandboxAgentBackend``; the two are different -engines that happen to share the ``utils`` wire and transport helpers. +This is NOT a deployment backend. The service always uses ``SandboxAgentBackend``. This +class lives here, beside the transport round-trip test, only to exercise the real wire and +subprocess transport against a fake runner. It used to ship in the SDK as a public +"reference backend", which was misleading, so it now lives in the test tree. """ from __future__ import annotations @@ -14,7 +12,7 @@ import os from typing import Any, AsyncIterator, Dict, List, Mapping, Optional, Sequence -from ..dtos import ( +from agenta.sdk.agents.dtos import ( AgentResult, EventSink, HarnessAgentConfig, @@ -22,9 +20,9 @@ Message, TraceContext, ) -from ..interfaces import Backend, Sandbox, Session -from ..streaming import AgentRun -from ..utils import ( +from agenta.sdk.agents.interfaces import Backend, Sandbox, Session +from agenta.sdk.agents.streaming import AgentRun +from agenta.sdk.agents.utils import ( deliver_http, deliver_http_stream, deliver_subprocess, @@ -32,7 +30,7 @@ request_to_wire, result_from_wire, ) -from ._runner_config import resolve_runner_command +from agenta.sdk.agents.adapters._runner_config import resolve_runner_command class InProcessSandbox(Sandbox): diff --git a/sdks/python/oss/tests/pytest/integration/agents/test_transport_roundtrip.py b/sdks/python/oss/tests/pytest/integration/agents/test_transport_roundtrip.py index a73c30eecc..affac83c03 100644 --- a/sdks/python/oss/tests/pytest/integration/agents/test_transport_roundtrip.py +++ b/sdks/python/oss/tests/pytest/integration/agents/test_transport_roundtrip.py @@ -9,6 +9,7 @@ from __future__ import annotations +import json import sys import pytest @@ -16,11 +17,13 @@ from agenta.sdk.agents import ( AgentConfig, Environment, - InProcessPiBackend, Message, PiHarness, SessionConfig, ) +from agenta.sdk.agents.skills import SkillConfig + +from ._in_process_backend import InProcessPiBackend pytestmark = pytest.mark.integration @@ -74,6 +77,27 @@ json.load(sys.stdin) """ +# Reads the /run request and echoes back the `skills` it received in the result `output`, as +# JSON. This lets a test assert the runner actually received the resolved inline skill package +# (the full wire path: harness translation -> request_to_wire -> subprocess transport). +_SKILL_ECHO_RUNNER = """ +import sys, json + +req = json.load(sys.stdin) +skills = req.get("skills") +out = { + "ok": True, + "output": json.dumps(skills), + "messages": [{"role": "assistant", "content": "ok"}], + "events": [{"type": "done", "stopReason": "end_turn"}], + "usage": {"input": 1, "output": 1, "total": 2, "cost": 0.0}, + "stopReason": "end_turn", + "sessionId": "sess-fake", + "model": req.get("model"), +} +sys.stdout.write(json.dumps(out)) +""" + def _backend(tmp_path, body: str) -> InProcessPiBackend: runner = tmp_path / "fake_runner.py" @@ -111,3 +135,38 @@ async def test_runner_empty_output_raises(tmp_path): with pytest.raises(RuntimeError, match="no output"): await harness.prompt(config, [Message(role="user", content="hi")]) + + +async def test_resolved_skill_reaches_the_runner_over_the_wire(tmp_path): + # An AgentConfig carrying a resolved inline skill (the post-@ag.embed-resolution shape) must + # arrive at the runner as a concrete `skills` package over the real wire + transport, not as + # an embed and not dropped. The skill-echo runner reports the `skills` it saw. + harness = PiHarness(Environment(_backend(tmp_path, _SKILL_ECHO_RUNNER))) + skill = SkillConfig( + name="release-notes", + description="Draft release notes.", + body="Read the changelog, then write notes.", + files=[{"path": "scripts/draft.py", "content": "print(1)", "executable": True}], + disable_model_invocation=True, + allow_executable_files=True, + ) + config = SessionConfig( + agent=AgentConfig(instructions="hi", model="gpt-5.5", skills=[skill]) + ) + + result = await harness.prompt(config, [Message(role="user", content="ping")]) + + # The runner received the materialized inline package (camelCase flags, bundled file). + received = json.loads(result.output) + assert received == [ + { + "name": "release-notes", + "description": "Draft release notes.", + "body": "Read the changelog, then write notes.", + "files": [ + {"path": "scripts/draft.py", "content": "print(1)", "executable": True} + ], + "disableModelInvocation": True, + "allowExecutableFiles": True, + } + ] diff --git a/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.claude.json b/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.claude.json index 14944896fb..3bc25b7fd2 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.claude.json +++ b/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.claude.json @@ -17,12 +17,20 @@ "description": "Get a user", "inputSchema": {"type": "object", "properties": {}}, "callRef": "tools__composio__github__GET_THE_AUTHENTICATED_USER__github-tvn", - "kind": "callback" + "kind": "callback", + "readOnly": true, + "permission": "allow" } ], "toolCallback": { "endpoint": "https://api.example/tools/call", "authorization": "Access tok-123" }, - "permissionPolicy": "deny" + "permissionPolicy": "deny", + "harnessFiles": [ + { + "path": ".claude/settings.json", + "content": "{\n \"permissions\": {\n \"defaultMode\": \"acceptEdits\",\n \"allow\": [\n \"Read\",\n \"Bash(npm run:*)\"\n ],\n \"deny\": [\n \"WebFetch\"\n ]\n }\n}" + } + ] } diff --git a/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.pi.json b/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.pi.json index ebfb966479..d55325c3bd 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.pi.json +++ b/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.pi.json @@ -23,7 +23,9 @@ "description": "Get a user", "inputSchema": {"type": "object", "properties": {}}, "callRef": "tools__composio__github__GET_THE_AUTHENTICATED_USER__github-tvn", - "kind": "callback" + "kind": "callback", + "readOnly": true, + "permission": "allow" } ], "toolCallback": { @@ -32,5 +34,21 @@ }, "permissionPolicy": "auto", "systemPrompt": "You are Pi.", - "appendSystemPrompt": "Be terse." + "appendSystemPrompt": "Be terse.", + "skills": [ + { + "name": "release-notes", + "description": "Draft release notes from a changelog.", + "body": "Read the changelog, then write release notes.", + "files": [ + {"path": "scripts/draft.py", "content": "print('draft')", "executable": true} + ], + "disableModelInvocation": true, + "allowExecutableFiles": true + } + ], + "sandboxPermission": { + "network": {"mode": "off", "allowlist": []}, + "enforcement": "strict" + } } diff --git a/sdks/python/oss/tests/pytest/unit/agents/skills/__init__.py b/sdks/python/oss/tests/pytest/unit/agents/skills/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/sdks/python/oss/tests/pytest/unit/agents/skills/__init__.py @@ -0,0 +1 @@ + diff --git a/sdks/python/oss/tests/pytest/unit/agents/skills/test_models.py b/sdks/python/oss/tests/pytest/unit/agents/skills/test_models.py new file mode 100644 index 0000000000..61ebe7f217 --- /dev/null +++ b/sdks/python/oss/tests/pytest/unit/agents/skills/test_models.py @@ -0,0 +1,145 @@ +"""``SkillConfig`` / ``SkillFile`` validation: the single inline-package shape. + +A skill is one shape (no discriminator). These lock the name pattern, the required fields, the +length bounds, the default-deny flags, and ``extra="forbid"`` so a stray key never rides the +wire. +""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from agenta.sdk.agents import SkillConfig, SkillFile + + +def _skill(**overrides): + base = { + "name": "release-notes", + "description": "Draft release notes from a changelog.", + "body": "Read the changelog, then write release notes.", + } + base.update(overrides) + return base + + +def test_minimal_skill_defaults(): + skill = SkillConfig(**_skill()) + assert skill.name == "release-notes" + assert skill.files == [] + assert skill.disable_model_invocation is False + assert skill.allow_executable_files is False + + +@pytest.mark.parametrize("name", ["release-notes", "a", "skill1", "a-b-c", "x9"]) +def test_valid_skill_names(name): + assert SkillConfig(**_skill(name=name)).name == name + + +@pytest.mark.parametrize( + "name", + [ + "Release-Notes", # uppercase + "release_notes", # underscore + "-leading", # leading hyphen + "trailing-", # trailing hyphen + "double--hyphen", # consecutive hyphens + "", # empty + "a" * 65, # too long + ], +) +def test_invalid_skill_names_rejected(name): + with pytest.raises(ValidationError): + SkillConfig(**_skill(name=name)) + + +def test_description_required_and_bounded(): + with pytest.raises(ValidationError): + SkillConfig(**_skill(description="")) + with pytest.raises(ValidationError): + SkillConfig(**_skill(description="x" * 1025)) + + +def test_body_required_and_bounded(): + with pytest.raises(ValidationError): + SkillConfig(**_skill(body="")) + with pytest.raises(ValidationError): + SkillConfig(**_skill(body="x" * 50_001)) + + +def test_extra_fields_forbidden(): + with pytest.raises(ValidationError): + SkillConfig(**_skill(source="curated")) + + +def test_skill_file_defaults_and_bounds(): + file = SkillFile(path="scripts/foo.py", content="print(1)") + assert file.executable is False + with pytest.raises(ValidationError): + SkillFile(path="", content="x") + with pytest.raises(ValidationError): + SkillFile(path="x", content="y" * 200_001) + + +def test_skill_file_extra_forbidden(): + with pytest.raises(ValidationError): + SkillFile(path="a", content="b", mode="0755") + + +@pytest.mark.parametrize( + "path", + [ + "/etc/passwd", # absolute + "../escape.py", # parent traversal + "scripts/../../escape.py", # traversal mid-path + "\\windows\\path", # backslash absolute + "scripts\\foo.py", # backslash separator + "SKILL.md", # would clobber the composed frontmatter + "skill.md", # ...case-insensitive + "Skill.MD", + ], +) +def test_skill_file_path_validated_on_the_model(path): + # The safe-path rule rides the model itself, so a *direct* construction (not just the + # parsing helper) rejects an unsafe path. This is the bypass the validator closes. + with pytest.raises(ValidationError): + SkillFile(path=path, content="x") + with pytest.raises(ValidationError): + SkillConfig(**_skill(files=[{"path": path, "content": "x"}])) + + +@pytest.mark.parametrize( + "path", ["scripts/foo.py", "references/notes.md", "a.txt", "nested/skill.md"] +) +def test_skill_file_safe_paths_accepted_on_the_model(path): + # A nested `skill.md` (not at the dir root) is fine; only the root SKILL.md is reserved. + assert SkillFile(path=path, content="x").path == path + + +def test_to_wire_minimal_omits_optional_flags(): + wire = SkillConfig(**_skill()).to_wire() + assert wire == { + "name": "release-notes", + "description": "Draft release notes from a changelog.", + "body": "Read the changelog, then write release notes.", + } + assert "files" not in wire + assert "disableModelInvocation" not in wire + assert "allowExecutableFiles" not in wire + + +def test_to_wire_carries_files_and_flags_camelcase(): + wire = SkillConfig( + **_skill( + files=[ + {"path": "scripts/foo.py", "content": "print(1)", "executable": True} + ], + disable_model_invocation=True, + allow_executable_files=True, + ) + ).to_wire() + assert wire["files"] == [ + {"path": "scripts/foo.py", "content": "print(1)", "executable": True} + ] + assert wire["disableModelInvocation"] is True + assert wire["allowExecutableFiles"] is True diff --git a/sdks/python/oss/tests/pytest/unit/agents/skills/test_parsing.py b/sdks/python/oss/tests/pytest/unit/agents/skills/test_parsing.py new file mode 100644 index 0000000000..81057bdfbe --- /dev/null +++ b/sdks/python/oss/tests/pytest/unit/agents/skills/test_parsing.py @@ -0,0 +1,89 @@ +"""``parse_skill_configs``: list-of-dicts -> ``List[SkillConfig]`` with safe-path validation. + +Tolerates entries that are already plain dicts (the post-embed-resolution shape) and rejects +bundled-file paths that are absolute or escape the skill dir. The index is carried on the error +so a caller can point at the offending entry. +""" + +from __future__ import annotations + +import pytest + +from agenta.sdk.agents import SkillConfig, parse_skill_config, parse_skill_configs +from agenta.sdk.agents.skills import SkillConfigurationError + + +def _skill(**overrides): + base = { + "name": "release-notes", + "description": "Draft release notes.", + "body": "Read the changelog.", + } + base.update(overrides) + return base + + +def test_parses_plain_dicts(): + parsed = parse_skill_configs([_skill(), _skill(name="other")]) + assert [s.name for s in parsed] == ["release-notes", "other"] + assert all(isinstance(s, SkillConfig) for s in parsed) + + +def test_passes_through_skill_config_instances(): + skill = SkillConfig(**_skill()) + assert parse_skill_config(skill).name == "release-notes" + + +def test_empty_list_is_empty(): + assert parse_skill_configs([]) == [] + + +def test_invalid_name_raises_with_index(): + with pytest.raises(SkillConfigurationError) as exc: + parse_skill_configs([_skill(), _skill(name="Bad Name")]) + assert exc.value.index == 1 + + +@pytest.mark.parametrize( + "path", + [ + "/etc/passwd", # absolute + "../escape.py", # parent traversal + "scripts/../../escape.py", # traversal mid-path + "\\windows\\path", # backslash absolute + "scripts\\foo.py", # backslash separator + "SKILL.md", # would clobber the composed frontmatter + "skill.md", # ...case-insensitive + ], +) +def test_rejects_unsafe_file_paths(path): + # The model's path validator raises a ValidationError, which the parser wraps into a + # SkillConfigurationError (so unsafe paths are rejected on the parsing path too). + with pytest.raises(SkillConfigurationError): + parse_skill_config(_skill(files=[{"path": path, "content": "x"}])) + + +@pytest.mark.parametrize("path", ["scripts/foo.py", "references/notes.md", "a.txt"]) +def test_accepts_safe_relative_file_paths(path): + skill = parse_skill_config(_skill(files=[{"path": path, "content": "x"}])) + assert skill.files[0].path == path + + +def test_unresolved_object_embed_raises_clear_error(): + # A raw @ag.embed reaching strict parsing means resolution was skipped (flags.resolve=False); + # surface a clear, typed error instead of a confusing extra="forbid" ValidationError dump. + embed = { + "@ag.embed": {"@ag.references": {"workflow_revision": {"slug": "my-skill"}}} + } + with pytest.raises(SkillConfigurationError) as exc: + parse_skill_config(embed) + assert "unresolved" in str(exc.value).lower() + + +def test_unresolved_snippet_token_raises_clear_error(): + with pytest.raises(SkillConfigurationError) as exc: + parse_skill_configs( + ["@{{workflow_revision.slug=my-skill, path=parameters.skill}}"] + ) + assert "unresolved" in str(exc.value).lower() + assert exc.value.index == 0 diff --git a/sdks/python/oss/tests/pytest/unit/agents/skills/test_skills_e2e.py b/sdks/python/oss/tests/pytest/unit/agents/skills/test_skills_e2e.py new file mode 100644 index 0000000000..cac7aa00c0 --- /dev/null +++ b/sdks/python/oss/tests/pytest/unit/agents/skills/test_skills_e2e.py @@ -0,0 +1,205 @@ +"""End-to-end SDK skill coverage: an `AgentConfig`'s skills land on the `/run` wire as +concrete inline packages, whether they were authored inline or pulled in via an `@ag.embed`. + +These lock the two author shapes the skills feature ships: + +1. **Inline skill -> wire.** An `AgentConfig` carrying an inline `SkillConfig` produces a runner + request whose `skills[0]` is the materialized inline package (name/description/body/files + + camelCase flags), via the `wire_skills()` seam that `request_to_wire` spreads. + +2. **Embed skill -> resolve -> wire.** An `AgentConfig` whose `skills` list holds an `@ag.embed` + entry, run through the resolution middleware against a MOCKED resolve endpoint that returns a + `SkillConfig`-shaped `parameters.skill`, ends up on the wire as a concrete inline package (the + embed is gone). This mirrors how the resolver tests mock `/workflows/revisions/resolve`, then + carries the resolved params the rest of the way: `from_params` -> harness -> `request_to_wire`. + +No live LLM, no runner, no network: the resolve endpoint is mocked and the wire is built directly. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import agenta as ag +from agenta.sdk.agents import ( + AgentConfig, + Environment, + HarnessType, + Message, + PiHarness, + SessionConfig, +) +from agenta.sdk.agents.skills import SkillConfig +from agenta.sdk.agents.utils import request_to_wire +from agenta.sdk.contexts.tracing import TracingContext, tracing_context_manager +from agenta.sdk.middlewares.running.resolver import ResolverMiddleware +from agenta.sdk.models.workflows import WorkflowInvokeRequest, WorkflowRequestData + + +_INLINE_SKILL = { + "name": "release-notes", + "description": "Draft release notes from a changelog.", + "body": "Read the changelog, then write release notes.", + "files": [ + {"path": "scripts/draft.py", "content": "print('draft')", "executable": True} + ], + "disable_model_invocation": True, + "allow_executable_files": True, +} + + +def _pi_wire(env: Environment, agent: AgentConfig) -> dict: + """Translate an `AgentConfig` through the Pi harness and serialize one turn to the wire.""" + harness = PiHarness(env) + pi_config = harness._to_harness_config(SessionConfig(agent=agent)) + return request_to_wire( + engine="pi", + harness=HarnessType.PI, + sandbox="local", + config=pi_config, + messages=[Message(role="user", content="ship it")], + ) + + +# --------------------------------------------------------------------------- inline -> wire + + +def test_inline_skill_materializes_on_the_wire(make_env): + env = make_env(supported=[HarnessType.PI]) + agent = AgentConfig(instructions="hi", model="gpt-5.5", skills=[_INLINE_SKILL]) + + wire = _pi_wire(env, agent) + + # The whole inline package rides the `skills` field (its own seam, not `tools`). + assert wire["skills"] == [ + { + "name": "release-notes", + "description": "Draft release notes from a changelog.", + "body": "Read the changelog, then write release notes.", + "files": [ + { + "path": "scripts/draft.py", + "content": "print('draft')", + "executable": True, + } + ], + # Optional flags ride the wire in camelCase. + "disableModelInvocation": True, + "allowExecutableFiles": True, + } + ] + + +def test_minimal_inline_skill_omits_optional_flags_on_the_wire(make_env): + env = make_env(supported=[HarnessType.PI]) + agent = AgentConfig( + instructions="hi", + model="gpt-5.5", + skills=[SkillConfig(name="a", description="d", body="b")], + ) + + wire = _pi_wire(env, agent) + + assert wire["skills"] == [{"name": "a", "description": "d", "body": "b"}] + # A minimal skill stays minimal: no optional keys leak onto the wire. + only = wire["skills"][0] + assert "files" not in only + assert "disableModelInvocation" not in only + assert "allowExecutableFiles" not in only + + +# ------------------------------------------------------ embed -> resolve -> wire + + +@pytest.mark.asyncio +async def test_embed_skill_resolves_to_a_concrete_package_on_the_wire(make_env): + # The author config references a skill by an `@ag.embed` inside the skills list (the platform default-config shape). The resolver inlines the stored `SkillConfig` BEFORE the handler + # builds the AgentConfig, so the runner must never see the embed -- only a concrete package. + params_with_embed = { + "skills": [ + { + "@ag.embed": { + "@ag.references": { + "workflow": {"slug": "_agenta.agenta-getting-started"} + }, + "@ag.selector": {"path": "parameters.skill"}, + } + } + ] + } + resolved_params = { + "skills": [ + { + "name": "agenta-getting-started", + "description": "Get started with Agenta.", + "body": "Welcome. Here is how to begin.", + } + ] + } + + request = WorkflowInvokeRequest( + credentials="test-creds", + flags={"resolve": True}, + data=WorkflowRequestData(parameters=params_with_embed), + ) + + # Mock the /workflows/revisions/resolve endpoint the resolver actually calls, so the real + # resolver code runs (detect embed -> resolve -> inline). Same seam the resolver tests use. + endpoint_response = MagicMock() + endpoint_response.raise_for_status = MagicMock() + endpoint_response.json = MagicMock( + return_value={"workflow_revision": {"data": {"parameters": resolved_params}}} + ) + post_mock = AsyncMock(return_value=endpoint_response) + + class _FakeAsyncClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def post(self, *args, **kwargs): + return await post_mock(*args, **kwargs) + + fake_async_api = MagicMock() + fake_async_api._client_wrapper._base_url = "http://api.test" + + with ( + patch.object(ag, "async_api", fake_async_api), + patch( + "agenta.sdk.middlewares.running.resolver.httpx.AsyncClient", + return_value=_FakeAsyncClient(), + ), + patch( + "agenta.sdk.middlewares.running.resolver.resolve_handler", + new_callable=AsyncMock, + return_value=MagicMock(), + ), + tracing_context_manager(TracingContext()), + ): + mw = ResolverMiddleware() + call_next = AsyncMock(return_value="result") + await mw(request, call_next) + + # The resolver hit the resolve endpoint and inlined the embed into the request params. + post_mock.assert_awaited_once() + assert request.data.parameters == resolved_params + + # Now carry the resolved params the rest of the way, exactly as the handler does: build the + # AgentConfig from them, translate through the harness, and serialize the wire. + env = make_env(supported=[HarnessType.PI]) + agent = AgentConfig.from_params(request.data.parameters) + wire = _pi_wire(env, agent) + + # The embed is gone; a concrete inline package rides the wire. + assert wire["skills"] == [ + { + "name": "agenta-getting-started", + "description": "Get started with Agenta.", + "body": "Welcome. Here is how to begin.", + } + ] + assert "@ag.embed" not in str(wire["skills"]) diff --git a/sdks/python/oss/tests/pytest/unit/agents/skills/test_wire.py b/sdks/python/oss/tests/pytest/unit/agents/skills/test_wire.py new file mode 100644 index 0000000000..166471f4bb --- /dev/null +++ b/sdks/python/oss/tests/pytest/unit/agents/skills/test_wire.py @@ -0,0 +1,47 @@ +"""``skills_to_wire``: resolved ``SkillConfig`` list -> the ``WireSkill[]`` runner contract. + +The wire is camelCase to match ``services/agent/src/protocol.ts``; optional flags and ``files`` +are omitted when unset so a minimal skill stays minimal. +""" + +from __future__ import annotations + +from agenta.sdk.agents import SkillConfig, skills_to_wire + + +def test_skills_to_wire_empty(): + assert skills_to_wire([]) == [] + + +def test_skills_to_wire_minimal(): + skills = [ + SkillConfig(name="a", description="d", body="b"), + SkillConfig(name="c", description="e", body="f"), + ] + assert skills_to_wire(skills) == [ + {"name": "a", "description": "d", "body": "b"}, + {"name": "c", "description": "e", "body": "f"}, + ] + + +def test_skills_to_wire_full_shape(): + skill = SkillConfig( + name="release-notes", + description="Draft release notes.", + body="Read the changelog.", + files=[{"path": "scripts/foo.py", "content": "print(1)", "executable": True}], + disable_model_invocation=True, + allow_executable_files=True, + ) + assert skills_to_wire([skill]) == [ + { + "name": "release-notes", + "description": "Draft release notes.", + "body": "Read the changelog.", + "files": [ + {"path": "scripts/foo.py", "content": "print(1)", "executable": True} + ], + "disableModelInvocation": True, + "allowExecutableFiles": True, + } + ] diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_dtos_agent_config.py b/sdks/python/oss/tests/pytest/unit/agents/test_dtos_agent_config.py index e4cf65716e..af6c13d606 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_dtos_agent_config.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_dtos_agent_config.py @@ -115,6 +115,40 @@ def test_from_params_coerces_single_tool_dict_to_list(): assert config.tools == [BuiltinToolConfig(name="solo")] +# ------------------------------------------------------------------- skills + + +_SKILL = { + "name": "release-notes", + "description": "Draft release notes.", + "body": "Read the changelog.", +} + + +def test_from_params_parses_skills_from_agent_element(): + config = AgentConfig.from_params({"agent": {"skills": [dict(_SKILL)]}}) + assert [s.name for s in config.skills] == ["release-notes"] + + +def test_from_params_parses_skills_from_flat_request(): + config = AgentConfig.from_params({"skills": [dict(_SKILL)]}) + assert [s.name for s in config.skills] == ["release-notes"] + + +def test_from_params_skills_default_empty(): + # An absent `skills` is not silently dropped into a default it never had; it is just empty. + config = AgentConfig.from_params({"agent": {"instructions": "I"}}) + assert config.skills == [] + + +def test_from_params_skills_falls_back_to_defaults_when_absent(): + defaults = AgentConfig(skills=[dict(_SKILL)]) + config = AgentConfig.from_params( + {"agent": {"instructions": "I"}}, defaults=defaults + ) + assert [s.name for s in config.skills] == ["release-notes"] + + def test_harness_options_drops_malformed_and_lowercases_keys(): config = AgentConfig.from_params( { diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_harness_adapters.py b/sdks/python/oss/tests/pytest/unit/agents/test_harness_adapters.py index 0b3b64ad43..3b8e3e83ea 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_harness_adapters.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_harness_adapters.py @@ -2,8 +2,10 @@ Pi and Claude genuinely differ (Pi takes built-ins and never gates tool use; Claude has no built-ins, delivers tools over MCP, and gates on a permission policy). Agenta is Pi with a -fixed opinion: a forced preamble, persona, tools, and skills. These tests lock that the -translation honors those differences and that ``make_harness`` validates support. +fixed opinion: a forced preamble, persona, and tools. Skills ride the neutral config as +resolved inline packages (seeding platform default skills is a separate workstream). These +tests lock that the translation honors those differences and that ``make_harness`` validates +support. """ from __future__ import annotations @@ -28,7 +30,6 @@ from agenta.sdk.agents.adapters import harnesses from agenta.sdk.agents.adapters.agenta_builtins import ( AGENTA_FORCED_APPEND_SYSTEM, - AGENTA_FORCED_SKILLS, AGENTA_FORCED_TOOLS, AGENTA_PREAMBLE, ) @@ -102,10 +103,15 @@ def test_pi_drops_blank_harness_options(make_env): # ------------------------------------------------------------------------- Agenta -def test_agenta_forces_skills_tools_preamble_and_persona(make_env): +def test_agenta_forces_tools_preamble_and_persona_and_carries_skills(make_env): harness = AgentaHarness(make_env(supported=[HarnessType.AGENTA])) + skill = { + "name": "release-notes", + "description": "Draft release notes.", + "body": "Read the changelog, then write notes.", + } config = _session_config( - agent=AgentConfig(instructions="My project rules.", model="m"), + agent=AgentConfig(instructions="My project rules.", model="m", skills=[skill]), builtin_tools=["web_search"], custom_tools=[{"name": "t", "callRef": "ref"}], tool_callback=_CALLBACK, @@ -122,9 +128,10 @@ def test_agenta_forces_skills_tools_preamble_and_persona(make_env): assert forced in result.builtin_tools assert "web_search" in result.builtin_tools assert "read" in result.builtin_tools - # Forced skills ride the config and reach the wire. - assert result.skills == list(AGENTA_FORCED_SKILLS) - assert result.wire_tools()["skills"] == list(AGENTA_FORCED_SKILLS) + # The author's resolved inline skills ride the config and reach the wire on their own seam. + assert [s.name for s in result.skills] == ["release-notes"] + assert "skills" not in result.wire_tools() + assert result.wire_skills()["skills"][0]["name"] == "release-notes" # The persona is forced onto append_system; custom tools and callback pass through. assert result.append_system.startswith(AGENTA_FORCED_APPEND_SYSTEM) assert result.custom_tools[0]["name"] == "t" @@ -156,12 +163,6 @@ def test_agenta_passes_through_user_pi_options(make_env): assert result.append_system.endswith("Be terse.") -def test_agenta_is_in_process_pi_supported(): - from agenta.sdk.agents import InProcessPiBackend - - assert InProcessPiBackend(url="http://runner").supports(HarnessType.AGENTA) - - def test_agenta_is_sandbox_agent_supported(): # Agenta is Pi with an opinion, so the sandbox-agent backend drives it too (on the `pi` ACP # agent, with the runner laying the forced skills into the sandbox). This is what lets @@ -197,6 +198,25 @@ def test_claude_drops_builtins_and_warns(make_env, monkeypatch): assert recorded, "expected a warning when built-ins are dropped" +def test_claude_carries_skills_for_project_local_materialization(make_env): + harness = ClaudeHarness(make_env(supported=[HarnessType.CLAUDE])) + skill = { + "name": "release-notes", + "description": "Draft release notes.", + "body": "Read the changelog, then write notes.", + } + config = _session_config( + agent=AgentConfig(instructions="hi", model="m", skills=[skill]) + ) + + result = harness._to_harness_config(config) + + # Claude keeps resolved inline packages on the config. The runner materializes them under + # `.claude/skills/` in the session cwd, matching Claude's project-local skill layout. + assert [s.name for s in result.skills] == ["release-notes"] + assert result.wire_skills()["skills"][0]["name"] == "release-notes" + + def test_claude_no_warning_without_builtins(make_env, monkeypatch): recorded = [] monkeypatch.setattr( @@ -211,6 +231,47 @@ def test_claude_no_warning_without_builtins(make_env, monkeypatch): assert recorded == [] +def test_claude_threads_options_and_renders_settings_file(make_env): + import json + + harness = ClaudeHarness(make_env(supported=[HarnessType.CLAUDE])) + options = { + "claude": { + "permissions": { + "default_mode": "acceptEdits", + "allow": ["Read"], + "deny": ["Write", "Edit"], + } + }, + "pi": {"system": "ignored for Claude"}, + } + agent = AgentConfig(instructions="hi", model="m", harness_options=options) + + result = harness._to_harness_config(_session_config(agent=agent)) + + # The whole map is threaded onto the config; the claude config's `wire_harness_files` (the + # Python claude adapter) translates its own `claude.permissions` slice into a rendered file. + assert result.harness_options == options + wire = result.wire_harness_files() + assert wire["harnessFiles"][0]["path"] == ".claude/settings.json" + assert json.loads(wire["harnessFiles"][0]["content"]) == { + "permissions": { + "defaultMode": "acceptEdits", + "allow": ["Read"], + "deny": ["Write", "Edit"], + } + } + + +def test_claude_without_harness_options_renders_no_files(make_env): + harness = ClaudeHarness(make_env(supported=[HarnessType.CLAUDE])) + + result = harness._to_harness_config(_session_config()) + + assert result.harness_options == {} + assert result.wire_harness_files() == {} + + # --------------------------------------------------------------- _normalize_tool_specs diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_runner_adapter_config.py b/sdks/python/oss/tests/pytest/unit/agents/test_runner_adapter_config.py index b60575fc8c..5b6ede56d0 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_runner_adapter_config.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_runner_adapter_config.py @@ -9,7 +9,6 @@ from agenta.sdk.agents import ( AgentRunnerConfigurationError, - InProcessPiBackend, SandboxAgentBackend, ) @@ -22,19 +21,19 @@ def runner_dir(tmp_path: Path) -> Path: return tmp_path -@pytest.mark.parametrize("backend_cls", [InProcessPiBackend, SandboxAgentBackend]) +@pytest.mark.parametrize("backend_cls", [SandboxAgentBackend]) def test_default_subprocess_requires_cwd(backend_cls): with pytest.raises(AgentRunnerConfigurationError, match="pass cwd"): backend_cls() -@pytest.mark.parametrize("backend_cls", [InProcessPiBackend, SandboxAgentBackend]) +@pytest.mark.parametrize("backend_cls", [SandboxAgentBackend]) def test_default_subprocess_requires_runner_cli(backend_cls, tmp_path: Path): with pytest.raises(AgentRunnerConfigurationError, match="src/cli.ts"): backend_cls(cwd=str(tmp_path)) -@pytest.mark.parametrize("backend_cls", [InProcessPiBackend, SandboxAgentBackend]) +@pytest.mark.parametrize("backend_cls", [SandboxAgentBackend]) def test_default_subprocess_accepts_runner_wrapper_cwd(backend_cls, runner_dir: Path): backend = backend_cls(cwd=str(runner_dir)) @@ -42,7 +41,7 @@ def test_default_subprocess_accepts_runner_wrapper_cwd(backend_cls, runner_dir: assert backend._command == ["pnpm", "exec", "tsx", "src/cli.ts"] -@pytest.mark.parametrize("backend_cls", [InProcessPiBackend, SandboxAgentBackend]) +@pytest.mark.parametrize("backend_cls", [SandboxAgentBackend]) def test_http_transport_does_not_require_runner_wrapper(backend_cls): backend = backend_cls(url="http://sandbox-agent:8765") @@ -50,7 +49,7 @@ def test_http_transport_does_not_require_runner_wrapper(backend_cls): assert backend._command == ["pnpm", "exec", "tsx", "src/cli.ts"] -@pytest.mark.parametrize("backend_cls", [InProcessPiBackend, SandboxAgentBackend]) +@pytest.mark.parametrize("backend_cls", [SandboxAgentBackend]) def test_custom_command_does_not_require_runner_wrapper(backend_cls): command = [sys.executable, "-m", "runner"] diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_ui_messages.py b/sdks/python/oss/tests/pytest/unit/agents/test_ui_messages.py index f7cce7d31c..4594bf2b3d 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_ui_messages.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_ui_messages.py @@ -257,8 +257,9 @@ async def test_full_turn_part_order(self): # start carries the session id; tool output prefers the structured `data`. assert parts[0]["messageMetadata"] == {"sessionId": "sess_123"} assert parts[4]["output"] == {"w": "sunny"} - # finish carries the usage and the stop reason. - assert parts[-1]["finishReason"] == "end_turn" + # finish carries the usage and the stop reason, mapped from the model's + # raw `end_turn` onto the AI SDK `finishReason` enum (`stop`). + assert parts[-1]["finishReason"] == "stop" assert parts[-1]["messageMetadata"]["usage"] == { "input": 820, "output": 36, @@ -309,8 +310,14 @@ async def test_permission_interaction_becomes_approval_request(self): assert approval["approvalId"] == "perm_1" # REQUIRED top-level toolCallId binds the approval to its tool part (RFC / AI SDK). assert approval["toolCallId"] == "call_1" - assert approval["availableReplies"] == ["once", "always", "reject"] - assert approval["toolCall"] == {"toolCallId": "call_1", "name": "deleteFile"} + # The AI SDK chunk is a strict object: only type/approvalId/toolCallId are + # allowed; the agenta-only availableReplies/toolCall keys must not leak. + assert set(approval.keys()) == {"type", "approvalId", "toolCallId"} + # No tool_call preceded, so a tool part is synthesized for the approval to + # attach to (toolName from the request's nested toolCall). + synth = next(p for p in parts if p["type"] == "tool-input-available") + assert synth["toolCallId"] == "call_1" + assert synth["toolName"] == "deleteFile" async def test_permission_tool_call_id_falls_back_to_nested_tool_call(self): # No top-level toolCallId on the payload: dig it out of the nested ACP toolCall detail. @@ -333,6 +340,36 @@ async def test_permission_tool_call_id_falls_back_to_nested_tool_call(self): approval = next(p for p in parts if p["type"] == "tool-approval-request") assert approval["toolCallId"] == "call_9" + async def test_permission_does_not_duplicate_an_already_streamed_tool_call(self): + # The tool call was already surfaced as a tool part, so the approval binds + # to it by id without synthesizing a second tool-input part. + run = _run( + events=[ + { + "type": "tool_call", + "id": "call_1", + "name": "deleteFile", + "input": {}, + }, + { + "type": "interaction_request", + "id": "perm_1", + "kind": "permission", + "payload": { + "toolCallId": "call_1", + "toolCall": {"toolCallId": "call_1", "name": "deleteFile"}, + }, + }, + {"type": "done"}, + ], + result={"output": ""}, + ) + parts = await _collect(run, session_id="s1") + inputs = [p for p in parts if p["type"] == "tool-input-available"] + assert len(inputs) == 1 # no synthesized duplicate + approval = next(p for p in parts if p["type"] == "tool-approval-request") + assert approval["toolCallId"] == "call_1" + async def test_tool_denial_becomes_output_denied(self): # A human denied the tool: it never ran, so emit tool-output-denied (not -available). run = _run( @@ -375,7 +412,41 @@ async def test_finish_trace_id_falls_back_to_terminal_result(self): parts = await _collect(run, session_id="s1") assert parts[-1]["messageMetadata"]["traceId"] == "trace_from_result" - async def test_render_hint_passes_through_tool_parts(self): + async def test_finish_reason_maps_model_stop_reason_to_ai_sdk_enum(self): + # The AI SDK `finish` chunk only accepts a closed `finishReason` enum; + # raw model reasons must be mapped or the client's stream validator + # rejects the whole frame. Unknown reasons fall back to `unknown`. + cases = { + "end_turn": "stop", + "stop_sequence": "stop", + "max_tokens": "length", + "tool_use": "tool-calls", + "refusal": "content-filter", + "stop": "stop", # already-valid value passes through + "wat": "unknown", # unmapped reason does not break validation + } + for raw, expected in cases.items(): + run = _run( + events=[ + {"type": "message", "text": "hi"}, + {"type": "done", "stopReason": raw}, + ], + result={"output": "hi"}, + ) + parts = await _collect(run, session_id="s1") + assert parts[-1]["finishReason"] == expected, raw + + async def test_finish_omits_reason_when_model_gives_none(self): + run = _run( + events=[{"type": "message", "text": "hi"}, {"type": "done"}], + result={"output": "hi"}, + ) + parts = await _collect(run, session_id="s1") + assert "finishReason" not in parts[-1] + + async def test_render_hint_rides_a_sibling_data_part(self): + # The AI SDK tool chunks are strict objects with no `render` field, so the + # hint travels as a `data-render` part keyed by toolCallId, not inline. render = {"kind": "component", "component": "WeatherCard"} run = _run( events=[ @@ -399,8 +470,14 @@ async def test_render_hint_passes_through_tool_parts(self): parts = await _collect(run, session_id="s1") available = next(p for p in parts if p["type"] == "tool-input-available") output = next(p for p in parts if p["type"] == "tool-output-available") - assert available["render"] == render - assert output["render"] == render + # render does not leak onto the strict tool chunks… + assert "render" not in available + assert "render" not in output + # …it rides one data-render part per tool frame, keyed by toolCallId. + renders = [p for p in parts if p["type"] == "data-render"] + assert len(renders) == 2 + assert all(p["data"]["toolCallId"] == "c1" for p in renders) + assert all(p["data"]["render"] == render for p in renders) async def test_tool_error_becomes_output_error(self): run = _run( diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py b/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py index c7f9497495..7d3a1a0b5a 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py @@ -12,14 +12,20 @@ from __future__ import annotations +import json + import pytest from agenta.sdk.agents import ( AgentaAgentConfig, ClaudeAgentConfig, + Endpoint, HarnessType, Message, PiAgentConfig, + ResolvedConnection, + SandboxPermission, + SkillConfig, ToolCallback, TraceContext, ) @@ -35,6 +41,11 @@ "sessionId", "agentsMd", "model", + "provider", + "connection", + "deployment", + "endpoint", + "credentialMode", "messages", "secrets", "trace", @@ -46,6 +57,8 @@ "systemPrompt", "appendSystemPrompt", "skills", + "sandboxPermission", + "harnessFiles", } _CUSTOM_TOOL = { @@ -54,10 +67,23 @@ "inputSchema": {"type": "object", "properties": {}}, "callRef": "tools__composio__github__GET_THE_AUTHENTICATED_USER__github-tvn", "kind": "callback", + "readOnly": True, } _CALLBACK = ToolCallback( endpoint="https://api.example/tools/call", authorization="Access tok-123" ) +# One resolved inline skill package (the post-embed shape that rides the wire). A bundled +# file is included so the `files[]` wire shape (camelCase `executable`) is exercised too. +_SKILL = { + "name": "release-notes", + "description": "Draft release notes from a changelog.", + "body": "Read the changelog, then write release notes.", + "files": [ + {"path": "scripts/draft.py", "content": "print('draft')", "executable": True} + ], + "disable_model_invocation": True, + "allow_executable_files": True, +} def _pi_payload(): @@ -67,6 +93,8 @@ def _pi_payload(): builtin_tools=["read", "write"], custom_tools=[dict(_CUSTOM_TOOL)], tool_callback=_CALLBACK, + skills=[dict(_SKILL)], + sandbox_permission=SandboxPermission(network={"mode": "off"}), system="You are Pi.", append_system="Be terse.", ) @@ -94,6 +122,15 @@ def _claude_payload(): custom_tools=[dict(_CUSTOM_TOOL)], tool_callback=_CALLBACK, permission_policy="deny", + harness_options={ + "claude": { + "permissions": { + "default_mode": "acceptEdits", + "allow": ["Read", "Bash(npm run:*)"], + "deny": ["WebFetch"], + } + } + }, ) return request_to_wire( engine="sandbox-agent", @@ -115,7 +152,7 @@ def _agenta_payload(): custom_tools=[dict(_CUSTOM_TOOL)], tool_callback=_CALLBACK, append_system="You are an Agenta agent.", - skills=["agenta-getting-started"], + skills=[dict(_SKILL)], ) return request_to_wire( engine="pi", @@ -133,27 +170,76 @@ def test_request_to_wire_agenta_carries_skills_and_pi_shape(): assert payload["permissionPolicy"] == "auto" assert payload["tools"] == ["read", "bash"] assert payload["appendSystemPrompt"] == "You are an Agenta agent." - # ...plus the forced skills the runner loads. - assert payload["skills"] == ["agenta-getting-started"] + # ...plus the resolved inline skill packages, on their own seam (not in `wire_tools`). + assert payload["skills"][0]["name"] == "release-notes" + assert payload["skills"][0]["files"][0]["path"] == "scripts/draft.py" -def test_request_to_wire_pi_has_no_skills_key(): - # Only the Agenta config emits `skills`; the plain Pi config must not. - assert "skills" not in _pi_payload() +def test_request_to_wire_skills_ride_their_own_seam_not_tools(): + # Skills are emitted by `wire_skills`, not folded into the tool wire. + config = PiAgentConfig(skills=[dict(_SKILL)]) + assert "skills" not in config.wire_tools() + assert config.wire_skills() == {"skills": [SkillConfig(**_SKILL).to_wire()]} + + +def test_request_to_wire_omits_skills_when_none(): + # No declared skills -> no `skills` key (keeps a skill-free payload byte-identical). + payload = request_to_wire( + engine="pi", + harness=HarnessType.PI, + sandbox="local", + config=PiAgentConfig(), + messages=[Message(role="user", content="hi")], + ) + assert "skills" not in payload def test_request_to_wire_pi_matches_golden(golden): - assert _pi_payload() == golden("run_request.pi.json") + payload = _pi_payload() + assert payload == golden("run_request.pi.json") + # The Composio read-only hint rides the wire as camelCase `readOnly`. + assert payload["customTools"][0]["readOnly"] is True + # No explicit author permission + read_only=True -> derived `allow` rides the wire. + assert payload["customTools"][0]["permission"] == "allow" + # The declared sandbox boundary rides the wire as nested camelCase `sandboxPermission`; + # the unset `filesystem` is dropped (declared, not enforced) so it never appears. + assert payload["sandboxPermission"] == { + "network": {"mode": "off", "allowlist": []}, + "enforcement": "strict", + } + # Pi renders no harness files, so the generic `harnessFiles` key is absent. + assert "harnessFiles" not in payload def test_request_to_wire_claude_matches_golden(golden): payload = _claude_payload() assert payload == golden("run_request.claude.json") + # No explicit author permission + read_only=True -> derived `allow` rides the wire. + assert payload["customTools"][0]["permission"] == "allow" # Claude-specific invariants the golden encodes, asserted explicitly so a failure reads clearly. assert payload["tools"] == [] # Claude has no Pi built-ins assert payload["permissionPolicy"] == "deny" # Claude gates tool use assert "systemPrompt" not in payload # Claude exposes no prompt overrides assert "appendSystemPrompt" not in payload + # No sandbox boundary declared on this config -> the key is absent (optional, default None). + assert "sandboxPermission" not in payload + # The claude adapter (Python) translated the author's permissions slice into a rendered + # `.claude/settings.json`, carried on the generic `harnessFiles` seam. The runner writes it blind. + assert payload["harnessFiles"] == [ + { + "path": ".claude/settings.json", + "content": json.dumps( + { + "permissions": { + "defaultMode": "acceptEdits", + "allow": ["Read", "Bash(npm run:*)"], + "deny": ["WebFetch"], + } + }, + indent=2, + ), + } + ] def test_request_to_wire_has_no_prompt_key(): @@ -179,6 +265,64 @@ def test_request_to_wire_emits_only_known_keys(): assert {"systemPrompt", "appendSystemPrompt"} <= set(pi) +def test_request_to_wire_carries_resolved_connection_non_secret_descriptor(): + # A threaded resolved connection is the authoritative provider/model descriptor: the + # resolved `model` overrides the config-build `model`, `provider`/`deployment`/ + # `credentialMode`/`endpoint.baseUrl` ride the wire, and the secret `key` NEVER does (it + # rides `secrets`; `env` is masked from the wire by `ResolvedConnection.to_wire`). + config = PiAgentConfig( + model="openai/gpt-5.5", # the config-build model + resolved_connection=ResolvedConnection( + provider="openai", + model="gpt-5.5-2026", # the resolved EXACT model, wins over `model` + deployment="custom", + credential_mode="env", + env={"OPENAI_API_KEY": "sk-secret"}, # secret channel; never on the wire + endpoint=Endpoint(base_url="https://gw.example/v1"), + ), + ) + payload = request_to_wire( + engine="pi", + harness=HarnessType.PI, + sandbox="local", + config=config, + messages=[Message(role="user", content="hi")], + secrets={"OPENAI_API_KEY": "sk-secret"}, # the secret rides here, by design + ) + assert set(payload) <= KNOWN_REQUEST_KEYS + assert payload["provider"] == "openai" + assert payload["credentialMode"] == "env" + assert payload["deployment"] == "custom" + assert payload["endpoint"] == {"baseUrl": "https://gw.example/v1"} + # Exactly one `model` key, and it is the resolved exact model (last spread wins). + assert payload["model"] == "gpt-5.5-2026" + # The secret only rides `secrets`; `env` is never serialized onto the wire. + assert payload["secrets"] == {"OPENAI_API_KEY": "sk-secret"} + assert "env" not in payload + assert ( + "sk-secret" not in {k: v for k, v in payload.items() if k != "secrets"}.values() + ) + + +def test_request_to_wire_omits_resolved_connection_when_none(): + # No resolved connection -> no resolved-connection keys, so a config without one is + # byte-identical to before (the golden contract; the golden fixtures set none). + config = PiAgentConfig(model="gpt-5.5") + payload = request_to_wire( + engine="pi", + harness=HarnessType.PI, + sandbox="local", + config=config, + messages=[Message(role="user", content="hi")], + ) + assert config.wire_resolved_connection() == {} + assert "provider" not in payload + assert "credentialMode" not in payload + assert "deployment" not in payload + assert "endpoint" not in payload + assert payload["model"] == "gpt-5.5" + + def test_pi_permission_policy_is_always_auto(): # Pi never gates tool use, regardless of any requested policy. payload = request_to_wire( @@ -299,3 +443,108 @@ def test_request_to_wire_omits_mcp_servers_when_none(): messages=[Message(role="user", content="hi")], ) assert "mcpServers" not in payload + + +def test_request_to_wire_omits_sandbox_permission_when_none(): + # No declared boundary -> no `sandboxPermission` key (keeps a boundary-free payload + # byte-identical, so existing configs/fixtures are unaffected). + payload = request_to_wire( + engine="pi", + harness=HarnessType.PI, + sandbox="local", + config=PiAgentConfig(), + messages=[Message(role="user", content="hi")], + ) + assert "sandboxPermission" not in payload + + +def test_request_to_wire_omits_harness_files_when_none(): + # No authored options on a Claude config -> the claude adapter renders nothing, so no + # `harnessFiles` key (a Claude run without harness options is byte-identical to before). + payload = request_to_wire( + engine="sandbox-agent", + harness=HarnessType.CLAUDE, + sandbox="local", + config=ClaudeAgentConfig(), + messages=[Message(role="user", content="hi")], + ) + assert "harnessFiles" not in payload + + +def test_request_to_wire_pi_renders_no_harness_files_from_its_options(): + # The per-harness translation is now in Python and only the claude config renders files; a Pi + # config carrying options (even a `claude` slice that is never its concern) emits no + # `harnessFiles`. The raw options map no longer rides the wire. + config = PiAgentConfig( + harness_options={ + "pi": {"system": "You are Pi."}, + "claude": {"permissions": {"default_mode": "plan"}}, + } + ) + payload = request_to_wire( + engine="pi", + harness=HarnessType.PI, + sandbox="local", + config=config, + messages=[Message(role="user", content="hi")], + ) + assert set(payload) <= KNOWN_REQUEST_KEYS + assert "harnessFiles" not in payload + assert "harnessOptions" not in payload + + +def test_request_to_wire_claude_renders_settings_from_options_and_boundaries(): + # The claude config's `wire_harness_files` is the Python claude adapter: it merges the author's + # permissions slice with the Layer-2 sandbox derivation and Layer-3 MCP permissions into one + # `.claude/settings.json` file. network:off -> WebFetch/WebSearch deny; an `ask` MCP server -> + # `mcp__` ask. The author's deny keeps its position; derived rules append (deduped). + config = ClaudeAgentConfig( + sandbox_permission=SandboxPermission(network={"mode": "off"}), + harness_options={"claude": {"permissions": {"default_mode": "plan"}}}, + mcp_servers=[ + { + "name": "github", + "transport": "http", + "url": "https://x", + "permission": "ask", + } + ], + ) + payload = request_to_wire( + engine="sandbox-agent", + harness=HarnessType.CLAUDE, + sandbox="local", + config=config, + messages=[Message(role="user", content="hi")], + ) + assert set(payload) <= KNOWN_REQUEST_KEYS + assert payload["harnessFiles"][0]["path"] == ".claude/settings.json" + settings = json.loads(payload["harnessFiles"][0]["content"]) + assert settings == { + "permissions": { + "defaultMode": "plan", + "deny": ["WebFetch", "WebSearch"], + "ask": ["mcp__github"], + } + } + + +def test_request_to_wire_carries_sandbox_permission_allowlist(): + # The allowlist mode rides the wire with its CIDR ranges and the default enforcement. + config = PiAgentConfig( + sandbox_permission=SandboxPermission( + network={"mode": "allowlist", "allowlist": ["10.0.0.0/8"]}, + ) + ) + payload = request_to_wire( + engine="pi", + harness=HarnessType.PI, + sandbox="local", + config=config, + messages=[Message(role="user", content="hi")], + ) + assert set(payload) <= KNOWN_REQUEST_KEYS + assert payload["sandboxPermission"] == { + "network": {"mode": "allowlist", "allowlist": ["10.0.0.0/8"]}, + "enforcement": "strict", + } diff --git a/sdks/python/oss/tests/pytest/unit/agents/tools/test_resolver.py b/sdks/python/oss/tests/pytest/unit/agents/tools/test_resolver.py index 7c7ef58b46..dddce819d2 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/tools/test_resolver.py +++ b/sdks/python/oss/tests/pytest/unit/agents/tools/test_resolver.py @@ -43,6 +43,7 @@ async def resolve( call_ref=tool.reference, needs_approval=tool.needs_approval, render=tool.render, + permission=tool.permission, ) for tool in tools ], @@ -115,6 +116,38 @@ async def test_gateway_metadata_survives_resolution(): assert spec.render == {"kind": "component", "component": "User"} +async def test_authored_permission_lands_on_resolved_code_spec_wire(): + # An author's Layer-3 permission on a config rides through resolution onto the wire. + resolved = await ToolResolver().resolve( + [CodeToolConfig(name="calc", script="...", permission="deny")] + ) + spec = resolved.tool_specs[0] + assert spec.permission == "deny" + assert spec.to_wire()["permission"] == "deny" + + +async def test_authored_permission_lands_on_resolved_gateway_spec_wire(): + resolved = await ToolResolver(gateway_resolver=FakeGatewayResolver()).resolve( + [ + GatewayToolConfig( + integration="github", + action="GET_USER", + connection="c1", + permission="deny", + ) + ] + ) + spec = resolved.tool_specs[0] + assert spec.permission == "deny" + assert spec.to_wire()["permission"] == "deny" + + +async def test_resolved_spec_omits_permission_when_unset(): + # Backward compatible: no authored permission -> no `permission` key on the wire. + resolved = await ToolResolver().resolve([CodeToolConfig(name="calc", script="...")]) + assert "permission" not in resolved.tool_specs[0].to_wire() + + @pytest.mark.parametrize( "configs", [ diff --git a/sdks/python/oss/tests/pytest/unit/test_skill_config_catalog.py b/sdks/python/oss/tests/pytest/unit/test_skill_config_catalog.py new file mode 100644 index 0000000000..ee6925a56e --- /dev/null +++ b/sdks/python/oss/tests/pytest/unit/test_skill_config_catalog.py @@ -0,0 +1,104 @@ +"""The ``skill_config`` catalog type and the ``skills`` field on the agent-config twin. + +These pin that the playground gets a typed editor for an inline skill package and that the +agent-config control renders a list of those skills, where each item is EITHER an inline +``skill_config`` package OR an ``@ag.embed`` reference the backend inlines server-side. +""" + +import jsonschema + +from agenta.sdk.utils.types import CATALOG_TYPES, SkillConfigSchema + + +def test_skill_config_registered_in_catalog(): + assert SkillConfigSchema.ag_type() == "skill_config" + assert "skill_config" in CATALOG_TYPES + + +def test_skill_config_schema_shape(): + schema = CATALOG_TYPES["skill_config"] + + assert schema["x-ag-type"] == "skill_config" + assert set(schema["properties"]) == { + "name", + "description", + "body", + "files", + "disable_model_invocation", + "allow_executable_files", + } + # name carries the harness skill-name rule. + assert schema["properties"]["name"]["pattern"] == r"^[a-z0-9]+(-[a-z0-9]+)*$" + # body renders as a textarea in the form. + assert schema["properties"]["body"]["x-ag-type"] == "textarea" + + file_item = schema["properties"]["files"]["items"] + assert set(file_item["properties"]) == {"path", "content", "executable"} + + +def test_agent_config_catalog_exposes_skills_as_inline_or_embed_union(): + agent_config = CATALOG_TYPES["agent_config"] + + assert "skills" in agent_config["properties"] + skills_item = agent_config["properties"]["skills"]["items"] + + # Each entry is a union: an inline skill_config package, or an @ag.embed reference. + variants = skills_item["anyOf"] + assert len(variants) == 2 + + inline = next(v for v in variants if v.get("x-ag-type") == "skill_config") + assert {"name", "description", "body"}.issubset(inline["properties"]) + + embed = next(v for v in variants if "@ag.embed" in v.get("properties", {})) + assert embed["required"] == ["@ag.embed"] + + +def _base_agent_config() -> dict: + """The shape ``services/oss/src/agent/schemas.py::_DEFAULT_AGENT_CONFIG`` seeds, minus skills.""" + return { + "agents_md": "hi", + "model": "gpt-4o", + "tools": [], + "mcp_servers": [], + "harness": "pi", + "sandbox": "local", + "permission_policy": "auto", + "sandbox_permission": { + "network": {"mode": "on", "allowlist": []}, + "enforcement": "strict", + }, + } + + +def test_platform_default_agent_config_with_embed_skill_validates(): + """The platform default ships an @ag.embed skill entry; the catalog schema must accept it.""" + agent_config = CATALOG_TYPES["agent_config"] + + config = _base_agent_config() + config["skills"] = [ + { + "@ag.embed": { + "@ag.references": { + "workflow": {"slug": "_agenta.agenta-getting-started"} + }, + "@ag.selector": {"path": "parameters.skill"}, + } + } + ] + + jsonschema.validate(config, agent_config) + + +def test_inline_skill_entry_validates(): + agent_config = CATALOG_TYPES["agent_config"] + + config = _base_agent_config() + config["skills"] = [ + { + "name": "release-notes", + "description": "Draft release notes.", + "body": "Read it.", + } + ] + + jsonschema.validate(config, agent_config) diff --git a/sdks/python/oss/tests/pytest/unit/test_skill_flags.py b/sdks/python/oss/tests/pytest/unit/test_skill_flags.py new file mode 100644 index 0000000000..e3208c3cda --- /dev/null +++ b/sdks/python/oss/tests/pytest/unit/test_skill_flags.py @@ -0,0 +1,56 @@ +"""Skill workflow-family flag derivation. + +A skill is a URI-less, non-runnable workflow. A URI-less workflow otherwise defaults to +``is_evaluator=True`` in :func:`infer_flags_from_data`, so a skill must be committed with an +explicit flags object that sets ``is_skill=True`` and ``is_evaluator=False``. These tests pin +that the explicit flags survive derivation and that ``is_skill`` is exposed on the SDK flag +models. +""" + +from agenta.sdk.engines.running.utils import infer_flags_from_data +from agenta.sdk.models.workflows import ( + WorkflowFlags, + WorkflowQueryFlags, + WorkflowRevisionData, +) + + +def _skill_data() -> WorkflowRevisionData: + return WorkflowRevisionData( + parameters={ + "skill": { + "name": "agenta-getting-started", + "description": "A starter skill.", + "body": "Do the thing.", + } + } + ) + + +def test_workflow_flags_expose_is_skill(): + flags = WorkflowFlags() + assert flags.is_skill is False + + query_flags = WorkflowQueryFlags() + assert query_flags.is_skill is None + + +def test_infer_flags_keeps_explicit_skill_flags_for_uri_less_workflow(): + flags = infer_flags_from_data( + flags=WorkflowFlags(is_skill=True, is_evaluator=False), + data=_skill_data(), + ) + + assert flags.is_skill is True + # The URI-less default is is_evaluator=True; the explicit flags object must override it. + assert flags.is_evaluator is False + assert flags.has_url is False + assert flags.has_script is False + assert flags.has_handler is False + + +def test_infer_flags_uri_less_default_without_flags_is_evaluator_not_skill(): + flags = infer_flags_from_data(flags=None, data=_skill_data()) + + assert flags.is_skill is False + assert flags.is_evaluator is True diff --git a/sdks/python/oss/tests/pytest/utils/test_resolver_middleware.py b/sdks/python/oss/tests/pytest/utils/test_resolver_middleware.py index 42ae116ade..d8b7d7e173 100644 --- a/sdks/python/oss/tests/pytest/utils/test_resolver_middleware.py +++ b/sdks/python/oss/tests/pytest/utils/test_resolver_middleware.py @@ -248,6 +248,166 @@ async def test_calls_resolve_when_markers_present(self): credentials="test-creds", ) + @pytest.mark.asyncio + async def test_resolves_embeds_in_inline_parameters(self): + """ + When the caller runs an UNSAVED config (parameters inline on + request.data.parameters, no data.revision), embeds in those inline + parameters must still resolve. This is the playground path the old + middleware skipped, because resolution was attached only to the + revision object. It is the regression the skills-config fix targets: + an @ag.embed inside `parameters.skills[i]` resolves on this path too. + """ + from agenta.sdk.middlewares.running.resolver import ResolverMiddleware + from agenta.sdk.models.workflows import ( + WorkflowInvokeRequest, + WorkflowRequestData, + ) + + params_with_embed = { + "skills": [ + { + "@ag.embed": { + "@ag.references": {"workflow_revision": {"slug": "my-skill"}}, + "@ag.selector": {"path": "parameters.skill"}, + } + } + ] + } + resolved_params = { + "skills": [ + { + "name": "my-skill", + "description": "A resolved skill.", + "body": "Do the thing.", + } + ] + } + + request = WorkflowInvokeRequest( + credentials="test-creds", + flags={"resolve": True}, + data=WorkflowRequestData(parameters=params_with_embed), + ) + + with ( + patch( + "agenta.sdk.middlewares.running.resolver.resolve_handler", + new_callable=AsyncMock, + return_value=MagicMock(), + ), + patch( + "agenta.sdk.middlewares.running.resolver.resolve_embeds", + new_callable=AsyncMock, + return_value=resolved_params, + ) as mock_resolve_embeds, + tracing_context_manager(TracingContext()), + ): + mw = ResolverMiddleware() + call_next = AsyncMock(return_value="result") + await mw(request, call_next) + + # Resolution ran on the inline parameters (no revision involved)... + mock_resolve_embeds.assert_called_once_with( + parameters=params_with_embed, + credentials="test-creds", + ) + # ...and the resolved parameters are written back so the handler sees concrete skills. + assert request.data.parameters == resolved_params + + @pytest.mark.asyncio + async def test_inline_embed_resolves_end_to_end_via_mocked_endpoint(self): + """ + Prove a REAL no-revision request resolves embeds end-to-end. Instead of patching the + `resolve_embeds` SDK helper, this mocks the `/workflows/revisions/resolve` HTTP endpoint + it calls, so the actual resolver code runs: the middleware detects the inline `@ag.embed` + in `parameters.skills[0]`, calls `resolve_embeds`, and inlines the returned skill package. + + This is the playground path (parameters inline on `request.data.parameters`, no + `data.revision`) that the old middleware skipped. + """ + from agenta.sdk.middlewares.running.resolver import ResolverMiddleware + from agenta.sdk.models.workflows import ( + WorkflowInvokeRequest, + WorkflowRequestData, + ) + + params_with_embed = { + "skills": [ + { + "@ag.embed": { + "@ag.references": {"workflow_revision": {"slug": "my-skill"}}, + "@ag.selector": {"path": "parameters.skill"}, + } + } + ] + } + resolved_params = { + "skills": [ + { + "name": "my-skill", + "description": "A resolved skill.", + "body": "Do the thing.", + } + ] + } + + request = WorkflowInvokeRequest( + credentials="test-creds", + flags={"resolve": True}, + data=WorkflowRequestData(parameters=params_with_embed), + ) + + # The /workflows/revisions/resolve endpoint returns the inlined parameters under + # workflow_revision.data.parameters (the shape `resolve_embeds` unwraps). + endpoint_response = MagicMock() + endpoint_response.raise_for_status = MagicMock() + endpoint_response.json = MagicMock( + return_value={ + "workflow_revision": {"data": {"parameters": resolved_params}} + } + ) + + post_mock = AsyncMock(return_value=endpoint_response) + + class _FakeAsyncClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def post(self, *args, **kwargs): + return await post_mock(*args, **kwargs) + + fake_async_api = MagicMock() + fake_async_api._client_wrapper._base_url = "http://api.test" + + with ( + patch.object(ag, "async_api", fake_async_api), + patch( + "agenta.sdk.middlewares.running.resolver.httpx.AsyncClient", + return_value=_FakeAsyncClient(), + ), + patch( + "agenta.sdk.middlewares.running.resolver.resolve_handler", + new_callable=AsyncMock, + return_value=MagicMock(), + ), + tracing_context_manager(TracingContext()), + ): + mw = ResolverMiddleware() + call_next = AsyncMock(return_value="result") + await mw(request, call_next) + + # The real resolver hit the resolve endpoint once... + post_mock.assert_awaited_once() + url = post_mock.await_args.args[0] + assert url.endswith("/workflows/revisions/resolve") + # ...and the embed was inlined into a concrete skill package on the inline params. + assert request.data.parameters == resolved_params + assert request.data.parameters["skills"][0]["name"] == "my-skill" + @pytest.mark.asyncio async def test_skips_resolve_when_flag_is_false(self): """ diff --git a/services/agent/src/engines/pi.ts b/services/agent/src/engines/pi.ts index 1b279ce5c0..3b702c7fd9 100644 --- a/services/agent/src/engines/pi.ts +++ b/services/agent/src/engines/pi.ts @@ -43,6 +43,7 @@ import { resolveRunSessionId, resolvePromptText, } from "../protocol.ts"; +import { KNOWN_PROVIDER_ENV_VARS } from "./sandbox_agent/daemon.ts"; import { EMPTY_OBJECT_SCHEMA } from "../tools/callback.ts"; import { runResolvedTool } from "../tools/dispatch.ts"; import { resolveSkillDirs } from "./skills.ts"; @@ -71,14 +72,29 @@ function log(message: string): void { // exactly so one request's vault keys cannot leak into the next request. let providerEnvQueue: Promise = Promise.resolve(); -async function withRequestProviderEnv( +export async function withRequestProviderEnv( secrets: Record | undefined, fn: () => Promise, + // Clear-then-apply (Security rule 5 in the provider-model-auth design): on a MANAGED run + // (`credentialMode === "env"`) clear ALL `KNOWN_PROVIDER_ENV_VARS` first so an inherited key + // for another provider cannot leak in, then apply only `secrets`. For runtime_provided/none/ + // un-migrated runs the in-process Pi uses its own env/login, so we do NOT clear. + credentialMode?: string, ): Promise { + const clearProviderEnv = credentialMode === "env"; const run = providerEnvQueue.then(async () => { + // Snapshot every var we touch so the finally restores the prior process env exactly. The + // managed case snapshots the whole known set (it clears them); every case snapshots the + // keys it applies. A var that appears in both is snapshotted once (Map keys dedupe). const previous = new Map(); + if (clearProviderEnv) { + for (const key of KNOWN_PROVIDER_ENV_VARS) { + if (!previous.has(key)) previous.set(key, process.env[key]); + delete process.env[key]; + } + } for (const [key, value] of Object.entries(secrets ?? {})) { - previous.set(key, process.env[key]); + if (!previous.has(key)) previous.set(key, process.env[key]); if (value) process.env[key] = value; else delete process.env[key]; } @@ -102,7 +118,9 @@ async function withRequestProviderEnv( function pickModel(available: any[], wanted?: string): any { return ( (wanted && - available.find((m) => m.id === wanted || `${m.provider}/${m.id}` === wanted)) || + available.find( + (m) => m.id === wanted || `${m.provider}/${m.id}` === wanted, + )) || available.find((m) => m.id === "gpt-5.5") || available.find((m) => !/spark|mini/i.test(m.id)) || available[0] @@ -160,22 +178,36 @@ export function buildCustomTools( parameters: (spec.inputSchema as any) ?? EMPTY_OBJECT_SCHEMA, }; if (spec.kind === "client") { - log(`skipping client tool '${spec.name}' (browser-fulfilled; not available in-process)`); + log( + `skipping client tool '${spec.name}' (browser-fulfilled; not available in-process)`, + ); continue; } if (spec.kind === "code") { tools.push({ ...base, - async execute(toolCallId: string, params: unknown, signal?: AbortSignal) { - const text = await runResolvedTool(spec, params, { toolCallId, signal }); - return { content: [{ type: "text", text }], details: { kind: "code" } }; + async execute( + toolCallId: string, + params: unknown, + signal?: AbortSignal, + ) { + const text = await runResolvedTool(spec, params, { + toolCallId, + signal, + }); + return { + content: [{ type: "text", text }], + details: { kind: "code" }, + }; }, }); continue; } // callback (default): route back to Agenta's /tools/call. if (!callback?.endpoint) { - log(`skipping callback tool '${spec.name}': missing toolCallback endpoint`); + log( + `skipping callback tool '${spec.name}': missing toolCallback endpoint`, + ); continue; } tools.push({ @@ -201,7 +233,37 @@ export async function runPi( request: AgentRunRequest, emit?: EmitEvent, ): Promise { - return withRequestProviderEnv(request.secrets, () => runPiWithEnv(request, emit)); + return withRequestProviderEnv( + request.secrets, + () => runPiWithEnv(request, emit), + request.credentialMode, + ); +} + +/** + * The in-process Pi engine has no sandbox and runs tools directly (no relay), so it cannot honor + * the capability layers the sandbox-agent engine enforces. Rather than silently ignore a + * restrictive policy, fail loud and point at the enforcing backend. (Layer 1 Claude settings are + * not checked here: Claude always runs over sandbox-agent, never this engine.) + */ +export function unenforceableCapabilityConfig( + request: AgentRunRequest, +): string | undefined { + const net = request.sandboxPermission?.network?.mode; + if (net && net !== "on") { + return `the in-process 'pi' backend cannot enforce sandbox_permission.network='${net}' (it has no sandbox); use the 'sandbox-agent' backend.`; + } + const fs = request.sandboxPermission?.filesystem; + if (fs && fs !== "on") { + return `the in-process 'pi' backend cannot enforce sandbox_permission.filesystem='${fs}'; use the 'sandbox-agent' backend.`; + } + const gated = (request.customTools as { name?: string; permission?: string }[] | undefined) + ?.filter((t) => t?.permission === "deny" || t?.permission === "ask") + .map((t) => t?.name ?? "?"); + if (gated && gated.length > 0) { + return `the in-process 'pi' backend does not enforce tool permissions (deny/ask) for [${gated.join(", ")}]; use the 'sandbox-agent' backend.`; + } + return undefined; } async function runPiWithEnv( @@ -210,10 +272,21 @@ async function runPiWithEnv( ): Promise { const prompt = resolvePromptText(request); if (!prompt) { - return { ok: false, error: "No user message to send (prompt/messages empty)." }; + return { + ok: false, + error: "No user message to send (prompt/messages empty).", + }; + } + + const unenforceable = unenforceableCapabilityConfig(request); + if (unenforceable) { + return { ok: false, error: `Capability config rejected: ${unenforceable}` }; } const cwd = mkdtempSync(join(tmpdir(), "agenta-agent-")); + // Removes the per-run skills temp root; assigned once skills materialize and always run in + // the outer `finally`. No-op until then. + let skillsCleanup: () => void = () => {}; try { const authStorage = AuthStorage.create(); @@ -227,9 +300,23 @@ async function runPiWithEnv( }; } + // `request.model` is the resolved exact model (the Python wire sets it from the resolved + // connection when one exists). The fallback chain in pickModel stays: model-config owns the + // staged strict-fail rollout, this slice does not flip strict on. const model = pickModel(available, request.model); log(`model: ${model.provider}/${model.id}`); + // A custom OpenAI-compatible base_url for in-process Pi (registerProvider / models.json + // write into the agent dir) is OWNED by the model-config sibling project (Part 1) and not + // landed here. Log it so a configured-but-not-applied endpoint is visible rather than + // silently ignored. The Claude path applies ANTHROPIC_BASE_URL in the sandbox-agent engine. + if (request.endpoint?.baseUrl) { + log( + `endpoint.baseUrl '${request.endpoint.baseUrl}' is not applied in-process yet ` + + `(Pi custom-endpoint write is owned by the model-config project); ignoring for this run`, + ); + } + // Tracing: turn this run into OTel spans. When the caller passed a traceparent, // invoke_agent nests under their /invoke span so the whole agent run is part of the // same trace (just like completion/chat). @@ -249,22 +336,26 @@ async function runPiWithEnv( // request carries applies, never a SYSTEM.md / APPEND_SYSTEM.md left on disk. const systemPrompt = request.systemPrompt?.trim(); const appendSystemPrompt = request.appendSystemPrompt?.trim(); - // Forced skills (the Agenta harness): load exactly the bundled dirs the request names. + // Skills: materialize each resolved inline package into a fresh dir and load exactly those. // `noSkills` suppresses host/global discovery so the run is deterministic; the loader still - // merges `additionalSkillPaths` on top, so the bundled skills load. They only surface in - // the prompt when `read` is enabled (the harness forces it). - const skillDirs = resolveSkillDirs(request.skills, log); - if (skillDirs.length > 0) { - log(`skills: ${skillDirs.join(", ")}`); + // merges `additionalSkillPaths` on top, so the materialized skills load. They only surface + // in the prompt when `read` is enabled (the harness forces it). The temp root is removed in + // the outer `finally` (skillsCleanup) on both success and error. + const skillsResult = resolveSkillDirs(request.skills, log); + const skills = skillsResult.skills; + skillsCleanup = skillsResult.cleanup; + if (skills.length > 0) { + log(`skills: ${skills.map((s) => s.name).join(", ")}`); } const loader = new DefaultResourceLoader({ cwd, agentDir: getAgentDir(), noContextFiles: true, noSkills: true, - additionalSkillPaths: skillDirs, + additionalSkillPaths: skills.map((s) => s.dir), systemPromptOverride: () => systemPrompt || undefined, - appendSystemPromptOverride: () => (appendSystemPrompt ? [appendSystemPrompt] : []), + appendSystemPromptOverride: () => + appendSystemPrompt ? [appendSystemPrompt] : [], agentsFilesOverride: () => ({ agentsFiles: agentsMd ? [{ path: "/virtual/AGENTS.md", content: agentsMd }] @@ -276,7 +367,10 @@ async function runPiWithEnv( // Build runnable tools from the resolved specs. Pi's allowlist gates custom tools too, // so their names must be in `tools` for the model to see them. - const customTools = buildCustomTools(request.customTools ?? [], request.toolCallback); + const customTools = buildCustomTools( + request.customTools ?? [], + request.toolCallback, + ); const toolAllowlist = [ ...(request.tools ?? []), ...customTools.map((tool) => tool.name), @@ -287,7 +381,9 @@ async function runPiWithEnv( // Created before the prompt so a throw mid-run still flushes the partial trace and // disposes the session (the inner finally below). Mirrors the sandbox-agent engine's pattern. - let session: Awaited>["session"] | undefined; + let session: + | Awaited>["session"] + | undefined; try { ({ session } = await createAgentSession({ cwd, @@ -394,6 +490,7 @@ async function runPiWithEnv( session?.dispose(); } } finally { + skillsCleanup(); try { rmSync(cwd, { recursive: true, force: true }); } catch { diff --git a/services/agent/src/engines/sandbox_agent.ts b/services/agent/src/engines/sandbox_agent.ts index f56e82f8c2..fafc4cec19 100644 --- a/services/agent/src/engines/sandbox_agent.ts +++ b/services/agent/src/engines/sandbox_agent.ts @@ -34,7 +34,8 @@ import { startToolRelay, } from "../tools/relay.ts"; import { - PolicyResponder, + HITLResponder, + extractApprovalDecisions, policyFromRequest, type Responder, } from "../responder.ts"; @@ -46,10 +47,7 @@ import { resolveRunSessionId, } from "../protocol.ts"; import { probeCapabilities } from "./sandbox_agent/capabilities.ts"; -import { - buildDaemonEnv, - resolveDaemonBinary, -} from "./sandbox_agent/daemon.ts"; +import { buildDaemonEnv, resolveDaemonBinary } from "./sandbox_agent/daemon.ts"; import { createCookieFetch, prepareDaytonaPiAssets, @@ -71,7 +69,10 @@ import { priorMessages } from "./sandbox_agent/transcript.ts"; import { resolveRunUsage } from "./sandbox_agent/usage.ts"; import { prepareWorkspace } from "./sandbox_agent/workspace.ts"; -export { buildTurnText, messageTranscript } from "./sandbox_agent/transcript.ts"; +export { + buildTurnText, + messageTranscript, +} from "./sandbox_agent/transcript.ts"; export { toAcpMcpServers } from "./sandbox_agent/mcp.ts"; function log(message: string): void { @@ -80,6 +81,43 @@ function log(message: string): void { type Log = (message: string) => void; +const CLAUDE_STRICT_DEPLOYMENTS = new Set(["custom", "bedrock", "vertex", "vertex_ai"]); + +function applyClaudeConnectionEnv( + env: Record, + request: AgentRunRequest, + acpAgent: string, + logger: Log, +): boolean { + if (acpAgent !== "claude") return false; + + const deployment = request.deployment; + const selectedModel = request.model; + const baseUrl = request.endpoint?.baseUrl; + if (baseUrl) { + env.ANTHROPIC_BASE_URL = baseUrl; + logger(`claude base_url: ${baseUrl}`); + } + + if (deployment === "bedrock") { + env.CLAUDE_CODE_USE_BEDROCK = "1"; + const region = request.endpoint?.region; + if (region) { + env.AWS_REGION = region; + env.AWS_DEFAULT_REGION ??= region; + } + } else if (deployment === "vertex" || deployment === "vertex_ai") { + env.CLAUDE_CODE_USE_VERTEX = "1"; + } + + if (selectedModel && (baseUrl || (deployment && CLAUDE_STRICT_DEPLOYMENTS.has(deployment)))) { + env.ANTHROPIC_MODEL = selectedModel; + env.ANTHROPIC_CUSTOM_MODEL_OPTION = selectedModel; + return true; + } + return false; +} + export interface SandboxAgentDeps extends BuildRunPlanDeps { startSandboxAgent?: typeof SandboxAgent.start; createPersist?: () => InMemorySessionPersistDriver; @@ -115,8 +153,16 @@ export async function runSandboxAgent( if (!planResult.ok) return { ok: false, error: planResult.error }; const plan = planResult.plan; - const env = (deps.buildDaemonEnv ?? buildDaemonEnv)(plan.acpAgent); - Object.assign(env, plan.secrets); // local daemon inherits the provider keys + // Clear-then-apply (Security rule 5): on a managed run (credentialMode "env") the daemon + // inherits NONE of the sidecar's own provider keys, so only the resolved `plan.secrets` are + // present and an inherited key for another provider cannot leak. For runtime_provided/none/ + // un-migrated runs the harness uses its own login, so the inherited keys stay. + const clearProviderEnv = plan.credentialMode === "env"; + const env = (deps.buildDaemonEnv ?? buildDaemonEnv)(plan.acpAgent, { + clearProviderEnv, + }); + Object.assign(env, plan.secrets); // apply only the resolved provider keys + const strictModel = applyClaudeConnectionEnv(env, request, plan.acpAgent, logger); // Pi self-instruments locally: propagate the trace context + public tool metadata into Pi // via the Agenta extension. Tool execution always relays back to this runner, which keeps // private specs, scoped env, callback endpoints, and callback auth in memory. @@ -143,14 +189,18 @@ export async function runSandboxAgent( let toolRelay: { stop: () => Promise } | undefined; let workspace: { cleanup: () => Promise } | undefined = plan.isDaytona ? undefined - : { cleanup: async () => rmSync(plan.cwd, { recursive: true, force: true }) }; + : { + cleanup: async () => rmSync(plan.cwd, { recursive: true, force: true }), + }; try { // Persist events in-process so a follow-up turn can resume by session id. - const persist = deps.createPersist?.() ?? new InMemorySessionPersistDriver(); + const persist = + deps.createPersist?.() ?? new InMemorySessionPersistDriver(); const startSandboxAgent = deps.startSandboxAgent ?? - ((options: Parameters[0]) => SandboxAgent.start(options)); + ((options: Parameters[0]) => + SandboxAgent.start(options)); sandbox = await startSandboxAgent({ sandbox: (deps.buildSandboxProvider ?? buildSandboxProvider)( plan.sandboxId, @@ -158,6 +208,7 @@ export async function runSandboxAgent( binaryPath, piExtEnv, plan.secrets, + plan.sandboxPermission, ), persist, // Propagate caller cancellation (a client disconnect on the streaming HTTP edge) so an @@ -165,7 +216,9 @@ export async function runSandboxAgent( ...(signal ? { signal } : {}), // Daytona's preview proxy authenticates with a per-sandbox cookie; carry it across // requests so ACP calls after the first don't 401. Harmless for local. - ...(plan.isDaytona ? { fetch: (deps.createCookieFetch ?? createCookieFetch)() } : {}), + ...(plan.isDaytona + ? { fetch: (deps.createCookieFetch ?? createCookieFetch)() } + : {}), }); // On Daytona, push the harness login, the extension, and AGENTS.md into the remote @@ -174,13 +227,20 @@ export async function runSandboxAgent( if (plan.isDaytona) { await prepareDaytonaPiAssets({ sandbox, plan, log: logger }); } - workspace = await (deps.prepareWorkspace ?? prepareWorkspace)({ sandbox, plan, log: logger }); + workspace = await (deps.prepareWorkspace ?? prepareWorkspace)({ + sandbox, + plan, + log: logger, + }); // Probe what this harness supports and branch on capabilities, not on the harness // name. Tool delivery: Pi loads our extension (native tools, set up above); any other // harness takes tools over MCP only when it advertises `mcpTools` (pi-acp does not // forward MCP, Claude/Codex do). - const capabilities = await (deps.probeCapabilities ?? probeCapabilities)(sandbox, plan.acpAgent); + const capabilities = await (deps.probeCapabilities ?? probeCapabilities)( + sandbox, + plan.acpAgent, + ); const mcpServers = buildSessionMcpServers({ isPi: plan.isPi, capabilities, @@ -202,7 +262,12 @@ export async function runSandboxAgent( // Resolve the model first: when the harness rejects the requested id and keeps its // own default (e.g. Claude ignores "gpt-5.5"), `model` is undefined and the chat span // is labelled "chat" instead of falsely claiming the requested model. - const model = await (deps.applyModel ?? applyModel)(session, request.model, logger); + const model = await (deps.applyModel ?? applyModel)( + session, + request.model, + logger, + { strict: strictModel }, + ); const run = (deps.createOtel ?? createSandboxAgentOtel)({ harness: plan.harness, @@ -220,7 +285,10 @@ export async function runSandboxAgent( run.start({ prompt: plan.prompt, sessionId, - messages: [...priorMessages(request), { role: "user", content: plan.prompt }], + messages: [ + ...priorMessages(request), + { role: "user", content: plan.prompt }, + ], }); session.onEvent((event: any) => { @@ -229,15 +297,29 @@ export async function runSandboxAgent( if (update) run.handleUpdate(update); }); + // Cross-turn HITL: when the request carries a platform `sessionId` it came through the + // `/messages` endpoint, which validates and stamps a session id on every turn and replays + // the conversation — i.e. there is a browser that can answer a permission prompt. The + // headless `/invoke` path sets no session id. With no human surface and no stored + // decisions the HITLResponder falls back to the base policy and is byte-identical to the + // old PolicyResponder, so `/invoke` is unchanged. + const hasHumanSurface = !!(request.sessionId && request.sessionId.trim()); attachPermissionResponder({ session, run, responder: deps.responderFactory?.(request.permissionPolicy) ?? - new PolicyResponder(policyFromRequest(request.permissionPolicy)), + new HITLResponder( + extractApprovalDecisions(request), + policyFromRequest(request.permissionPolicy), + hasHumanSurface, + ), }); if (plan.useToolRelay) { + // Layer 3 (S3b): the relay enforces each resolved tool's `permission`; an `ask`/unset + // permission degrades to the run's headless permission policy (the same policy the + // PolicyResponder uses for Claude builtins above). toolRelay = (deps.startToolRelay ?? startToolRelay)( plan.isDaytona ? (deps.sandboxRelayHost ?? sandboxRelayHost)(sandbox) @@ -245,10 +327,13 @@ export async function runSandboxAgent( plan.relayDir, plan.toolSpecs, request.toolCallback as ToolCallbackContext | undefined, + policyFromRequest(request.permissionPolicy), ); } - const result = await session.prompt([{ type: "text", text: plan.turnText }]); + const result = await session.prompt([ + { type: "text", text: plan.turnText }, + ]); await toolRelay?.stop(); const stopReason = (result as any)?.stopReason; logger(`prompt stopReason=${stopReason}`); @@ -280,7 +365,10 @@ export async function runSandboxAgent( stopReason, // `streamingDeltas` advertises end-to-end live deltas, which is only true when a live // sink is wired. The one-shot path reports false even when the harness produces deltas. - capabilities: { ...capabilities, streamingDeltas: !!emit && capabilities.streamingDeltas }, + capabilities: { + ...capabilities, + streamingDeltas: !!emit && capabilities.streamingDeltas, + }, sessionId, model: model ?? request.model, traceId: run.traceId(), @@ -296,5 +384,7 @@ export async function runSandboxAgent( await workspace?.cleanup().catch(() => {}); // The per-run Agenta agent dir (skills isolation) is throwaway; remove it too. if (runAgentDir) rmSync(runAgentDir, { recursive: true, force: true }); + // Remove the per-run skills temp root the materializer created (success or error). + plan.skillsCleanup(); } } diff --git a/services/agent/src/engines/sandbox_agent/pi-assets.ts b/services/agent/src/engines/sandbox_agent/pi-assets.ts index ab10e77d7e..8c6b975040 100644 --- a/services/agent/src/engines/sandbox_agent/pi-assets.ts +++ b/services/agent/src/engines/sandbox_agent/pi-assets.ts @@ -10,10 +10,11 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { basename, dirname, join } from "node:path"; +import { dirname, join } from "node:path"; import type { AgentRunRequest, ResolvedToolSpec } from "../../protocol.ts"; import { publicToolSpecs } from "../../tools/public-spec.ts"; +import type { MaterializedSkill } from "../skills.ts"; import { PKG_ROOT } from "./daemon.ts"; import type { RunPlan } from "./run-plan.ts"; @@ -22,7 +23,8 @@ type Log = (message: string) => void; // The bundled Agenta Pi extension (tracing + tools). Built by `pnpm run build:extension` // and baked into the image; installed into Pi's agent dir so Pi loads it on every run. export const EXTENSION_BUNDLE = - process.env.SANDBOX_AGENT_EXTENSION_BUNDLE ?? join(PKG_ROOT, "dist", "extensions", "agenta.js"); + process.env.SANDBOX_AGENT_EXTENSION_BUNDLE ?? + join(PKG_ROOT, "dist", "extensions", "agenta.js"); /** * Env the Agenta Pi extension reads. Tool env contains only public metadata plus the @@ -38,9 +40,12 @@ export function buildPiExtensionEnv( if (trace?.traceparent) env.AGENTA_TRACEPARENT = trace.traceparent; if (trace?.endpoint) env.AGENTA_OTLP_ENDPOINT = trace.endpoint; if (trace?.authorization) env.AGENTA_OTLP_AUTHORIZATION = trace.authorization; - if (trace && trace.captureContent === false) env.AGENTA_CAPTURE_CONTENT = "false"; + if (trace && trace.captureContent === false) + env.AGENTA_CAPTURE_CONTENT = "false"; - const specs = publicToolSpecs((request.customTools as ResolvedToolSpec[]) ?? []); + const specs = publicToolSpecs( + (request.customTools as ResolvedToolSpec[]) ?? [], + ); if (specs.length && opts.relayDir) { env.AGENTA_TOOL_PUBLIC_SPECS = JSON.stringify(specs); env.AGENTA_TOOL_RELAY_DIR = opts.relayDir; @@ -50,9 +55,14 @@ export function buildPiExtensionEnv( } /** Install the extension bundle into a local Pi agent dir's extensions/. Best-effort. */ -export function installPiExtensionLocal(agentDir: string, log: Log = () => {}): void { +export function installPiExtensionLocal( + agentDir: string, + log: Log = () => {}, +): void { if (!existsSync(EXTENSION_BUNDLE)) { - log(`pi extension bundle missing at ${EXTENSION_BUNDLE} (run build:extension)`); + log( + `pi extension bundle missing at ${EXTENSION_BUNDLE} (run build:extension)`, + ); return; } try { @@ -76,9 +86,14 @@ export function writeSystemPromptLocal( ): void { try { mkdirSync(agentDir, { recursive: true }); - if (systemPrompt) writeFileSync(join(agentDir, "SYSTEM.md"), systemPrompt, "utf-8"); + if (systemPrompt) + writeFileSync(join(agentDir, "SYSTEM.md"), systemPrompt, "utf-8"); if (appendSystemPrompt) { - writeFileSync(join(agentDir, "APPEND_SYSTEM.md"), appendSystemPrompt, "utf-8"); + writeFileSync( + join(agentDir, "APPEND_SYSTEM.md"), + appendSystemPrompt, + "utf-8", + ); } } catch (err) { log(`system prompt write skipped: ${(err as Error).message}`); @@ -96,10 +111,16 @@ export async function uploadSystemPromptToSandbox( try { await sandbox.mkdirFs({ path: agentDir }); if (systemPrompt) { - await sandbox.writeFsFile({ path: `${agentDir}/SYSTEM.md` }, systemPrompt); + await sandbox.writeFsFile( + { path: `${agentDir}/SYSTEM.md` }, + systemPrompt, + ); } if (appendSystemPrompt) { - await sandbox.writeFsFile({ path: `${agentDir}/APPEND_SYSTEM.md` }, appendSystemPrompt); + await sandbox.writeFsFile( + { path: `${agentDir}/APPEND_SYSTEM.md` }, + appendSystemPrompt, + ); } } catch (err) { log(`system prompt upload skipped: ${(err as Error).message}`); @@ -116,32 +137,39 @@ export async function uploadPiExtensionToSandbox( try { const dir = `${agentDir}/extensions`; await sandbox.mkdirFs({ path: dir }); - await sandbox.writeFsFile({ path: `${dir}/agenta.js` }, readFileSync(EXTENSION_BUNDLE, "utf-8")); + await sandbox.writeFsFile( + { path: `${dir}/agenta.js` }, + readFileSync(EXTENSION_BUNDLE, "utf-8"), + ); } catch (err) { log(`pi extension upload skipped: ${(err as Error).message}`); } } -/** Install forced skill dirs into a local Pi agent dir's user-scope `skills/`. */ -export function installSkillsLocal(agentDir: string, skillDirs: string[], log: Log = () => {}): void { - for (const src of skillDirs) { +/** Install materialized skill dirs into a local Pi agent dir's user-scope `skills/`. */ +export function installSkillsLocal( + agentDir: string, + skillDirs: MaterializedSkill[], + log: Log = () => {}, +): void { + for (const skill of skillDirs) { try { - const dest = join(agentDir, "skills", basename(src)); + const dest = join(agentDir, "skills", skill.name); mkdirSync(dirname(dest), { recursive: true }); - cpSync(src, dest, { recursive: true, dereference: true }); + cpSync(skill.dir, dest, { recursive: true, dereference: true }); } catch (err) { - log(`skill install skipped for ${basename(src)}: ${(err as Error).message}`); + log(`skill install skipped for ${skill.name}: ${(err as Error).message}`); } } } /** * Seed a throwaway local Pi agent dir from `sourceAgentDir` and install the Agenta extension - * plus forced skills into it. + * plus the run's materialized skills into it. */ export function prepareLocalAgentDir( sourceAgentDir: string, - skillDirs: string[], + skillDirs: MaterializedSkill[], log: Log = () => {}, ): string { const dir = mkdtempSync(join(tmpdir(), "agenta-pi-agentdir-")); @@ -186,9 +214,18 @@ export function prepareLocalPiAssets({ if (!plan.isPi || plan.isDaytona) return undefined; if (plan.skillDirs.length > 0 || plan.hasSystemPrompt) { - const runAgentDir = prepareLocalAgentDir(plan.sourcePiAgentDir, plan.skillDirs, log); + const runAgentDir = prepareLocalAgentDir( + plan.sourcePiAgentDir, + plan.skillDirs, + log, + ); if (plan.hasSystemPrompt) { - writeSystemPromptLocal(runAgentDir, plan.systemPrompt, plan.appendSystemPrompt, log); + writeSystemPromptLocal( + runAgentDir, + plan.systemPrompt, + plan.appendSystemPrompt, + log, + ); } env.PI_CODING_AGENT_DIR = runAgentDir; return runAgentDir; @@ -200,18 +237,22 @@ export function prepareLocalPiAssets({ return undefined; } -/** Upload forced skill dirs into a Daytona sandbox's Pi `skills/` user scope. */ +/** Upload materialized skill dirs into a Daytona sandbox's Pi `skills/` user scope. */ export async function uploadSkillsToSandbox( sandbox: any, agentDir: string, - skillDirs: string[], + skillDirs: MaterializedSkill[], log: Log = () => {}, ): Promise { - for (const src of skillDirs) { + for (const skill of skillDirs) { try { - await uploadDirToSandbox(sandbox, src, `${agentDir}/skills/${basename(src)}`); + await uploadDirToSandbox( + sandbox, + skill.dir, + `${agentDir}/skills/${skill.name}`, + ); } catch (err) { - log(`skill upload skipped for ${basename(src)}: ${(err as Error).message}`); + log(`skill upload skipped for ${skill.name}: ${(err as Error).message}`); } } } @@ -240,7 +281,10 @@ export async function uploadDirToSandbox( if (isDir) { await uploadDirToSandbox(sandbox, srcPath, destPath); } else if (isFile) { - await sandbox.writeFsFile({ path: destPath }, readFileSync(srcPath, "utf-8")); + await sandbox.writeFsFile( + { path: destPath }, + readFileSync(srcPath, "utf-8"), + ); } } } diff --git a/services/agent/src/engines/sandbox_agent/run-plan.ts b/services/agent/src/engines/sandbox_agent/run-plan.ts index 2dfc076849..b89665bc3f 100644 --- a/services/agent/src/engines/sandbox_agent/run-plan.ts +++ b/services/agent/src/engines/sandbox_agent/run-plan.ts @@ -1,15 +1,20 @@ import { randomBytes } from "node:crypto"; import { mkdtempSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; -import { basename, join } from "node:path"; +import { join } from "node:path"; import { type AgentRunRequest, + type McpServerConfig, type ResolvedToolSpec, + type SandboxPermission, resolvePromptText, } from "../../protocol.ts"; import { executableToolSpecs } from "../../tools/public-spec.ts"; -import { resolveSkillDirs as defaultResolveSkillDirs } from "../skills.ts"; +import { + type MaterializedSkill, + resolveSkillDirs as defaultResolveSkillDirs, +} from "../skills.ts"; import { buildTurnText } from "./transcript.ts"; type Log = (message: string) => void; @@ -24,8 +29,21 @@ export interface RunPlan { turnText: string; agentsMd?: string; secrets: Record; - harnessKeyVar: string; + /** + * Back-compat inputs to the OAuth-upload decision (see `shouldUploadOwnLogin`). `legacyHarnessApiKeyVar` + * does not choose the provider; it only feeds the fallback `hasApiKey` heuristic for an un-migrated caller that sends no + * `credentialMode`. + */ + legacyHarnessApiKeyVar: string; hasApiKey: boolean; + /** + * How the credential is delivered: "env" (managed, resolved key) | "runtime_provided" (the + * harness owns its login) | "none". From the resolved connection (provider-model-auth design, + * Concern 3). `undefined` when an un-migrated caller sends no credentialMode; the run then + * falls back to the `hasApiKey` heuristic. Drives clear-then-apply env (Security rule 5) and + * the OAuth-upload gate (rule 6). + */ + credentialMode?: string; cwd: string; relayDir: string; usageOutPath?: string; @@ -35,8 +53,24 @@ export interface RunPlan { systemPrompt?: string; appendSystemPrompt?: string; hasSystemPrompt: boolean; - skillDirs: string[]; + skillDirs: MaterializedSkill[]; + /** Removes the per-run skills temp root. The engine runs it in its `finally` so it never leaks. */ + skillsCleanup: () => void; sourcePiAgentDir: string; + /** + * The declared sandbox security boundary (Layer 2). `buildSandboxProvider` enforces the + * network policy on Daytona (S1b); `buildRunPlan` rejects restricted-network runs the + * provider cannot make a hard guarantee for (local sidecar, or runner-host tools / stdio + * MCP) when `enforcement === "strict"`. + */ + sandboxPermission?: SandboxPermission; + /** + * Generic harness-rendered files to materialize in the cwd before the session starts. Each + * `{ path (relative to cwd), content }` was produced by the Python harness adapter (e.g. the + * claude adapter renders `.claude/settings.json` from its permissions slice). `prepareWorkspace` + * writes each entry blind — no harness knowledge on the runner. + */ + harnessFiles?: Array<{ path: string; content: string }>; } export type BuildRunPlanResult = @@ -51,6 +85,18 @@ export interface BuildRunPlanDeps { log?: Log; } +/** + * True when an MCP server runs as a host command (stdio) rather than a remote URL. Mirrors + * the delivery rule in `mcp.ts` (`toAcpMcpServers`): the default transport is `stdio`, and a + * stdio server only runs when it carries a `command`. Such a server is an arbitrary process + * on the RUNNER HOST, so a network-blocked sandbox does not confine it. + */ +function hasStdioMcpServer(servers: McpServerConfig[] | undefined): boolean { + return (servers ?? []).some( + (s) => (s.transport ?? "stdio") === "stdio" && !!s.command, + ); +} + function defaultLocalCwd(): string { return mkdtempSync(join(tmpdir(), "agenta-sandbox-agent-")); } @@ -79,23 +125,76 @@ export function buildRunPlan( const prompt = resolvePromptText(request); if (!prompt) { - return { ok: false, error: "No user message to send (prompt/messages empty)." }; + return { + ok: false, + error: "No user message to send (prompt/messages empty).", + }; } const isPi = acpAgent === "pi"; const isDaytona = sandboxId === "daytona"; - const cwd = isDaytona ? createDaytonaCwd() : createLocalCwd(); - const relayDir = `${cwd}/.agenta-tools`; const secrets = request.secrets ?? {}; - const harnessKeyVar = acpAgent === "claude" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY"; + const legacyHarnessApiKeyVar = + acpAgent === "claude" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY"; const toolSpecs = (request.customTools as ResolvedToolSpec[]) ?? []; const executableToolSpecsForRun = executableToolSpecs(toolSpecs); - const skillDirs = isPi ? resolveSkillDirs(request.skills, log) : []; - if (skillDirs.length > 0) log(`skills: ${skillDirs.map((d) => basename(d)).join(", ")}`); - const systemPrompt = isPi ? request.systemPrompt?.trim() || undefined : undefined; - const appendSystemPrompt = isPi ? request.appendSystemPrompt?.trim() || undefined : undefined; + // Layer 2 (S1b/S1g): enforce the declared network boundary, and fail loud where it cannot + // be a hard guarantee. Only `strict` blocks; `best_effort` is the per-axis opt-out that + // accepts the boundary may not hold. `mode: "on"` (or no policy) imposes no restriction. + // Checked before any cwd is created so a rejected run does not orphan a temp dir. + const network = request.sandboxPermission?.network; + const networkRestricted = !!network && (network.mode ?? "on") !== "on"; + const strict = request.sandboxPermission?.enforcement === "strict"; + if (networkRestricted && strict) { + const mode = network?.mode ?? "on"; + // Most specific first: the local sidecar has no egress control at all, so any restricted + // network is unenforceable; Daytona applies it via networkBlockAll/networkAllowList. + if (!isDaytona) { + return { + ok: false, + error: + `local sandbox cannot enforce network:${mode} (the local sidecar runs on this ` + + `host with no egress control); set enforcement=best_effort to run locally without ` + + `the guarantee, or run on daytona.`, + }; + } + // Even on Daytona, code/gateway tools and stdio MCP run on the RUNNER HOST via the relay, + // not inside the sandbox, so they bypass the sandbox network boundary. + if ( + executableToolSpecsForRun.length > 0 || + hasStdioMcpServer(request.mcpServers) + ) { + return { + ok: false, + error: + `code/gateway tools and stdio MCP servers run on the runner host and would bypass ` + + `the sandbox network boundary; remove them, or set enforcement=best_effort to accept ` + + `that network:${mode} is not a hard guarantee.`, + }; + } + } + + const cwd = isDaytona ? createDaytonaCwd() : createLocalCwd(); + const relayDir = `${cwd}/.agenta-tools`; + + // Skills materialize once from the resolved inline packages. Pi/Agenta consume the dirs + // through Pi's agent-dir user scope; Claude consumes the same packages from the project-local + // `.claude/skills` tree that `prepareWorkspace` writes below. + const { skills: skillDirs, cleanup: skillsCleanup } = resolveSkillDirs( + request.skills, + log, + ); + if (skillDirs.length > 0) + log(`skills: ${skillDirs.map((s) => s.name).join(", ")}`); + + const systemPrompt = isPi + ? request.systemPrompt?.trim() || undefined + : undefined; + const appendSystemPrompt = isPi + ? request.appendSystemPrompt?.trim() || undefined + : undefined; return { ok: true, @@ -109,8 +208,9 @@ export function buildRunPlan( turnText: buildTurnText(request), agentsMd: request.agentsMd?.trim() || undefined, secrets, - harnessKeyVar, - hasApiKey: !!secrets[harnessKeyVar], + legacyHarnessApiKeyVar, + hasApiKey: !!secrets[legacyHarnessApiKeyVar], + credentialMode: request.credentialMode, cwd, relayDir, usageOutPath: isPi ? `${cwd}/.agenta-usage.json` : undefined, @@ -121,8 +221,33 @@ export function buildRunPlan( appendSystemPrompt, hasSystemPrompt: !!(systemPrompt || appendSystemPrompt), skillDirs, + skillsCleanup, sourcePiAgentDir: process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"), + sandboxPermission: request.sandboxPermission, + // Generic: the Python harness adapter already rendered any harness config files; the runner + // just carries them onto the plan and writes them into the cwd in `prepareWorkspace`. + harnessFiles: request.harnessFiles, }, }; } + +/** + * Whether to upload Pi's fallback `auth.json` (the harness's own OAuth login) into the run. + * + * The provider-model-auth design (Security rule 6) gates this on the harness owning its login, + * NOT on a provider guessed from the harness name: + * - `credentialMode === "env"` (a resolved key): NEVER upload the fallback (the resolved key is + * the credential). + * - `credentialMode === "runtime_provided"`: upload (the harness authenticates with its login). + * - `credentialMode === "none"`: do not upload (no credential asserted). + * - no `credentialMode` on the wire (un-migrated caller): fall back to today's heuristic — + * upload only when no api key was supplied (`!hasApiKey`). + */ +export function shouldUploadOwnLogin( + plan: Pick, +): boolean { + if (plan.credentialMode === "runtime_provided") return true; + if (plan.credentialMode) return false; // "env" / "none": a resolved decision, never upload + return !plan.hasApiKey; // back-compat: un-migrated caller, no credentialMode +} diff --git a/services/agent/src/engines/sandbox_agent/workspace.ts b/services/agent/src/engines/sandbox_agent/workspace.ts index 6a4560b988..b5224bcf30 100644 --- a/services/agent/src/engines/sandbox_agent/workspace.ts +++ b/services/agent/src/engines/sandbox_agent/workspace.ts @@ -1,7 +1,8 @@ -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { cpSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; import type { RunPlan } from "./run-plan.ts"; +import { uploadDirToSandbox } from "./pi-assets.ts"; type Log = (message: string) => void; @@ -11,16 +12,36 @@ export interface Workspace { export interface PrepareWorkspaceInput { sandbox: any; - plan: Pick; + plan: Pick< + RunPlan, + | "isDaytona" + | "isPi" + | "cwd" + | "relayDir" + | "useToolRelay" + | "agentsMd" + | "acpAgent" + | "harnessFiles" + | "skillDirs" + >; log?: Log; } -/** Prepare the run cwd, relay directory, and optional AGENTS.md for local or Daytona runs. */ +/** + * Prepare the run cwd, relay directory, optional AGENTS.md, generic `harnessFiles`, and + * non-Pi skill packages for local or Daytona runs. `harnessFiles` are written blind: the + * Python harness adapter already rendered them. Skills stay resolved inline packages on the + * wire; Pi installs them through its agent dir, while Claude loads project-local + * `.claude/skills/` directories from the cwd. + */ export async function prepareWorkspace({ sandbox, plan, log = () => {}, }: PrepareWorkspaceInput): Promise { + const harnessFiles = plan.harnessFiles ?? []; + const projectSkillRoot = plan.isPi ? undefined : `.${plan.acpAgent}/skills`; + if (plan.isDaytona) { await sandbox.mkdirFs({ path: plan.cwd }).catch((err: Error) => { log(`workspace mkdir skipped: ${err.message}`); @@ -33,11 +54,42 @@ export async function prepareWorkspace({ if (plan.agentsMd) { await sandbox.writeFsFile({ path: `${plan.cwd}/AGENTS.md` }, plan.agentsMd); } + for (const file of harnessFiles) { + const path = `${plan.cwd}/${file.path}`; + const parent = dirname(path); + await sandbox.mkdirFs({ path: parent }).catch((err: Error) => { + log(`harness file dir mkdir skipped: ${err.message}`); + }); + await sandbox.writeFsFile({ path }, file.content); + } + if (projectSkillRoot) { + for (const skill of plan.skillDirs) { + await uploadDirToSandbox( + sandbox, + skill.dir, + `${plan.cwd}/${projectSkillRoot}/${skill.name}`, + ).catch((err: Error) => { + log(`skill workspace upload skipped for ${skill.name}: ${err.message}`); + }); + } + } return { cleanup: async () => {} }; } if (plan.useToolRelay) mkdirSync(plan.relayDir, { recursive: true }); if (plan.agentsMd) writeFileSync(join(plan.cwd, "AGENTS.md"), plan.agentsMd, "utf-8"); + for (const file of harnessFiles) { + const path = join(plan.cwd, file.path); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, file.content, "utf-8"); + } + if (projectSkillRoot) { + for (const skill of plan.skillDirs) { + const dest = join(plan.cwd, projectSkillRoot, skill.name); + mkdirSync(dirname(dest), { recursive: true }); + cpSync(skill.dir, dest, { recursive: true, dereference: true }); + } + } return { cleanup: async () => { diff --git a/services/agent/src/engines/skills.ts b/services/agent/src/engines/skills.ts index 2efd28cc17..77db436713 100644 --- a/services/agent/src/engines/skills.ts +++ b/services/agent/src/engines/skills.ts @@ -1,50 +1,181 @@ /** - * Bundled-skill resolution, shared by both engines. + * Skill materialization, shared by both engines. * - * The Agenta harness ships a fixed set of skills (see the SDK's `agenta_builtins`). They - * cannot ride the `/run` wire as text because each skill is a directory that may reference - * relative scripts and assets, so the wire carries only the skill *names* and each engine - * resolves them here against the runner's bundled `skills/` root: + * A skill rides the `/run` wire as a resolved inline package (`WireSkill`): the SKILL.md + * frontmatter fields (`name`/`description`), a Markdown `body`, and optional bundled `files`. + * References to skills that live elsewhere were inlined server-side (via `@ag.embed`) before + * the request reached us, so there is exactly one shape here and no name-against-a-bundled-root + * resolution. For each skill we write a fresh directory under a per-run temp root, compose its + * `SKILL.md`, and lay each bundled file at its (re-validated) relative path. The resulting + * `{ name, dir }` pairs flow through the existing install paths: * - * - the in-process Pi engine (`engines/pi.ts`) feeds the resolved dirs to Pi's resource - * loader as `additionalSkillPaths`; - * - the sandbox-agent engine (`engines/sandbox_agent.ts`) lays the resolved dirs into the Pi agent dir's + * - the in-process Pi engine (`engines/pi.ts`) feeds the dirs to Pi's resource loader as + * `additionalSkillPaths`; + * - the sandbox-agent engine (`engines/sandbox_agent.ts`) lays the dirs into the Pi agent dir's * `skills/` (user scope), where Pi auto-discovers them on every run. + * + * Executable bundled files default to OFF. A file is `chmod +x`-ed only when the skill sets + * `allowExecutableFiles`, the file sets `executable`, AND the policy passed in allows it. The + * caller owns the policy decision (sandbox/harness), so this helper defaults to deny. + */ +import { + chmodSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve, sep } from "node:path"; + +import type { WireSkill } from "../protocol.ts"; + +/** A materialized skill: the on-disk directory the install paths consume. */ +export interface MaterializedSkill { + name: string; + dir: string; +} + +/** + * The output of materialization: the `{ name, dir }` pairs plus a `cleanup()` that removes the + * per-run temp root they live under. An engine calls `cleanup()` in its `finally` (success or + * error) so the temp root never leaks. `cleanup()` is a no-op when no skills materialized. */ -import { existsSync, statSync } from "node:fs"; -import { dirname, isAbsolute, join } from "node:path"; -import { fileURLToPath } from "node:url"; +export interface MaterializedSkills { + skills: MaterializedSkill[]; + cleanup: () => void; +} -// services/agent/src/engines/skills.ts -> services/agent. Bundled skills (the Agenta -// harness's forced skills) live under services/agent/skills//. Overridable for -// non-default layouts (e.g. a relocated sidecar). -const PKG_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); -export const SKILLS_ROOT = process.env.AGENTA_AGENT_SKILLS_DIR || join(PKG_ROOT, "skills"); +export type SkillExecPolicy = "allow" | "deny"; + +// The wire is an untrusted boundary (a non-SDK client can POST anything), so the runner +// re-validates `skill.name` against the same safe pattern the SDK enforces before joining it to +// a filesystem path. Without this a name like `../x` or `/etc` would escape the per-run root. +const SKILL_NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/; +const SKILL_NAME_MAX = 64; + +function isSafeSkillName(name: unknown): name is string { + return ( + typeof name === "string" && + name.length <= SKILL_NAME_MAX && + SKILL_NAME_RE.test(name) + ); +} /** - * Resolve the requested skill names to bundled skill directories under SKILLS_ROOT. Each name - * must be a committed dir holding a SKILL.md (Pi loads it and surfaces it in the system - * prompt). Absolute paths are honored as-is; unknown or non-directory entries are skipped with - * a warning so a stale name never fails the run. `log` defaults to a no-op so callers without a - * logger stay quiet. + * A bundled-file path is safe when it stays under the skill dir (no absolute, no `..` escape) and + * does not resolve to the skill's own `SKILL.md` at the dir root, which would clobber the + * frontmatter the runner just composed. The `SKILL.md` check is case-insensitive. + */ +function safeSkillFilePath(skillDir: string, relPath: unknown): string | null { + if ( + typeof relPath !== "string" || + !relPath || + relPath.startsWith("/") || + relPath.startsWith("\\") + ) + return null; + const target = resolve(skillDir, relPath); + const rel = relative(skillDir, target); + if (rel === "" || rel.startsWith("..") || rel.startsWith(`..${sep}`)) + return null; + // A bundled file that lands on the composed SKILL.md at the dir root would overwrite it. + if (rel.toLowerCase() === "skill.md") return null; + return target; +} + +/** YAML-quote a scalar so author text (`:` `#` `"` newlines, ...) cannot break the frontmatter. */ +function yamlScalar(value: string): string { + // JSON string syntax is a valid YAML double-quoted flow scalar, so JSON-encoding both escapes + // the special characters and wraps the value in quotes in one step. + return JSON.stringify(value); +} + +/** Compose the SKILL.md text: YAML frontmatter built from name/description, then the body. */ +function composeSkillMd(skill: WireSkill): string { + const description = skill.description.replace(/\n/g, " ").trim(); + const frontmatter = [ + "---", + `name: ${yamlScalar(skill.name)}`, + `description: ${yamlScalar(description)}`, + ...(skill.disableModelInvocation ? ["disable-model-invocation: true"] : []), + "---", + ].join("\n"); + return `${frontmatter}\n\n${skill.body}\n`; +} + +/** + * Materialize each resolved inline skill into a fresh directory under a per-run temp root and + * return the `{ name, dir }` pairs plus a `cleanup()` that removes that root (the caller runs it + * in a `finally` so the root never leaks). `execPolicy` gates whether an executable bundled file + * is actually `chmod +x`-ed; it defaults to `"deny"` so a caller must opt in. `log` defaults to a + * no-op so callers without a logger stay quiet. + * + * A skill whose wire-supplied `name` is not a safe slug is rejected (the wire is untrusted), and + * a file that cannot be written safely is skipped with a warning rather than failing the run. */ export function resolveSkillDirs( - names: string[] | undefined, + skills: WireSkill[] | undefined, log: (message: string) => void = () => {}, -): string[] { - const dirs: string[] = []; - for (const name of names ?? []) { - if (!name) continue; - const path = isAbsolute(name) ? name : join(SKILLS_ROOT, name); + execPolicy: SkillExecPolicy = "deny", +): MaterializedSkills { + if (!skills || skills.length === 0) return { skills: [], cleanup: () => {} }; + + const root = mkdtempSync(join(tmpdir(), "agenta-skills-")); + const cleanup = () => { try { - if (existsSync(path) && statSync(path).isDirectory()) { - dirs.push(path); - } else { - log(`skipping unknown skill "${name}" (no directory at ${path})`); - } + rmSync(root, { recursive: true, force: true }); } catch { - log(`skipping skill "${name}": cannot stat ${path}`); + // best-effort cleanup of the throwaway per-run skills root + } + }; + const out: MaterializedSkill[] = []; + const seenNames = new Set(); + + for (const skill of skills) { + if (!isSafeSkillName(skill?.name)) { + log(`skipping skill with unsafe name ${JSON.stringify(skill?.name)}`); + continue; + } + // `dir` is keyed only by `skill.name`; a duplicate would overwrite the earlier skill's + // SKILL.md while leaving its bundled files behind, so skip the later entry. + if (seenNames.has(skill.name)) { + log(`skipping duplicate skill "${skill.name}"`); + continue; + } + seenNames.add(skill.name); + try { + const dir = join(root, skill.name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "SKILL.md"), composeSkillMd(skill)); + + for (const file of skill.files ?? []) { + const target = safeSkillFilePath(dir, file?.path); + if (!target) { + log( + `skipping unsafe skill file ${JSON.stringify(file?.path)} in skill "${skill.name}"`, + ); + continue; + } + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, file.content ?? ""); + const allowExec = + skill.allowExecutableFiles === true && execPolicy === "allow"; + if (file.executable && allowExec) { + chmodSync(target, 0o755); + } else if (file.executable) { + log( + `skill "${skill.name}" file "${file.path}" not made executable ` + + `(allowExecutableFiles=${!!skill.allowExecutableFiles}, policy=${execPolicy})`, + ); + } + } + + out.push({ name: skill.name, dir }); + } catch (err) { + log(`skipping skill "${skill.name}": ${(err as Error).message}`); } } - return dirs; + + return { skills: out, cleanup }; } diff --git a/services/agent/src/protocol.ts b/services/agent/src/protocol.ts index 859ae07147..de4891d334 100644 --- a/services/agent/src/protocol.ts +++ b/services/agent/src/protocol.ts @@ -73,6 +73,15 @@ export interface ResolvedToolSpec { env?: Record; needsApproval?: boolean; render?: RenderHint; + /** MCP behavioral hint: true (read-only), false (mutating), absent (unknown). */ + readOnly?: boolean; + /** + * Layer-3 permission: `allow` runs with no prompt, `ask` raises a + * human-in-the-loop request, `deny` never runs. Absent = fall back to the global + * `permissionPolicy`. The SDK derives a default from `readOnly`/`needsApproval` when the + * author set none. Plumbing only here; enforcement is a later slice. + */ + permission?: "allow" | "ask" | "deny"; } /** Where and how to route a tool call back through Agenta. */ @@ -81,6 +90,36 @@ export interface ToolCallbackContext { authorization?: string; } +/** + * One bundled file laid beside SKILL.md by relative `path`. `content` is inline UTF-8 text; + * `executable` requests a `chmod +x` that the runner honors only when the skill's + * `allowExecutableFiles` is set AND the sandbox/harness policy allows execution (default deny). + * `content` is untrusted author code. + */ +export interface WireSkillFile { + path: string; + content: string; + executable?: boolean; +} + +/** + * A resolved inline skill package. By the time a skill reaches the runner every reference has + * been inlined server-side (via `@ag.embed`), so there is one shape: the SKILL.md frontmatter + * fields (`name`/`description`), the Markdown `body`, and optional bundled `files`. The runner + * materializes this into a skill dir at run time (see `engines/skills.ts`). There is no + * name-against-a-bundled-root resolution anymore. + */ +export interface WireSkill { + name: string; + description: string; + body: string; + files?: WireSkillFile[]; + /** Pi/Claude: hide from the prompt, invoke only via `/skill:name`. */ + disableModelInvocation?: boolean; + /** Gate the `chmod +x` of executable bundled files (default deny; policy must also allow). */ + allowExecutableFiles?: boolean; +} + /** * A user-declared MCP server attached to the run. `stdio` launches `command`/`args` with * `env` (secret env already resolved server-side); `tools` is an optional allowlist (empty = @@ -94,6 +133,31 @@ export interface McpServerConfig { env?: Record; url?: string; tools?: string[]; + /** + * Layer-3 permission for the whole server: `allow` / `ask` / `deny`. Absent = + * fall back to the global `permissionPolicy`. An MCP server has no `readOnly` hint, so there + * is no derived default: an explicit author value or nothing. Plumbing only; enforcement is + * a later slice. + */ + permission?: "allow" | "ask" | "deny"; +} + +/** + * The sandbox security boundary an agent runs inside (Layer 2). `network` is the outbound + * egress policy (`on` = allow all, `off` = block all, `allowlist` = only `network.allowlist` + * CIDR ranges); `filesystem` is declared but not enforced yet; `enforcement` is `strict` + * (fail when the boundary cannot be applied) or `best_effort`. Plumbing only today: the runner + * carries it onto the run plan but does NOT yet apply it on the sandbox provider. + */ +export interface SandboxPermission { + network?: { + mode?: "on" | "off" | "allowlist"; + /** CIDR ranges; honored when `mode === "allowlist"`. */ + allowlist?: string[]; + }; + /** Declared, NOT enforced today. */ + filesystem?: "on" | "readonly" | "off"; + enforcement?: "strict" | "best_effort"; } /** @@ -144,7 +208,13 @@ export type AgentEvent = | { type: "reasoning_start"; id: string } | { type: "reasoning_delta"; id: string; delta: string } | { type: "reasoning_end"; id: string } - | { type: "tool_call"; id?: string; name?: string; input?: unknown; render?: RenderHint } + | { + type: "tool_call"; + id?: string; + name?: string; + input?: unknown; + render?: RenderHint; + } | { type: "tool_result"; id?: string; @@ -167,7 +237,13 @@ export type AgentEvent = // `file` -> Vercel `file`. | { type: "data"; name: string; data: unknown; transient?: boolean } | { type: "file"; url: string; mediaType: string } - | { type: "usage"; input?: number; output?: number; total?: number; cost?: number } + | { + type: "usage"; + input?: number; + output?: number; + total?: number; + cost?: number; + } | { type: "error"; message: string } | { type: "done"; stopReason?: string }; @@ -209,6 +285,39 @@ export interface AgentRunRequest { appendSystemPrompt?: string; /** Model id ("gpt-5.5") or "provider/id" ("openai-codex/gpt-5.5"). */ model?: string; + /** + * Provider family for the run, e.g. "openai" | "anthropic" | . Non-secret. + * Present only when the config carries a structured model ref. See the provider-model-auth + * design (Concern 1). + */ + provider?: string; + /** + * Where the credential comes from, named portably (a slug, never a db id). Non-secret. + * Present only when the config carries a structured model ref. See the provider-model-auth + * design (Concern 1). + */ + connection?: { mode: string; slug?: string }; + /** + * Deployment surface for the provider: "direct" | "azure" | "bedrock" | "vertex" | + * "custom". From a resolved connection; see the provider-model-auth design (Concern 3). + */ + deployment?: string; + /** + * Non-secret connection config (custom base URL, api version, region, public headers). + * Secret values never live here; they ride `secrets`. See the provider-model-auth design + * (Concern 3). + */ + endpoint?: { + baseUrl?: string; + apiVersion?: string; + region?: string; + headers?: Record; + }; + /** + * How the credential is delivered: "env" | "runtime_provided" | "none". From a resolved + * connection; see the provider-model-auth design (Concern 3). + */ + credentialMode?: string; /** Explicit latest turn. Falls back to the last user message in `messages`. */ prompt?: string; /** The conversation so far; the runner picks the latest turn and replays the rest. */ @@ -216,11 +325,12 @@ export interface AgentRunRequest { /** Built-in tools to enable. */ tools?: string[]; /** - * Bundled skill directory names to force-load (the Agenta harness). Each name resolves - * against the runner's bundled `skills/` root and is loaded into Pi's resource loader, so - * it appears in the system prompt (Pi only renders skills when the `read` tool is enabled). + * Resolved inline skill packages. Each rode the wire as concrete content (references + * inlined server-side via `@ag.embed`); the runner materializes each into a skill dir and + * loads it into Pi's resource loader, so it appears in the system prompt (Pi only renders + * skills when the `read` tool is enabled). */ - skills?: string[]; + skills?: WireSkill[]; /** Resolved runnable tools (WP-7). */ customTools?: ResolvedToolSpec[]; /** User-declared MCP servers, resolved (secret env injected). Omitted when there are none. */ @@ -229,6 +339,21 @@ export interface AgentRunRequest { toolCallback?: ToolCallbackContext; /** How a permission-gating harness handles tool-use prompts: "auto" (default) | "deny". */ permissionPolicy?: string; + /** + * The declared sandbox security boundary (Layer 2). Omitted when unset. Plumbing only: the + * runner threads it onto the run plan but does NOT yet enforce it on the sandbox provider. + */ + sandboxPermission?: SandboxPermission; + /** + * Generic harness-rendered files to drop in the session cwd before the session starts. Each + * entry is `{ path (relative to cwd), content (UTF-8 file text) }`. Produced by the Python + * harness adapters: a harness translates its own `harness_options` slice into a config file in + * Python (e.g. the claude adapter renders `.claude/settings.json` from its permissions slice), + * so the runner stays a dumb writer with no harness knowledge. Omitted when no files were + * rendered. This scales to many harnesses: a new harness emits its files here instead of a + * first-party wire field plus runner-side translation. + */ + harnessFiles?: Array<{ path: string; content: string }>; /** Tracing: thread the Agenta trace context across the boundary. */ trace?: TraceContext; } @@ -267,7 +392,9 @@ export type StreamRecord = | { kind: "result"; result: AgentRunResult }; /** Flatten a message's content (string or content blocks) to its text. */ -export function messageText(content: string | ContentBlock[] | undefined): string { +export function messageText( + content: string | ContentBlock[] | undefined, +): string { if (!content) return ""; if (typeof content === "string") return content; return content @@ -290,6 +417,11 @@ export function resolvePromptText(request: AgentRunRequest): string { } /** Prefer the platform conversation id, falling back to the harness's ephemeral id. */ -export function resolveRunSessionId(request: AgentRunRequest, fallback: string): string { - return request.sessionId && request.sessionId.trim() ? request.sessionId : fallback; +export function resolveRunSessionId( + request: AgentRunRequest, + fallback: string, +): string { + return request.sessionId && request.sessionId.trim() + ? request.sessionId + : fallback; } diff --git a/services/agent/src/tracing/otel.ts b/services/agent/src/tracing/otel.ts index 234eeb1770..5a8d98ce7f 100644 --- a/services/agent/src/tracing/otel.ts +++ b/services/agent/src/tracing/otel.ts @@ -175,19 +175,6 @@ export async function flushTrace(traceId?: string): Promise { await processor.flush(traceId); } -/** Flush and shut down all exporters. Call once on process exit, not per run. */ -export async function shutdownTracing(): Promise { - if (!provider) return; - try { - await provider.forceFlush(); - await provider.shutdown(); - } finally { - provider = undefined; - processor = undefined; - exporterCache.clear(); - } -} - /** * Order spans parent-before-child (preorder DFS). Agenta stores timestamps at * millisecond resolution and builds its roll-up tree by sorting on start_time, diff --git a/services/agent/tests/unit/responder.test.ts b/services/agent/tests/unit/responder.test.ts index bb040707d5..ab38dae8a1 100644 --- a/services/agent/tests/unit/responder.test.ts +++ b/services/agent/tests/unit/responder.test.ts @@ -12,11 +12,15 @@ import { afterEach, describe, it } from "vitest"; import assert from "node:assert/strict"; import { createSandboxAgentOtel } from "../../src/tracing/otel.ts"; -import type { AgentEvent } from "../../src/protocol.ts"; +import type { AgentEvent, AgentRunRequest } from "../../src/protocol.ts"; import { + HITLResponder, PolicyResponder, decisionToReply, + extractApprovalDecisions, policyFromRequest, + type PermissionDecision, + type PermissionRequest, } from "../../src/responder.ts"; // Defensive cleanup: policyFromRequest reads this env var; never let it leak past a test @@ -41,11 +45,25 @@ describe("policyFromRequest", () => { describe("decisionToReply (parity with the old inline mapping)", () => { it("maps allow/deny onto the available replies", () => { - assert.equal(decisionToReply("allow", ["always", "once", "reject"]), "always"); + assert.equal( + decisionToReply("allow", ["always", "once", "reject"]), + "always", + ); assert.equal(decisionToReply("allow", ["once", "reject"]), "once"); - assert.equal(decisionToReply("allow", []), "once", "allow falls back to once"); - assert.equal(decisionToReply("deny", ["always", "once", "reject"]), "reject"); - assert.equal(decisionToReply("deny", []), "reject", "deny falls back to reject"); + assert.equal( + decisionToReply("allow", []), + "once", + "allow falls back to once", + ); + assert.equal( + decisionToReply("deny", ["always", "once", "reject"]), + "reject", + ); + assert.equal( + decisionToReply("deny", []), + "reject", + "deny falls back to reject", + ); }); }); @@ -59,10 +77,175 @@ describe("PolicyResponder", () => { }); }); +// A permission request as the harness adapter shapes it: `raw.toolCall` carries the gated +// tool's id + name, which is what the responder keys a stored decision by. +function permReq(toolCallId?: string, name?: string): PermissionRequest { + return { + id: "perm-1", + availableReplies: ["once", "always", "reject"], + raw: { id: "perm-1", toolCall: { toolCallId, name } }, + }; +} + +describe("HITLResponder", () => { + it("applies a stored decision (resume path) by tool-call id", async () => { + const decisions = new Map([["tc-1", "allow"]]); + const allow = new HITLResponder(decisions, "auto", true); + assert.equal(await allow.onPermission(permReq("tc-1", "edit")), "allow"); + + const denied = new Map([["tc-2", "deny"]]); + const deny = new HITLResponder(denied, "auto", true); + assert.equal(await deny.onPermission(permReq("tc-2", "edit")), "deny"); + }); + + it("matches a stored decision by tool name when the id was not preserved", async () => { + const decisions = new Map([["edit", "allow"]]); + const responder = new HITLResponder(decisions, "auto", true); + // Fresh tool-call id this turn, but the name still matches the recorded decision. + assert.equal( + await responder.onPermission(permReq("fresh-id", "edit")), + "allow", + ); + }); + + it("parks (deny) when there is a human surface and no stored decision", async () => { + // `basePolicy` is "auto" so this proves the park overrides the policy, not the policy. + const responder = new HITLResponder(new Map(), "auto", true); + assert.equal(await responder.onPermission(permReq("tc-x", "edit")), "deny"); + }); + + it("headless: no decision + no human surface falls back to basePolicy (PolicyResponder parity)", async () => { + const auto = new HITLResponder(new Map(), "auto", false); + const deny = new HITLResponder(new Map(), "deny", false); + assert.equal(await auto.onPermission(permReq("tc-y", "edit")), "allow"); + assert.equal(await deny.onPermission(permReq("tc-z", "edit")), "deny"); + + // Byte-for-byte the same result the old headless responder produced. + const policyAuto = new PolicyResponder("auto"); + const policyDeny = new PolicyResponder("deny"); + assert.equal( + await auto.onPermission(permReq("tc-y", "edit")), + await policyAuto.onPermission(permReq("tc-y", "edit")), + ); + assert.equal( + await deny.onPermission(permReq("tc-z", "edit")), + await policyDeny.onPermission(permReq("tc-z", "edit")), + ); + }); +}); + +describe("extractApprovalDecisions", () => { + it("builds the lookup from approval tool_result blocks, keyed by id and name", () => { + const request: AgentRunRequest = { + sessionId: "s-1", + messages: [ + { role: "user", content: "do the thing" }, + { + role: "assistant", + content: [ + { + type: "tool_call", + toolCallId: "tc-1", + toolName: "edit", + input: {}, + }, + ], + }, + { + // The cross-turn approval reply the Vercel adapter produced. + role: "tool", + content: [ + { + type: "tool_result", + toolCallId: "tc-1", + toolName: "edit", + output: { approved: true }, + }, + { + type: "tool_result", + toolCallId: "tc-2", + toolName: "bash", + output: { approved: false }, + }, + ], + }, + ], + }; + + const decisions = extractApprovalDecisions(request); + assert.equal(decisions.get("tc-1"), "allow"); + assert.equal(decisions.get("edit"), "allow"); + assert.equal(decisions.get("tc-2"), "deny"); + assert.equal(decisions.get("bash"), "deny"); + }); + + it("ignores ordinary tool results that are not approval envelopes", () => { + const request: AgentRunRequest = { + messages: [ + { + role: "tool", + content: [ + // A real tool output, not an `{approved}` envelope. + { + type: "tool_result", + toolCallId: "tc-9", + output: "the weather is 24C", + }, + // Structured output that merely lacks `approved`. + { type: "tool_result", toolCallId: "tc-10", output: { temp: 24 } }, + // Text block: not a decision. + { type: "text", text: "hello" }, + ], + }, + ], + }; + + const decisions = extractApprovalDecisions(request); + assert.equal(decisions.size, 0); + }); + + it("returns an empty lookup when there are no structured messages (headless /invoke)", () => { + const request: AgentRunRequest = { prompt: "just a single turn" }; + assert.equal(extractApprovalDecisions(request).size, 0); + }); + + it("end-to-end: an extracted decision resumes a parked permission", async () => { + const request: AgentRunRequest = { + sessionId: "s-2", + messages: [ + { + role: "tool", + content: [ + { + type: "tool_result", + toolCallId: "tc-1", + toolName: "edit", + output: { approved: true }, + }, + ], + }, + ], + }; + const responder = new HITLResponder( + extractApprovalDecisions(request), + "auto", + true, // human surface present, but the stored decision wins over the park + ); + assert.equal( + await responder.onPermission(permReq("tc-1", "edit")), + "allow", + ); + }); +}); + describe("emitEvent", () => { it("streaming path: flushes to the live sink and the batch log", () => { const emitted: AgentEvent[] = []; - const run = createSandboxAgentOtel({ harness: "claude", model: "anthropic/x", emit: (e) => emitted.push(e) }); + const run = createSandboxAgentOtel({ + harness: "claude", + model: "anthropic/x", + emit: (e) => emitted.push(e), + }); run.start({ prompt: "hi" }); const interaction: AgentEvent = { type: "interaction_request", @@ -82,7 +265,10 @@ describe("emitEvent", () => { }); it("one-shot path: records in the batch log only", () => { - const run = createSandboxAgentOtel({ harness: "claude", model: "anthropic/x" }); + const run = createSandboxAgentOtel({ + harness: "claude", + model: "anthropic/x", + }); run.start({ prompt: "hi" }); run.emitEvent({ type: "data", name: "weather", data: { temp: 24 } }); const ev = run.events().find((e) => e.type === "data"); diff --git a/services/agent/tests/unit/sandbox-agent-orchestration.test.ts b/services/agent/tests/unit/sandbox-agent-orchestration.test.ts index 104d12380f..106e109768 100644 --- a/services/agent/tests/unit/sandbox-agent-orchestration.test.ts +++ b/services/agent/tests/unit/sandbox-agent-orchestration.test.ts @@ -8,7 +8,10 @@ import assert from "node:assert/strict"; import type { AgentEvent, AgentRunRequest } from "../../src/protocol.ts"; import type { PermissionDecision } from "../../src/responder.ts"; -import { runSandboxAgent, type SandboxAgentDeps } from "../../src/engines/sandbox_agent.ts"; +import { + runSandboxAgent, + type SandboxAgentDeps, +} from "../../src/engines/sandbox_agent.ts"; function flushPromises(): Promise { return new Promise((resolve) => setImmediate(resolve)); @@ -29,6 +32,7 @@ interface FakeOptions { function fakeHarness(options: FakeOptions = {}) { const calls = { daemonAgent: "", + daemonOptions: undefined as { clearProviderEnv?: boolean } | undefined, providerArgs: [] as unknown[], startOptions: undefined as any, createSessionOptions: undefined as any, @@ -42,6 +46,10 @@ function fakeHarness(options: FakeOptions = {}) { toolRelayArgs: undefined as unknown[] | undefined, toolRelayStops: 0, permissionReplies: [] as Array<{ id: string; reply: string }>, + applyModelArgs: [] as Array<{ + model: string | undefined; + options: { strict?: boolean } | undefined; + }>, runFinished: 0, runFlushed: 0, }; @@ -71,10 +79,12 @@ function fakeHarness(options: FakeOptions = {}) { }); } if (options.promptError) throw options.promptError; - return options.promptResult ?? { - stopReason: "complete", - usage: { inputTokens: 6, outputTokens: 4 }, - }; + return ( + options.promptResult ?? { + stopReason: "complete", + usage: { inputTokens: 6, outputTokens: 4 }, + } + ); }, }; @@ -100,7 +110,9 @@ function fakeHarness(options: FakeOptions = {}) { events.push(event); }, usage() { - return options.streamUsage ?? { input: 0, output: 0, total: 0, cost: 0.25 }; + return ( + options.streamUsage ?? { input: 0, output: 0, total: 0, cost: 0.25 } + ); }, setUsage(usage: unknown) { events.push({ type: "usage", ...(usage as any) }); @@ -124,9 +136,10 @@ function fakeHarness(options: FakeOptions = {}) { log: () => {}, createLocalCwd: () => options.cwd ?? "/tmp/agenta-fake-cwd", createDaytonaCwd: () => "/home/sandbox/agenta-fake-cwd", - resolveSkillDirs: () => [], - buildDaemonEnv: (agent) => { + resolveSkillDirs: () => ({ skills: [], cleanup: () => {} }), + buildDaemonEnv: (agent, daemonOptions) => { calls.daemonAgent = agent; + calls.daemonOptions = daemonOptions; return {}; }, resolveDaemonBinary: () => "/bin/sandbox-agent", @@ -154,7 +167,10 @@ function fakeHarness(options: FakeOptions = {}) { streamingDeltas: true, ...(options.capabilities ?? {}), }) as any, - applyModel: async (_session, model) => model ?? "resolved-model", + applyModel: async (_session, model, _log, options) => { + calls.applyModelArgs.push({ model, options }); + return model ?? "resolved-model"; + }, createOtel: ((otelOptions: any) => { calls.otelOptions = otelOptions; return run; @@ -193,8 +209,15 @@ describe("runSandboxAgent orchestration", () => { assert.equal(result.ok, true); if (!result.ok) return; assert.equal(result.output, "assistant output"); - assert.deepEqual(result.messages, [{ role: "assistant", content: "assistant output" }]); - assert.deepEqual(result.usage, { input: 6, output: 4, total: 10, cost: 0.25 }); + assert.deepEqual(result.messages, [ + { role: "assistant", content: "assistant output" }, + ]); + assert.deepEqual(result.usage, { + input: 6, + output: 4, + total: 10, + cost: 0.25, + }); assert.equal(result.stopReason, "complete"); assert.equal(result.sessionId, "session-1"); assert.equal(result.model, "requested-model"); @@ -204,7 +227,9 @@ describe("runSandboxAgent orchestration", () => { assert.equal(calls.createSessionOptions.agent, "claude"); assert.equal(calls.createSessionOptions.cwd, "/tmp/agenta-fake-cwd"); assert.deepEqual(calls.promptBlocks, [{ type: "text", text: "hello" }]); - assert.deepEqual(calls.runStart.messages, [{ role: "user", content: "hello" }]); + assert.deepEqual(calls.runStart.messages, [ + { role: "user", content: "hello" }, + ]); assert.equal(calls.runFinished, 1); assert.equal(calls.runFlushed, 1); assert.equal(calls.sandboxDestroyed, 1); @@ -243,20 +268,25 @@ describe("runSandboxAgent orchestration", () => { assert.equal(result.ok, true); if (!result.ok) return; - assert.deepEqual(result.events?.filter((event) => event.type === "interaction_request"), [ - { - type: "interaction_request", - id: "perm-1", - kind: "permission", - payload: { - toolCallId: "tool-1", - toolCall: { toolCallId: "tool-1", name: "edit" }, - availableReplies: ["once", "always", "reject"], - options: undefined, + assert.deepEqual( + result.events?.filter((event) => event.type === "interaction_request"), + [ + { + type: "interaction_request", + id: "perm-1", + kind: "permission", + payload: { + toolCallId: "tool-1", + toolCall: { toolCallId: "tool-1", name: "edit" }, + availableReplies: ["once", "always", "reject"], + options: undefined, + }, }, - }, + ], + ); + assert.deepEqual(calls.permissionReplies, [ + { id: "perm-1", reply: "always" }, ]); - assert.deepEqual(calls.permissionReplies, [{ id: "perm-1", reply: "always" }]); }); it("starts and stops the tool relay only when executable tools are present", async () => { @@ -279,8 +309,15 @@ describe("runSandboxAgent orchestration", () => { "/tmp/agenta-fake-cwd/.agenta-tools", [{ name: "server_tool", kind: "callback" }], undefined, + // Layer 3 (S3b): the resolved permission policy threaded into the relay. No + // `permissionPolicy` on the request -> the headless default `auto`. + "auto", ]); - assert.equal(calls.toolRelayStops, 2, "stopped after prompt and again in finally"); + assert.equal( + calls.toolRelayStops, + 2, + "stopped after prompt and again in finally", + ); }); it("flushes a partial trace and cleans up on prompt errors", async () => { @@ -301,6 +338,30 @@ describe("runSandboxAgent orchestration", () => { assert.equal(calls.workspaceCleanup, 1); }); + it("passes the sandbox permission through to buildSandboxProvider", async () => { + const { calls, deps } = fakeHarness(); + const sandboxPermission = { + network: { mode: "allowlist" as const, allowlist: ["10.0.0.0/8"] }, + enforcement: "best_effort" as const, + }; + + const result = await runSandboxAgent( + { + harness: "claude", + sandbox: "daytona", + prompt: "hello", + sandboxPermission, + }, + undefined, + undefined, + deps, + ); + + assert.equal(result.ok, true); + // sandboxId, env, binaryPath, piExtEnv, secrets, sandboxPermission + assert.deepEqual(calls.providerArgs[5], sandboxPermission); + }); + it("passes cancellation signals into SandboxAgent.start", async () => { const { calls, deps } = fakeHarness(); const controller = new AbortController(); @@ -315,4 +376,195 @@ describe("runSandboxAgent orchestration", () => { assert.equal(result.ok, true); assert.equal(calls.startOptions.signal, controller.signal); }); + + it("clears inherited provider env on a managed run and applies ANTHROPIC_BASE_URL for claude", async () => { + const { calls, deps } = fakeHarness(); + + const result = await runSandboxAgent( + { + harness: "claude", + prompt: "hello", + credentialMode: "env", + secrets: { ANTHROPIC_API_KEY: "resolved" }, + endpoint: { baseUrl: "https://claude-gw.example/v1" }, + } as AgentRunRequest, + undefined, + undefined, + deps, + ); + + assert.equal(result.ok, true); + // Managed run -> clear-then-apply: buildDaemonEnv is asked to clear the inherited provider env. + assert.deepEqual(calls.daemonOptions, { clearProviderEnv: true }); + // The env handed to buildSandboxProvider carries only the resolved key + the custom base url. + const env = calls.providerArgs[1] as Record; + assert.equal(env.ANTHROPIC_API_KEY, "resolved"); + assert.equal(env.ANTHROPIC_BASE_URL, "https://claude-gw.example/v1"); + assert.equal(env.ANTHROPIC_MODEL, undefined); + }); + + it("sets Claude Bedrock env and strict selected model pass-through", async () => { + const { calls, deps } = fakeHarness(); + + const result = await runSandboxAgent( + { + harness: "claude", + prompt: "hello", + model: "anthropic.claude-x", + deployment: "bedrock", + credentialMode: "env", + secrets: { AWS_ACCESS_KEY_ID: "AKIA" }, + endpoint: { region: "us-east-1" }, + } as AgentRunRequest, + undefined, + undefined, + deps, + ); + + assert.equal(result.ok, true); + const env = calls.providerArgs[1] as Record; + assert.equal(env.CLAUDE_CODE_USE_BEDROCK, "1"); + assert.equal(env.AWS_ACCESS_KEY_ID, "AKIA"); + assert.equal(env.AWS_REGION, "us-east-1"); + assert.equal(env.ANTHROPIC_MODEL, "anthropic.claude-x"); + assert.equal(env.ANTHROPIC_CUSTOM_MODEL_OPTION, "anthropic.claude-x"); + assert.deepEqual(calls.applyModelArgs.at(-1), { + model: "anthropic.claude-x", + options: { strict: true }, + }); + }); + + it("sets Claude Vertex env and selected model pass-through", async () => { + const { calls, deps } = fakeHarness(); + + const result = await runSandboxAgent( + { + harness: "claude", + prompt: "hello", + model: "claude-sonnet-4", + deployment: "vertex_ai", + credentialMode: "env", + secrets: { GOOGLE_CLOUD_PROJECT: "proj", GOOGLE_CLOUD_LOCATION: "us-central1" }, + } as AgentRunRequest, + undefined, + undefined, + deps, + ); + + assert.equal(result.ok, true); + const env = calls.providerArgs[1] as Record; + assert.equal(env.CLAUDE_CODE_USE_VERTEX, "1"); + assert.equal(env.GOOGLE_CLOUD_PROJECT, "proj"); + assert.equal(env.ANTHROPIC_MODEL, "claude-sonnet-4"); + }); + + it("does not clear provider env or set a base url on a runtime_provided run", async () => { + const { calls, deps } = fakeHarness(); + + const result = await runSandboxAgent( + { + harness: "claude", + prompt: "hello", + credentialMode: "runtime_provided", + } as AgentRunRequest, + undefined, + undefined, + deps, + ); + + assert.equal(result.ok, true); + // runtime_provided -> keep the harness's own inherited env (do not clear). + assert.deepEqual(calls.daemonOptions, { clearProviderEnv: false }); + const env = calls.providerArgs[1] as Record; + assert.equal(env.ANTHROPIC_BASE_URL, undefined); + }); +}); + +// These exercise the engine's DEFAULT responder (HITLResponder) by dropping the +// `responderFactory` override the fake otherwise installs, so we test the real cross-turn +// wiring: headless parity, the park, and the resume. +describe("runSandboxAgent default HITL responder wiring", () => { + function depsWithDefaultResponder() { + const { calls, deps } = fakeHarness({ emitPermission: true }); + delete deps.responderFactory; // fall through to the engine's HITLResponder + return { calls, deps }; + } + + it("headless (/invoke: no sessionId, no decisions) auto-allows — no regression", async () => { + const { calls, deps } = depsWithDefaultResponder(); + + const result = await runSandboxAgent( + { harness: "claude", prompt: "edit the file" }, + undefined, + undefined, + deps, + ); + await flushPromises(); + + assert.equal(result.ok, true); + // Old PolicyResponder("auto") would have replied "always"; the default must match. + assert.deepEqual(calls.permissionReplies, [ + { id: "perm-1", reply: "always" }, + ]); + }); + + it("human surface (/messages: sessionId set) with no decision parks the tool (reject)", async () => { + const { calls, deps } = depsWithDefaultResponder(); + + const result = await runSandboxAgent( + { + harness: "claude", + sessionId: "conv-1", + messages: [{ role: "user", content: "edit the file" }], + }, + undefined, + undefined, + deps, + ); + await flushPromises(); + + assert.equal(result.ok, true); + // Park: decline the unapproved tool this turn (the interaction_request already prompted + // the browser); the next turn carrying the decision resolves it. + assert.deepEqual(calls.permissionReplies, [ + { id: "perm-1", reply: "reject" }, + ]); + }); + + it("human surface with a stored approval resumes the tool (always)", async () => { + const { calls, deps } = depsWithDefaultResponder(); + + const result = await runSandboxAgent( + { + harness: "claude", + sessionId: "conv-1", + messages: [ + { role: "user", content: "edit the file" }, + { + // The cross-turn approval reply, keyed by the gated tool's name (cold replay + // mints a fresh tool-call id "tool-1" each turn, so the name is the anchor). + role: "tool", + content: [ + { + type: "tool_result", + toolCallId: "tool-1", + toolName: "edit", + output: { approved: true }, + }, + ], + }, + { role: "user", content: "continue" }, + ], + }, + undefined, + undefined, + deps, + ); + await flushPromises(); + + assert.equal(result.ok, true); + assert.deepEqual(calls.permissionReplies, [ + { id: "perm-1", reply: "always" }, + ]); + }); }); diff --git a/services/agent/tests/unit/sandbox-agent-pi-assets.test.ts b/services/agent/tests/unit/sandbox-agent-pi-assets.test.ts index c07bc6b576..cb0042aca3 100644 --- a/services/agent/tests/unit/sandbox-agent-pi-assets.test.ts +++ b/services/agent/tests/unit/sandbox-agent-pi-assets.test.ts @@ -14,7 +14,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { basename, join } from "node:path"; +import { join } from "node:path"; import type { AgentRunRequest } from "../../src/protocol.ts"; import { @@ -34,7 +34,8 @@ function tempDir(prefix: string): string { } afterEach(() => { - for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + for (const dir of dirs.splice(0)) + rmSync(dir, { recursive: true, force: true }); }); describe("buildPiExtensionEnv", () => { @@ -50,7 +51,10 @@ describe("buildPiExtensionEnv", () => { { name: "safe_tool", description: "safe", - inputSchema: { type: "object", properties: { x: { type: "string" } } }, + inputSchema: { + type: "object", + properties: { x: { type: "string" } }, + }, callRef: "server-secret-ref", env: { SECRET: "do-not-expose" }, kind: "callback", @@ -91,7 +95,8 @@ describe("buildPiExtensionEnv", () => { const env = buildPiExtensionEnv( { trace: { - traceparent: "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01", + traceparent: + "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01", }, customTools: [{ name: "safe_tool", kind: "callback" }], } as AgentRunRequest, @@ -111,27 +116,39 @@ describe("writeSystemPromptLocal", () => { writeSystemPromptLocal(dir, "system text", "append text"); assert.equal(readFileSync(join(dir, "SYSTEM.md"), "utf-8"), "system text"); - assert.equal(readFileSync(join(dir, "APPEND_SYSTEM.md"), "utf-8"), "append text"); + assert.equal( + readFileSync(join(dir, "APPEND_SYSTEM.md"), "utf-8"), + "append text", + ); }); }); describe("prepareLocalAgentDir", () => { - it("seeds auth/settings and installs forced skills into a throwaway dir", () => { + it("seeds auth/settings and installs materialized skills into a throwaway dir", () => { const source = tempDir("agenta-pi-source-test-"); - writeFileSync(join(source, "auth.json"), "{\"token\":\"x\"}", "utf-8"); - writeFileSync(join(source, "settings.json"), "{\"model\":\"gpt\"}", "utf-8"); + writeFileSync(join(source, "auth.json"), '{"token":"x"}', "utf-8"); + writeFileSync(join(source, "settings.json"), '{"model":"gpt"}', "utf-8"); const skill = tempDir("agenta-pi-skill-test-"); writeFileSync(join(skill, "SKILL.md"), "---\nname: skill\n---\n", "utf-8"); - const runDir = prepareLocalAgentDir(source, [skill]); + const runDir = prepareLocalAgentDir(source, [ + { name: "skill", dir: skill }, + ]); dirs.push(runDir); assert.notEqual(runDir, source); - assert.equal(readFileSync(join(runDir, "auth.json"), "utf-8"), "{\"token\":\"x\"}"); - assert.equal(readFileSync(join(runDir, "settings.json"), "utf-8"), "{\"model\":\"gpt\"}"); assert.equal( - readFileSync(join(runDir, "skills", basename(skill), "SKILL.md"), "utf-8"), + readFileSync(join(runDir, "auth.json"), "utf-8"), + '{"token":"x"}', + ); + assert.equal( + readFileSync(join(runDir, "settings.json"), "utf-8"), + '{"model":"gpt"}', + ); + // The dest dir is named by the skill's `name`, not the (throwaway) source dir basename. + assert.equal( + readFileSync(join(runDir, "skills", "skill", "SKILL.md"), "utf-8"), "---\nname: skill\n---\n", ); }); @@ -143,9 +160,11 @@ describe("sandbox uploads", () => { mkdirSync(join(root, "nested")); writeFileSync(join(root, "top.txt"), "top", "utf-8"); writeFileSync(join(root, "nested", "child.txt"), "child", "utf-8"); - const calls: Array<{ op: "mkdir" | "write"; path: string; body?: string }> = []; + const calls: Array<{ op: "mkdir" | "write"; path: string; body?: string }> = + []; const sandbox = { - mkdirFs: async ({ path }: { path: string }) => calls.push({ op: "mkdir", path }), + mkdirFs: async ({ path }: { path: string }) => + calls.push({ op: "mkdir", path }), writeFsFile: async ({ path }: { path: string }, body: string) => calls.push({ op: "write", path, body }), }; @@ -155,12 +174,16 @@ describe("sandbox uploads", () => { assert.deepEqual(calls, [ { op: "mkdir", path: "/agent/skills/custom" }, { op: "mkdir", path: "/agent/skills/custom/nested" }, - { op: "write", path: "/agent/skills/custom/nested/child.txt", body: "child" }, + { + op: "write", + path: "/agent/skills/custom/nested/child.txt", + body: "child", + }, { op: "write", path: "/agent/skills/custom/top.txt", body: "top" }, ]); }); - it("uploads each forced skill under the Pi skills directory", async () => { + it("uploads each materialized skill under the Pi skills directory", async () => { const skill = tempDir("agenta-pi-skill-upload-test-"); writeFileSync(join(skill, "SKILL.md"), "skill", "utf-8"); const written: string[] = []; @@ -169,9 +192,12 @@ describe("sandbox uploads", () => { writeFsFile: async ({ path }: { path: string }) => written.push(path), }; - await uploadSkillsToSandbox(sandbox, "/agent", [skill]); + await uploadSkillsToSandbox(sandbox, "/agent", [ + { name: "release-notes", dir: skill }, + ]); assert.equal(existsSync(skill), true); - assert.deepEqual(written, [`/agent/skills/${basename(skill)}/SKILL.md`]); + // The sandbox dest dir is named by the skill's `name`. + assert.deepEqual(written, ["/agent/skills/release-notes/SKILL.md"]); }); }); diff --git a/services/agent/tests/unit/sandbox-agent-run-plan.test.ts b/services/agent/tests/unit/sandbox-agent-run-plan.test.ts index ec3fe751da..d0cfe62f77 100644 --- a/services/agent/tests/unit/sandbox-agent-run-plan.test.ts +++ b/services/agent/tests/unit/sandbox-agent-run-plan.test.ts @@ -7,7 +7,10 @@ import { afterEach, describe, it } from "vitest"; import assert from "node:assert/strict"; import type { AgentRunRequest } from "../../src/protocol.ts"; -import { buildRunPlan } from "../../src/engines/sandbox_agent/run-plan.ts"; +import { + buildRunPlan, + shouldUploadOwnLogin, +} from "../../src/engines/sandbox_agent/run-plan.ts"; const previousPiDir = process.env.PI_CODING_AGENT_DIR; @@ -20,10 +23,15 @@ describe("buildRunPlan", () => { it("returns the current no-prompt error without creating a cwd", () => { let created = false; - const result = buildRunPlan({}, { createLocalCwd: () => { - created = true; - return "/tmp/unused"; - } }); + const result = buildRunPlan( + {}, + { + createLocalCwd: () => { + created = true; + return "/tmp/unused"; + }, + }, + ); assert.deepEqual(result, { ok: false, @@ -46,14 +54,19 @@ describe("buildRunPlan", () => { { name: "server_tool", kind: "callback" }, { name: "client_tool", kind: "client" }, ], - skills: ["alpha"], + skills: [ + { name: "alpha", description: "Alpha skill.", body: "Do alpha." }, + ], secrets: { OPENAI_API_KEY: "key" }, } as AgentRunRequest, { createLocalCwd: () => "/tmp/local-cwd", resolveSkillDirs: (_skills, log) => { (log ?? (() => {}))("resolved alpha"); - return ["/skills/alpha"]; + return { + skills: [{ name: "alpha", dir: "/skills/alpha" }], + cleanup: () => {}, + }; }, log: (message) => logs.push(message), }, @@ -79,10 +92,232 @@ describe("buildRunPlan", () => { ["server_tool"], ); assert.equal(result.plan.useToolRelay, true); - assert.deepEqual(result.plan.skillDirs, ["/skills/alpha"]); + assert.deepEqual(result.plan.skillDirs, [ + { name: "alpha", dir: "/skills/alpha" }, + ]); assert.deepEqual(logs, ["resolved alpha", "skills: alpha"]); }); + it("carries the sandbox permission onto the plan and leaves an unrestricted run alone", () => { + const result = buildRunPlan( + { + harness: "claude", + sandbox: "daytona", + prompt: "hello", + sandboxPermission: { + network: { mode: "on", allowlist: [] }, + enforcement: "strict", + }, + } as AgentRunRequest, + { createDaytonaCwd: () => "/home/sandbox/agenta-fixed" }, + ); + + assert.equal(result.ok, true); + if (!result.ok) return; + assert.deepEqual(result.plan.sandboxPermission, { + network: { mode: "on", allowlist: [] }, + enforcement: "strict", + }); + }); + + it("treats an absent sandbox permission as unrestricted", () => { + const result = buildRunPlan( + { harness: "claude", sandbox: "daytona", prompt: "hello" }, + { createDaytonaCwd: () => "/home/sandbox/agenta-fixed" }, + ); + + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.plan.sandboxPermission, undefined); + }); + + it("rejects a strict restricted-network run on the local sandbox", () => { + const result = buildRunPlan( + { + harness: "claude", + sandbox: "local", + prompt: "hello", + sandboxPermission: { + network: { mode: "off" }, + enforcement: "strict", + }, + } as AgentRunRequest, + { createLocalCwd: () => "/tmp/local-cwd" }, + ); + + assert.equal(result.ok, false); + if (result.ok) return; + assert.match(result.error, /local sandbox cannot enforce network:off/); + }); + + it("allows a best_effort restricted-network run on the local sandbox", () => { + const result = buildRunPlan( + { + harness: "claude", + sandbox: "local", + prompt: "hello", + sandboxPermission: { + network: { mode: "off" }, + enforcement: "best_effort", + }, + } as AgentRunRequest, + { createLocalCwd: () => "/tmp/local-cwd" }, + ); + + assert.equal(result.ok, true); + }); + + it("rejects a strict restricted-network Daytona run with a runner-host tool", () => { + const result = buildRunPlan( + { + harness: "claude", + sandbox: "daytona", + prompt: "hello", + customTools: [{ name: "server_tool", kind: "callback" }], + sandboxPermission: { + network: { mode: "allowlist", allowlist: ["10.0.0.0/8"] }, + enforcement: "strict", + }, + } as AgentRunRequest, + { createDaytonaCwd: () => "/home/sandbox/agenta-fixed" }, + ); + + assert.equal(result.ok, false); + if (result.ok) return; + assert.match(result.error, /run on the runner host and would bypass/); + assert.match(result.error, /network:allowlist/); + }); + + it("rejects a strict restricted-network Daytona run with a stdio MCP server", () => { + const result = buildRunPlan( + { + harness: "claude", + sandbox: "daytona", + prompt: "hello", + mcpServers: [{ name: "fs", transport: "stdio", command: "mcp-fs" }], + sandboxPermission: { + network: { mode: "off" }, + enforcement: "strict", + }, + } as AgentRunRequest, + { createDaytonaCwd: () => "/home/sandbox/agenta-fixed" }, + ); + + assert.equal(result.ok, false); + if (result.ok) return; + assert.match(result.error, /stdio MCP servers run on the runner host/); + }); + + it("allows a strict restricted-network Daytona run with only a remote MCP server", () => { + const result = buildRunPlan( + { + harness: "claude", + sandbox: "daytona", + prompt: "hello", + mcpServers: [ + { name: "remote", transport: "http", url: "https://mcp.example" }, + ], + sandboxPermission: { + network: { mode: "off" }, + enforcement: "strict", + }, + } as AgentRunRequest, + { createDaytonaCwd: () => "/home/sandbox/agenta-fixed" }, + ); + + assert.equal(result.ok, true); + }); + + it("allows a best_effort restricted-network Daytona run with a runner-host tool", () => { + const result = buildRunPlan( + { + harness: "claude", + sandbox: "daytona", + prompt: "hello", + customTools: [{ name: "server_tool", kind: "callback" }], + sandboxPermission: { + network: { mode: "off" }, + enforcement: "best_effort", + }, + } as AgentRunRequest, + { createDaytonaCwd: () => "/home/sandbox/agenta-fixed" }, + ); + + assert.equal(result.ok, true); + }); + + it("allows a strict Daytona run with a clean network boundary (no host tools)", () => { + const result = buildRunPlan( + { + harness: "claude", + sandbox: "daytona", + prompt: "hello", + sandboxPermission: { + network: { mode: "off" }, + enforcement: "strict", + }, + } as AgentRunRequest, + { createDaytonaCwd: () => "/home/sandbox/agenta-fixed" }, + ); + + assert.equal(result.ok, true); + }); + + it("materializes skills for Claude so workspace preparation can write .claude/skills", () => { + const logs: string[] = []; + const result = buildRunPlan( + { + harness: "claude", + sandbox: "daytona", + prompt: "hello", + skills: [ + { name: "alpha", description: "Alpha skill.", body: "Do alpha." }, + { name: "beta", description: "Beta skill.", body: "Do beta." }, + ], + } as AgentRunRequest, + { + createDaytonaCwd: () => "/home/sandbox/agenta-fixed", + resolveSkillDirs: (_skills, log) => { + (log ?? (() => {}))("resolved claude skills"); + return { + skills: [ + { name: "alpha", dir: "/skills/alpha" }, + { name: "beta", dir: "/skills/beta" }, + ], + cleanup: () => {}, + }; + }, + log: (message) => logs.push(message), + }, + ); + + assert.equal(result.ok, true); + if (!result.ok) return; + assert.deepEqual(result.plan.skillDirs, [ + { name: "alpha", dir: "/skills/alpha" }, + { name: "beta", dir: "/skills/beta" }, + ]); + assert.deepEqual(logs, ["resolved claude skills", "skills: alpha, beta"]); + }); + + it("stays quiet for a Claude harness with no skills", () => { + // No skills on the wire: materialization is a no-op and must not warn. + const logs: string[] = []; + const result = buildRunPlan( + { harness: "claude", sandbox: "daytona", prompt: "hello" }, + { + createDaytonaCwd: () => "/home/sandbox/agenta-fixed", + log: (message) => logs.push(message), + }, + ); + + assert.equal(result.ok, true); + assert.equal( + logs.some((line) => line.startsWith("WARNING: dropping")), + false, + ); + }); + it("normalizes a Daytona Claude run without Pi-only state", () => { const result = buildRunPlan( { @@ -90,13 +325,11 @@ describe("buildRunPlan", () => { sandbox: "daytona", prompt: "hello", secrets: { ANTHROPIC_API_KEY: "anthropic" }, + credentialMode: "env", systemPrompt: "ignored for non-pi", }, { createDaytonaCwd: () => "/home/sandbox/agenta-fixed", - resolveSkillDirs: () => { - throw new Error("non-Pi should not resolve skills"); - }, }, ); @@ -107,10 +340,64 @@ describe("buildRunPlan", () => { assert.equal(result.plan.isDaytona, true); assert.equal(result.plan.cwd, "/home/sandbox/agenta-fixed"); assert.equal(result.plan.usageOutPath, undefined); - assert.equal(result.plan.harnessKeyVar, "ANTHROPIC_API_KEY"); + assert.equal(result.plan.legacyHarnessApiKeyVar, "ANTHROPIC_API_KEY"); assert.equal(result.plan.hasApiKey, true); + // The resolved credentialMode is carried onto the plan (drives clear-then-apply + the + // OAuth-upload gate). + assert.equal(result.plan.credentialMode, "env"); assert.equal(result.plan.systemPrompt, undefined); assert.equal(result.plan.hasSystemPrompt, false); assert.deepEqual(result.plan.skillDirs, []); }); }); + +describe("shouldUploadOwnLogin", () => { + it("never uploads when the connection resolved a real key (credentialMode 'env')", () => { + // A resolved key is the credential (Security rule 6); the fallback auth.json must not load, + // even if hasApiKey somehow disagrees. + assert.equal( + shouldUploadOwnLogin({ credentialMode: "env", hasApiKey: true }), + false, + ); + assert.equal( + shouldUploadOwnLogin({ credentialMode: "env", hasApiKey: false }), + false, + ); + }); + + it("uploads for runtime_provided (the harness authenticates with its own login)", () => { + assert.equal( + shouldUploadOwnLogin({ + credentialMode: "runtime_provided", + hasApiKey: false, + }), + true, + ); + assert.equal( + shouldUploadOwnLogin({ + credentialMode: "runtime_provided", + hasApiKey: true, + }), + true, + ); + }); + + it("never uploads for credentialMode 'none' (no credential asserted)", () => { + assert.equal( + shouldUploadOwnLogin({ credentialMode: "none", hasApiKey: false }), + false, + ); + }); + + it("falls back to the hasApiKey heuristic for an un-migrated caller (no credentialMode)", () => { + // No credentialMode on the wire: upload only when no api key was supplied (today's behavior). + assert.equal( + shouldUploadOwnLogin({ credentialMode: undefined, hasApiKey: false }), + true, + ); + assert.equal( + shouldUploadOwnLogin({ credentialMode: undefined, hasApiKey: true }), + false, + ); + }); +}); diff --git a/services/agent/tests/unit/sandbox-agent-workspace.test.ts b/services/agent/tests/unit/sandbox-agent-workspace.test.ts index 91944127f9..25a95ea1ad 100644 --- a/services/agent/tests/unit/sandbox-agent-workspace.test.ts +++ b/services/agent/tests/unit/sandbox-agent-workspace.test.ts @@ -5,7 +5,7 @@ */ import { afterEach, describe, it } from "vitest"; import assert from "node:assert/strict"; -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -35,16 +35,80 @@ describe("prepareWorkspace", () => { relayDir: join(cwd, ".agenta-tools"), useToolRelay: true, agentsMd: "agent instructions", + acpAgent: "pi", + isPi: true, + skillDirs: [], }, }); assert.equal(existsSync(join(cwd, ".agenta-tools")), true); assert.equal(readFileSync(join(cwd, "AGENTS.md"), "utf-8"), "agent instructions"); + // A Pi run never gets a Claude settings file. + assert.equal(existsSync(join(cwd, ".claude", "settings.json")), false); await workspace.cleanup(); assert.equal(existsSync(cwd), false); }); + it("writes a nested harnessFiles entry (.claude/settings.json) for a local run", async () => { + const cwd = tempDir(); + // The Python harness adapter already rendered the file; the runner just writes it blind, + // creating the parent dir for the nested path. + const content = JSON.stringify( + { + permissions: { + defaultMode: "acceptEdits", + allow: ["Read"], + deny: ["WebFetch", "WebSearch"], + }, + }, + null, + 2, + ); + + const workspace = await prepareWorkspace({ + sandbox: {}, + plan: { + isDaytona: false, + cwd, + relayDir: join(cwd, ".agenta-tools"), + useToolRelay: false, + agentsMd: "agent instructions", + acpAgent: "claude", + isPi: false, + harnessFiles: [{ path: ".claude/settings.json", content }], + skillDirs: [], + }, + }); + + const settingsPath = join(cwd, ".claude", "settings.json"); + assert.equal(existsSync(settingsPath), true); + // The runner writes the content verbatim (no re-serialization). + assert.equal(readFileSync(settingsPath, "utf-8"), content); + + await workspace.cleanup(); + }); + + it("writes no harness file for a plan with no harnessFiles", async () => { + const cwd = tempDir(); + + await prepareWorkspace({ + sandbox: {}, + plan: { + isDaytona: false, + cwd, + relayDir: join(cwd, ".agenta-tools"), + useToolRelay: false, + agentsMd: "agent instructions", + acpAgent: "claude", + isPi: false, + skillDirs: [], + }, + }); + + assert.equal(existsSync(join(cwd, ".claude", "settings.json")), false); + }); + it("prepares a Daytona cwd through the sandbox fs API", async () => { const calls: Array<{ op: "mkdir" | "write"; path: string; body?: string }> = []; const sandbox = { @@ -61,6 +125,9 @@ describe("prepareWorkspace", () => { relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", useToolRelay: true, agentsMd: "agent instructions", + acpAgent: "pi", + isPi: true, + skillDirs: [], }, }); await workspace.cleanup(); @@ -75,4 +142,133 @@ describe("prepareWorkspace", () => { }, ]); }); + + it("writes a nested harnessFiles entry on Daytona via the fs API", async () => { + const calls: Array<{ op: "mkdir" | "write"; path: string; body?: string }> = []; + const sandbox = { + mkdirFs: async ({ path }: { path: string }) => calls.push({ op: "mkdir", path }), + writeFsFile: async ({ path }: { path: string }, body: string) => + calls.push({ op: "write", path, body }), + }; + const content = JSON.stringify({ permissions: { deny: ["Bash"] } }, null, 2); + + await prepareWorkspace({ + sandbox, + plan: { + isDaytona: true, + cwd: "/home/sandbox/agenta-fixed", + relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", + useToolRelay: false, + agentsMd: "agent instructions", + acpAgent: "claude", + isPi: false, + harnessFiles: [{ path: ".claude/settings.json", content }], + skillDirs: [], + }, + }); + + // The parent dir of the nested path is created via the fs API. + const claudeDir = calls.find( + (c) => c.op === "mkdir" && c.path === "/home/sandbox/agenta-fixed/.claude", + ); + assert.ok(claudeDir, ".claude dir is created via the fs API"); + const write = calls.find( + (c) => + c.op === "write" && + c.path === "/home/sandbox/agenta-fixed/.claude/settings.json", + ); + assert.ok(write, "settings.json is written via the fs API"); + // Written verbatim (the runner does not re-serialize harness-rendered content). + assert.equal(write!.body, content); + }); + + it("writes no harness file on Daytona for a plan with no harnessFiles", async () => { + const calls: Array<{ op: "mkdir" | "write"; path: string; body?: string }> = []; + const sandbox = { + mkdirFs: async ({ path }: { path: string }) => calls.push({ op: "mkdir", path }), + writeFsFile: async ({ path }: { path: string }, body: string) => + calls.push({ op: "write", path, body }), + }; + + await prepareWorkspace({ + sandbox, + plan: { + isDaytona: true, + cwd: "/home/sandbox/agenta-fixed", + relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", + useToolRelay: false, + agentsMd: "agent instructions", + acpAgent: "pi", + isPi: true, + skillDirs: [], + }, + }); + + assert.ok( + !calls.some((c) => c.path.includes(".claude")), + "no .claude path is touched", + ); + }); + + it("writes Claude skills into the project-local .claude/skills tree for a local run", async () => { + const cwd = tempDir(); + const skillDir = tempDir(); + const skillFile = join(skillDir, "SKILL.md"); + writeFileSync(skillFile, "---\nname: release-notes\n---\n", "utf-8"); + + await prepareWorkspace({ + sandbox: {}, + plan: { + isDaytona: false, + cwd, + relayDir: join(cwd, ".agenta-tools"), + useToolRelay: false, + acpAgent: "claude", + isPi: false, + skillDirs: [{ name: "release-notes", dir: skillDir }], + }, + }); + + assert.equal( + readFileSync( + join(cwd, ".claude", "skills", "release-notes", "SKILL.md"), + "utf-8", + ), + "---\nname: release-notes\n---\n", + ); + }); + + it("uploads Claude skills into the project-local .claude/skills tree on Daytona", async () => { + const calls: Array<{ op: "mkdir" | "write"; path: string; body?: string }> = []; + const skillDir = tempDir(); + writeFileSync(join(skillDir, "SKILL.md"), "skill", "utf-8"); + const sandbox = { + mkdirFs: async ({ path }: { path: string }) => calls.push({ op: "mkdir", path }), + writeFsFile: async ({ path }: { path: string }, body: string) => + calls.push({ op: "write", path, body }), + }; + + await prepareWorkspace({ + sandbox, + plan: { + isDaytona: true, + cwd: "/home/sandbox/agenta-fixed", + relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", + useToolRelay: false, + acpAgent: "claude", + isPi: false, + skillDirs: [{ name: "release-notes", dir: skillDir }], + }, + }); + + assert.ok( + calls.some( + (c) => + c.op === "write" && + c.path === + "/home/sandbox/agenta-fixed/.claude/skills/release-notes/SKILL.md", + ), + "SKILL.md is uploaded to Claude's project-local skill tree", + ); + }); }); diff --git a/services/agent/tests/unit/skills.test.ts b/services/agent/tests/unit/skills.test.ts index a5ddee6129..5ce6fd35ad 100644 --- a/services/agent/tests/unit/skills.test.ts +++ b/services/agent/tests/unit/skills.test.ts @@ -1,65 +1,241 @@ /** - * Unit tests for bundled-skill resolution (`engines/skills.ts`), the shared helper both - * engines use to turn the Agenta harness's forced skill *names* into directories on disk. + * Unit tests for skill materialization (`engines/skills.ts`), the shared helper both engines + * use to turn resolved inline skill packages (`WireSkill[]`) into directories on disk. * - * No harness, no network: just disk resolution against a temp SKILLS_ROOT and absolute paths. + * No harness, no network: just disk materialization of inline packages into a per-run temp + * root, plus the executable-file gating (default deny). * * Run: pnpm test (or: pnpm exec vitest run tests/unit/skills.test.ts) */ -import { afterAll, describe, it } from "vitest"; +import { afterEach, describe, it } from "vitest"; import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { existsSync, readFileSync, rmSync, statSync } from "node:fs"; import { join } from "node:path"; -// A throwaway skills root with one real skill dir and one bare file (not a skill dir). -const root = mkdtempSync(join(tmpdir(), "agenta-skills-test-")); -mkdirSync(join(root, "alpha")); -writeFileSync(join(root, "alpha", "SKILL.md"), "---\nname: alpha\n---\n"); -writeFileSync(join(root, "loose.md"), "not a dir"); - -// skills.ts reads AGENTA_AGENT_SKILLS_DIR at import time, so set it before importing. -const prevSkillsDir = process.env.AGENTA_AGENT_SKILLS_DIR; -process.env.AGENTA_AGENT_SKILLS_DIR = root; -const { resolveSkillDirs, SKILLS_ROOT } = await import("../../src/engines/skills.ts"); - -afterAll(() => { - // Restore the env var so this file does not leak it to others sharing the worker. - if (prevSkillsDir === undefined) delete process.env.AGENTA_AGENT_SKILLS_DIR; - else process.env.AGENTA_AGENT_SKILLS_DIR = prevSkillsDir; - rmSync(root, { recursive: true, force: true }); +import type { WireSkill } from "../../src/protocol.ts"; +import { + type MaterializedSkill, + type SkillExecPolicy, + resolveSkillDirs, +} from "../../src/engines/skills.ts"; + +const cleanups: Array<() => void> = []; + +afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup(); }); -describe("resolveSkillDirs", () => { - it("SKILLS_ROOT honors the override", () => { - assert.equal(SKILLS_ROOT, root, "SKILLS_ROOT reads AGENTA_AGENT_SKILLS_DIR"); +function materialize( + skills: WireSkill[], + log: (message: string) => void = () => {}, + execPolicy: SkillExecPolicy = "deny", +): MaterializedSkill[] { + const out = resolveSkillDirs(skills, log, execPolicy); + // Always track the materializer's own cleanup handle so the per-run root is removed. + cleanups.push(out.cleanup); + return out.skills; +} + +const SKILL: WireSkill = { + name: "release-notes", + description: "Draft release notes from a changelog.", + body: "Read the changelog, then write release notes.", +}; + +describe("resolveSkillDirs (materializer)", () => { + it("writes SKILL.md with composed frontmatter and the body", () => { + const [skill] = materialize([SKILL]); + assert.equal(skill.name, "release-notes"); + const md = readFileSync(join(skill.dir, "SKILL.md"), "utf-8"); + assert.match( + md, + /^---\nname: "release-notes"\ndescription: "Draft release notes from a changelog\."\n---\n/, + ); + assert.match(md, /Read the changelog, then write release notes\./); }); - it("resolves a known name to its directory under the root", () => { - assert.deepEqual(resolveSkillDirs(["alpha"]), [join(root, "alpha")]); + it("emits disable-model-invocation in the frontmatter only when set", () => { + const [plain] = materialize([SKILL]); + assert.doesNotMatch( + readFileSync(join(plain.dir, "SKILL.md"), "utf-8"), + /disable-model-invocation/, + ); + const [hidden] = materialize([{ ...SKILL, disableModelInvocation: true }]); + assert.match( + readFileSync(join(hidden.dir, "SKILL.md"), "utf-8"), + /disable-model-invocation: true/, + ); }); - it("skips unknown names and non-directories, logging each", () => { + it("lays bundled files at their relative paths", () => { + const [skill] = materialize([ + { + ...SKILL, + files: [ + { path: "scripts/draft.py", content: "print('draft')" }, + { path: "references/notes.md", content: "# notes" }, + ], + }, + ]); + assert.equal( + readFileSync(join(skill.dir, "scripts/draft.py"), "utf-8"), + "print('draft')", + ); + assert.equal( + readFileSync(join(skill.dir, "references/notes.md"), "utf-8"), + "# notes", + ); + }); + + it("does NOT chmod +x an executable file when policy is deny (default)", () => { + const [skill] = materialize([ + { + ...SKILL, + allowExecutableFiles: true, + files: [ + { path: "scripts/run.sh", content: "echo hi", executable: true }, + ], + }, + ]); + const mode = statSync(join(skill.dir, "scripts/run.sh")).mode & 0o111; + assert.equal(mode, 0, "no execute bits without an allowing policy"); + }); + + it("does NOT chmod +x when the skill disallows executable files, even with an allow policy", () => { const logs: string[] = []; - assert.deepEqual(resolveSkillDirs(["nope", "loose.md"], (m) => logs.push(m)), []); - assert.equal(logs.length, 2, "one log line per skipped entry"); - assert.ok( - logs.every((m) => /skipping/.test(m)), - "skips are surfaced through the logger", + const [skill] = materialize( + [ + { + ...SKILL, + allowExecutableFiles: false, + files: [ + { path: "scripts/run.sh", content: "echo hi", executable: true }, + ], + }, + ], + (m: string) => logs.push(m), + "allow", ); + const mode = statSync(join(skill.dir, "scripts/run.sh")).mode & 0o111; + assert.equal(mode, 0, "skill opt-out wins even when policy allows"); + assert.ok(logs.some((m) => /not made executable/.test(m))); }); - it("honors absolute paths as-is (the in-process loader path)", () => { - assert.deepEqual(resolveSkillDirs([join(root, "alpha")]), [join(root, "alpha")]); + it("chmod +x ONLY when the skill allows AND the policy allows", () => { + const [skill] = materialize( + [ + { + ...SKILL, + allowExecutableFiles: true, + files: [ + { path: "scripts/run.sh", content: "echo hi", executable: true }, + ], + }, + ], + () => {}, + "allow", + ); + const mode = statSync(join(skill.dir, "scripts/run.sh")).mode & 0o111; + assert.notEqual(mode, 0, "execute bits set when both gates open"); + }); + + it("skips an unsafe file path (absolute / parent escape) but keeps the skill", () => { + const logs: string[] = []; + const [skill] = materialize( + [ + { + ...SKILL, + files: [ + { path: "../escape.py", content: "x" }, + { path: "/etc/passwd", content: "x" }, + { path: "scripts/ok.py", content: "ok" }, + ], + }, + ], + (m: string) => logs.push(m), + ); + assert.equal(readFileSync(join(skill.dir, "scripts/ok.py"), "utf-8"), "ok"); + assert.equal(existsSync(join(skill.dir, "escape.py")), false); + assert.equal(logs.filter((m) => /unsafe skill file/.test(m)).length, 2); + }); + + it("materializes multiple skills into separate dirs under one root", () => { + const out = materialize([SKILL, { ...SKILL, name: "other" }]); + assert.deepEqual( + out.map((s) => s.name), + ["release-notes", "other"], + ); + assert.notEqual(out[0].dir, out[1].dir); + assert.ok(existsSync(join(out[0].dir, "SKILL.md"))); + assert.ok(existsSync(join(out[1].dir, "SKILL.md"))); }); it("treats empty / undefined input as a no-op", () => { - assert.deepEqual(resolveSkillDirs(undefined), []); - assert.deepEqual(resolveSkillDirs([]), []); - assert.deepEqual(resolveSkillDirs([""]), [], "blank names are dropped"); + assert.deepEqual(resolveSkillDirs(undefined).skills, []); + assert.deepEqual(resolveSkillDirs([]).skills, []); + }); + + it("rejects a skill whose name would traverse out of the root (untrusted wire)", () => { + const logs: string[] = []; + const out = materialize( + [ + { ...SKILL, name: "../escape" } as WireSkill, + { ...SKILL, name: "/etc/cron.d/x" } as WireSkill, + { ...SKILL, name: "Bad Name" } as WireSkill, + SKILL, // a valid one survives alongside the rejected ones + ], + (m: string) => logs.push(m), + ); + assert.deepEqual( + out.map((s) => s.name), + ["release-notes"], + ); + assert.equal(logs.filter((m) => /unsafe name/.test(m)).length, 3); + }); + + it("rejects a bundled file that targets SKILL.md (would clobber the composed frontmatter)", () => { + const logs: string[] = []; + const [skill] = materialize( + [ + { + ...SKILL, + files: [ + { path: "SKILL.md", content: "name: hijacked" }, + { path: "skill.md", content: "name: hijacked-too" }, // case-insensitive + { path: "scripts/ok.py", content: "ok" }, + ], + }, + ], + (m: string) => logs.push(m), + ); + // The composed frontmatter is intact, not the bundled-file content. + const md = readFileSync(join(skill.dir, "SKILL.md"), "utf-8"); + assert.match(md, /^---\nname: "release-notes"/); + assert.doesNotMatch(md, /hijacked/); + assert.equal(readFileSync(join(skill.dir, "scripts/ok.py"), "utf-8"), "ok"); + assert.equal(logs.filter((m) => /unsafe skill file/.test(m)).length, 2); + }); + + it("escapes YAML-breaking characters in the description scalar", () => { + const [skill] = materialize([ + { + ...SKILL, + description: 'Trigger: when foo: bar # baz, use "quotes" too.', + }, + ]); + const md = readFileSync(join(skill.dir, "SKILL.md"), "utf-8"); + // The description rides as a quoted (JSON-encoded) scalar, so the `:` / `#` / `"` are inert. + assert.match( + md, + /description: "Trigger: when foo: bar # baz, use \\"quotes\\" too\."/, + ); }); - it("uses a silent no-op default logger (no throw without a logger)", () => { - assert.deepEqual(resolveSkillDirs(["nope"]), [], "missing skill is skipped, not thrown"); + it("cleanup() removes the per-run temp root", () => { + const out = resolveSkillDirs([SKILL]); + const root = join(out.skills[0].dir, ".."); + assert.equal(existsSync(root), true); + out.cleanup(); + assert.equal(existsSync(root), false); }); }); diff --git a/services/agent/tests/unit/wire-contract.test.ts b/services/agent/tests/unit/wire-contract.test.ts index 0a452b2b34..1b604e4f74 100644 --- a/services/agent/tests/unit/wire-contract.test.ts +++ b/services/agent/tests/unit/wire-contract.test.ts @@ -38,6 +38,11 @@ const KNOWN_REQUEST_KEYS = [ "sessionId", "agentsMd", "model", + "provider", + "connection", + "deployment", + "endpoint", + "credentialMode", "messages", "secrets", "trace", @@ -49,11 +54,14 @@ const KNOWN_REQUEST_KEYS = [ "systemPrompt", "appendSystemPrompt", "skills", + "sandboxPermission", + "harnessFiles", ] as const; // COMPILE-TIME drift guard: every wire key must be a field of AgentRunRequest. Drop or rename // a field in protocol.ts and this assignment stops typechecking. -const _requestKeysExistOnType: readonly (keyof AgentRunRequest)[] = KNOWN_REQUEST_KEYS; +const _requestKeysExistOnType: readonly (keyof AgentRunRequest)[] = + KNOWN_REQUEST_KEYS; void _requestKeysExistOnType; describe("wire contract: requests (vs Python golden)", () => { @@ -82,10 +90,34 @@ describe("wire contract: requests (vs Python golden)", () => { // The custom-tool axes reach the runner intact. const tool = req.customTools![0]; assert.equal(tool.kind, "callback"); - assert.ok(tool.callRef && tool.callRef.length > 0, "callback tool carries its callRef"); + assert.ok( + tool.callRef && tool.callRef.length > 0, + "callback tool carries its callRef", + ); + // The Composio read-only hint reaches the runner as `readOnly`. + assert.equal(tool.readOnly, true); + // The Layer-3 permission (derived `allow` from read-only) reaches the runner. + assert.equal(tool.permission, "allow"); // Pi exposes the prompt overrides. assert.equal(req.systemPrompt, "You are Pi."); assert.equal(req.appendSystemPrompt, "Be terse."); + // The resolved inline skill package reaches the runner with its full nested shape intact: + // the frontmatter fields, the behavior flags, and each bundled file's `executable` bit. + const skill = req.skills![0]; + assert.equal(skill.name, "release-notes"); + assert.equal(skill.description, "Draft release notes from a changelog."); + assert.equal(skill.body, "Read the changelog, then write release notes."); + assert.equal(skill.disableModelInvocation, true); + assert.equal(skill.allowExecutableFiles, true); + assert.equal(skill.files![0].path, "scripts/draft.py"); + assert.equal(skill.files![0].content, "print('draft')"); + assert.equal(skill.files![0].executable, true); + // The declared sandbox boundary reaches the runner as nested camelCase `sandboxPermission`. + assert.equal(req.sandboxPermission!.network!.mode, "off"); + assert.deepEqual(req.sandboxPermission!.network!.allowlist, []); + assert.equal(req.sandboxPermission!.enforcement, "strict"); + // Pi renders no harness config files, so the generic `harnessFiles` is absent. + assert.equal(req.harnessFiles, undefined); }); it("claude request: gates tool use, no prompt overrides, null session id", () => { @@ -96,8 +128,23 @@ describe("wire contract: requests (vs Python golden)", () => { assert.equal(req.permissionPolicy, "deny"); // Claude gates tool use assert.equal(req.systemPrompt, undefined); // Claude exposes no prompt overrides assert.equal(req.appendSystemPrompt, undefined); + assert.equal(req.sandboxPermission, undefined); // no boundary declared on this config + // The Claude harness's permission knobs are translated to a rendered file in Python: the + // wire carries a generic `harnessFiles` entry the runner writes blind into the cwd. + const files = req.harnessFiles!; + assert.equal(files.length, 1); + assert.equal(files[0].path, ".claude/settings.json"); + const settings = JSON.parse(files[0].content) as { + permissions: Record; + }; + assert.equal(settings.permissions.defaultMode, "acceptEdits"); + assert.deepEqual(settings.permissions.allow, ["Read", "Bash(npm run:*)"]); + assert.deepEqual(settings.permissions.deny, ["WebFetch"]); // sessionId is null on the wire, so the runner falls back to its ephemeral id. - assert.equal(resolveRunSessionId(req, "runner-ephemeral"), "runner-ephemeral"); + assert.equal( + resolveRunSessionId(req, "runner-ephemeral"), + "runner-ephemeral", + ); }); }); @@ -116,7 +163,8 @@ const CAPABILITY_KEYS = [ "streamingDeltas", "sessionLifecycle", ] as const; -const _capabilityKeysExistOnType: readonly (keyof HarnessCapabilities)[] = CAPABILITY_KEYS; +const _capabilityKeysExistOnType: readonly (keyof HarnessCapabilities)[] = + CAPABILITY_KEYS; void _capabilityKeysExistOnType; describe("wire contract: results (vs Python golden)", () => { @@ -126,12 +174,25 @@ describe("wire contract: results (vs Python golden)", () => { }; assert.equal(res.ok, true); assert.equal(res.output, "Hello!"); - assert.deepEqual(res.messages!.map((m) => m.role), ["assistant"]); + assert.deepEqual( + res.messages!.map((m) => m.role), + ["assistant"], + ); // The wire carries a trailing event with no `type`; the Python consumer drops it on // parse, so the TS contract must tolerate it (three typed events survive). - const typed = res.events!.filter((e) => typeof (e as { type?: unknown }).type === "string"); - assert.deepEqual(typed.map((e) => e.type), ["message", "usage", "done"]); - assert.deepEqual(res.usage, { input: 10, output: 5, total: 15, cost: 0.001 }); + const typed = res.events!.filter( + (e) => typeof (e as { type?: unknown }).type === "string", + ); + assert.deepEqual( + typed.map((e) => e.type), + ["message", "usage", "done"], + ); + assert.deepEqual(res.usage, { + input: 10, + output: 5, + total: 15, + cost: 0.001, + }); assert.equal(res.stopReason, "end_turn"); assert.equal(res.sessionId, "sess-42"); assert.equal(res.model, "gpt-5.5"); diff --git a/services/oss/src/agent/client.py b/services/oss/src/agent/client.py deleted file mode 100644 index 59ec7969b4..0000000000 --- a/services/oss/src/agent/client.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Access to the Agenta backend from inside a harness run. - -Resolving the backend base URL and the caller-scoped credential is shared by the tool -resolver and the secret resolver, so it lives here. The credential reuses the same -propagation the OTLP export rides on, so an agent run calls ``/tools/resolve``, -``/tools/call``, and ``/secrets/`` as the caller, not with broader rights. -""" - -import os -from typing import Optional - -import agenta as ag -from agenta.sdk.engines.tracing.propagation import inject - -# Budget for a backend round-trip (the tool catalog/connection check, the vault fetch). -TOOLS_TIMEOUT = float(os.getenv("AGENTA_AGENT_TOOLS_TIMEOUT", "30")) - - -def agenta_api_base() -> Optional[str]: - """Resolve the Agenta backend base URL (``.../api``). - - Prefers an explicit override, then derives it from the OTLP endpoint the SDK is - configured with (``{host}/api/otlp/v1/traces``), then falls back to env. Returns - ``None`` when nothing is configured; callers only need this when tools or secrets apply. - """ - override = os.getenv("AGENTA_AGENT_TOOLS_API_URL") - if override: - return override.rstrip("/") - - try: - otlp_url = ag.tracing.otlp_url - except Exception: # pylint: disable=broad-except - otlp_url = None - if otlp_url and "/otlp/" in otlp_url: - return otlp_url.split("/otlp/", 1)[0].rstrip("/") - - api_url = os.getenv("AGENTA_API_URL") - if api_url: - return api_url.rstrip("/") - - return None - - -def request_authorization() -> Optional[str]: - """The project-scoped credential to call the Agenta backend. - - Reuses the same propagation the OTLP credential rides on (the caller's Authorization), - falling back to the service's own API key the way the tracing sidecar does. Scoping to - the caller keeps an agent run from invoking tools the user could not (WP-7 risk: - RUN_TOOLS scoping). - """ - try: - authorization = inject({}).get("Authorization") - except Exception: # pylint: disable=broad-except - authorization = None - if authorization: - return authorization - - api_key = os.getenv("AGENTA_API_KEY") - if api_key: - return f"ApiKey {api_key}" - - return None diff --git a/services/oss/src/agent/schemas.py b/services/oss/src/agent/schemas.py index 7047734a01..62d0c0addf 100644 --- a/services/oss/src/agent/schemas.py +++ b/services/oss/src/agent/schemas.py @@ -42,6 +42,14 @@ # The catalog type keeps the typed tools/mcp_servers shape in one place; this schema only # carries the default that the playground pre-fills. The agent handler reads it from # `parameters.agent` in app.py. +# Reserved slug of the platform default skill, served from code by the PlatformWorkflowCatalog +# (api/oss/src/core/workflows/platform_catalog.py), never the database. The default config +# references it by stable slug through an @ag.embed; the embed resolver inlines the catalogue's +# SkillConfig (at the canonical parameters.skill selector) before the runner sees it. The +# `_agenta.` prefix is reserved: a user cannot author or shadow it. This replaces both +# AGENTA_FORCED_SKILLS and the old per-project skill seeder. +_DEFAULT_SKILL_SLUG = "_agenta.agenta-getting-started" + _DEFAULT_AGENT_CONFIG = { "agents_md": _DEFAULT_AGENTS_MD, "model": _DEFAULT_MODEL, @@ -50,6 +58,25 @@ "harness": "pi", "sandbox": "local", "permission_policy": "auto", + # The declared sandbox boundary the playground pre-fills (Layer 2). Network egress on by + # default; the runner does not enforce it yet (plumbing-only slice). + "sandbox_permission": { + "network": {"mode": "on", "allowlist": []}, + "enforcement": "strict", + }, + "skills": [ + { + "@ag.embed": { + # Reference the skill at the ARTIFACT level (resolves to its latest revision). + # A `workflow_revision` slug matches the revision's own hash slug, not the + # author-facing artifact slug, so a bare revision slug with no version 500s; + # `workflow.slug` is the correct "use the latest" shape. Pin a version with + # `{"workflow_revision": {"slug": , "version": "v3"}}`. + "@ag.references": {"workflow": {"slug": _DEFAULT_SKILL_SLUG}}, + "@ag.selector": {"path": "parameters.skill"}, + } + } + ], } AGENT_CONFIG_SCHEMA = { diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SkillConfigControl.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SkillConfigControl.tsx new file mode 100644 index 0000000000..fcce262a6d --- /dev/null +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SkillConfigControl.tsx @@ -0,0 +1,247 @@ +/** + * SkillConfigControl + * + * Schema-driven control for one declared skill on the agent config. Skills are a sibling of + * `tools` and `mcp_servers`: each entry is either an inline SKILL.md package (`name`, + * `description`, `body`, optional bundled `files`, and the two behavior flags) or an + * `@ag.embed` reference to a stored skill the backend inlines into that same shape before the + * runner sees it. The shape is open enough that a JSON editor is the pragmatic v1 — the same + * approach McpServerItemControl takes for MCP servers — with a name header and a delete control. + * The typed shape lives in the `skill_config` catalog type (SkillConfigSchema in the SDK); this + * control just edits one entry of the `skills` array. + * + * The full inline-authoring form (separate fields per file, an upload affordance) is out of + * scope; the JSON editor keeps both inline skills and `@ag.embed` references editable and, more + * importantly, preserves an `@ag.embed` object intact on round-trip (it parses and re-serializes + * the object as-is, so the embed markers survive). + */ +import {memo, useCallback, useEffect, useRef, useState} from "react" + +import {isPlainObject, safeStringify} from "@agenta/shared/utils" +import {useDrillInUI} from "@agenta/ui/drill-in" +import {MinusCircle} from "@phosphor-icons/react" +import {Button, Tag, Tooltip, Typography} from "antd" +import clsx from "clsx" + +export interface SkillConfigControlProps { + /** Skill value (object or JSON string). An inline package or an `@ag.embed` reference. */ + value: unknown + /** Called when the skill value changes (only on valid JSON) */ + onChange?: (value: Record) => void + /** Called when the skill should be removed */ + onDelete?: () => void + /** Whether the control is read-only */ + disabled?: boolean + /** Additional CSS classes */ + className?: string +} + +function toSkillObj(value: unknown): Record { + try { + if (typeof value === "string") { + const parsed = value ? JSON.parse(value) : {} + return isPlainObject(parsed) ? parsed : {} + } + if (isPlainObject(value)) return value + } catch { + // fall through to empty object + } + return {} +} + +/** An `@ag.embed` reference entry carries the embed marker at its top level. */ +export function isEmbedRef(skill: Record): boolean { + return "@ag.embed" in skill +} + +/** The reserved slug namespace for platform-owned skills (mirrors the backend `_agenta.*`). */ +const PLATFORM_SLUG_PREFIX = "_agenta." + +function asObj(value: unknown): Record | undefined { + return isPlainObject(value) ? value : undefined +} + +/** + * The slug an embed entry points at, read from either a `workflow` or a pinned + * `workflow_revision` reference under `@ag.embed > @ag.references`. Returns `undefined` for an + * inline (non-embed) entry or an embed without a slug. + */ +export function platformEmbedSlug(skill: Record): string | undefined { + const refs = asObj(asObj(skill["@ag.embed"])?.["@ag.references"]) + if (!refs) return undefined + const workflowSlug = asObj(refs.workflow)?.slug + const revisionSlug = asObj(refs.workflow_revision)?.slug + const slug = workflowSlug ?? revisionSlug + return typeof slug === "string" ? slug : undefined +} + +/** A pinned revision's version, when the embed references a `workflow_revision`. */ +function embedRevisionVersion(skill: Record): string | undefined { + const refs = asObj(asObj(skill["@ag.embed"])?.["@ag.references"]) + const version = asObj(refs?.workflow_revision)?.version + return typeof version === "string" ? version : undefined +} + +/** + * Whether a skill entry is platform-owned and so read-only for the author. The reliable + * client-side signal is the reserved `_agenta.` slug prefix on the embed's referenced workflow (or + * pinned workflow_revision); a resolved object carrying `flags.is_platform === true` counts too. + */ +export function isPlatformSkill(skill: Record): boolean { + const slug = platformEmbedSlug(skill) + if (slug && slug.startsWith(PLATFORM_SLUG_PREFIX)) return true + return asObj(skill.flags)?.is_platform === true +} + +/** + * Parse the editor's JSON text into a skill entry, or `null` when it does not parse to a plain + * object (kept invalid in the editor, not propagated). Re-serializes the object as-is, so an + * `@ag.embed` reference — including its `@ag.references` / `@ag.selector` markers — survives the + * round-trip intact. Extracted so the preservation guarantee is unit-testable without a React + * harness. + */ +export function parseSkillEditorText(text: string): Record | null { + try { + const parsed = text ? JSON.parse(text) : {} + return isPlainObject(parsed) ? parsed : null + } catch { + return null + } +} + +/** Header label for a skill entry: its `name`, or a generic label for an embed/empty entry. */ +function skillLabel(skill: Record): string { + if (typeof skill.name === "string" && skill.name) return skill.name + if (isEmbedRef(skill)) return "Skill reference" + return "Skill" +} + +export const SkillConfigControl = memo(function SkillConfigControl({ + value, + onChange, + onDelete, + disabled = false, + className, +}: SkillConfigControlProps) { + const {SharedEditor} = useDrillInUI() + const skillObj = toSkillObj(value) + const name = skillLabel(skillObj) + const embed = isEmbedRef(skillObj) + const platform = isPlatformSkill(skillObj) + + const [editorText, setEditorText] = useState(() => safeStringify(skillObj ?? {})) + + // Reset the editor text when the value changes from outside (add/remove/reorder). + const lastExternalRef = useRef(safeStringify(skillObj ?? {})) + useEffect(() => { + const next = safeStringify(toSkillObj(value) ?? {}) + if (next !== lastExternalRef.current) { + lastExternalRef.current = next + setEditorText(next) + } + }, [value]) + + const handleEditorChange = useCallback( + (text: string) => { + if (disabled) return + setEditorText(text) + // Round-trips the object as-is, so an `@ag.embed` reference is preserved intact. + // Invalid / non-object text stays in the editor and is not propagated. + const parsed = parseSkillEditorText(text) + if (parsed === null) return + lastExternalRef.current = safeStringify(parsed) + onChange?.(parsed) + }, + [disabled, onChange], + ) + + const header = ( +
+
+ + {name} + + {embed && @ag.embed} +
+ {!disabled && onDelete && ( + +
+ ) + + // A platform-owned skill is a default the author cannot edit or remove: render it read-only, + // with no JSON/body editor and no delete control (the embed and its body stay untouched). + if (platform) { + const slug = platformEmbedSlug(skillObj) + const version = embedRevisionVersion(skillObj) + return ( +
+
+ + {name} + + Platform skill + {version && {version}} +
+ {slug && ( + + {slug} + + )} + + Provided by Agenta. This skill cannot be edited or removed. + +
+ ) + } + + if (!SharedEditor) { + return ( +
+ {header} +