diff --git a/sdks/python/agenta/sdk/decorators/running.py b/sdks/python/agenta/sdk/decorators/running.py index 8d1d237cdc..74f151081b 100644 --- a/sdks/python/agenta/sdk/decorators/running.py +++ b/sdks/python/agenta/sdk/decorators/running.py @@ -43,7 +43,9 @@ retrieve_handler, retrieve_interface, retrieve_configuration, + parse_uri, ) +from agenta.sdk.engines.running.handlers import remote_forward_v0 import agenta as ag @@ -51,6 +53,17 @@ log = get_module_logger(__name__) +def _is_custom_hook(uri: Optional[str]) -> bool: + """True for a custom hook URI (any provider/version), e.g. agenta:custom:hook:v0.""" + if not uri: + return False + try: + _provider, kind, key, _version = parse_uri(uri) + except Exception: + return False + return kind == "custom" and key == "hook" + + class InvokeFn(Protocol): async def __call__( self, @@ -133,6 +146,8 @@ def __init__( # revision: Optional[dict] = None, # -------------------------------------------------------------------- # + remote: bool = False, + # -------------------------------------------------------------------- # **kwargs, ): # -------------------------------------------------------------------- # @@ -194,6 +209,8 @@ def __init__( self.handler = None + self.remote = remote + self.middlewares = [ VaultMiddleware(), ResolverMiddleware(), @@ -205,22 +222,25 @@ def __init__( self.uri = _data.uri if self.uri is not None: - self._retrieve_handler(self.uri) - - if self.handler: - registered = retrieve_interface(self.uri) - if registered: - # merge registered interface into revision data, keeping caller overrides - merged = registered.model_dump(exclude_none=True) - merged.update(self.revision.data.model_dump(exclude_none=True)) - self.revision.data = WorkflowRevisionData(**merged) - self.uri = self.revision.data.uri - - registered_config = retrieve_configuration(self.uri) - if registered_config and not self.revision.data.parameters: - self.revision.data.parameters = registered_config.parameters - - self.parameters = self.revision.data.parameters + # A user custom hook must run its own installed handler (local) or + # forward to its url (remote); the URI must not resolve to a managed + # handler that would shadow the function attached by the decorator. + if not _is_custom_hook(self.uri): + self._retrieve_handler(self.uri) + + registered = retrieve_interface(self.uri) + if registered: + # merge registered interface into revision data, keeping caller overrides + merged = registered.model_dump(exclude_none=True) + merged.update(self.revision.data.model_dump(exclude_none=True)) + self.revision.data = WorkflowRevisionData(**merged) + self.uri = self.revision.data.uri + + registered_config = retrieve_configuration(self.uri) + if registered_config and not self.revision.data.parameters: + self.revision.data.parameters = registered_config.parameters + + self.parameters = self.revision.data.parameters def __call__(self, handler: Optional[Callable[..., Any]] = None) -> Workflow: if self.handler is None and handler is not None: @@ -373,6 +393,21 @@ async def invoke( ) running_ctx.parameters = self.parameters + # remote=True forwards to the workflow url; otherwise run the + # installed handler. Seeding it here lets the resolver keep the + # decorator's handler instead of re-resolving by URI. + running_ctx.handler = remote_forward_v0 if self.remote else self.handler + log.info( + "workflow handler bound", + uri=self.uri, + remote=self.remote, + handler=getattr( + running_ctx.handler, + "__name__", + type(running_ctx.handler).__name__, + ), + ) + async def terminal(req: WorkflowInvokeRequest): return None diff --git a/sdks/python/agenta/sdk/engines/running/errors.py b/sdks/python/agenta/sdk/engines/running/errors.py index 6b68465681..327eb18bfe 100644 --- a/sdks/python/agenta/sdk/engines/running/errors.py +++ b/sdks/python/agenta/sdk/engines/running/errors.py @@ -250,6 +250,21 @@ def __init__(self, message: str, stacktrace: Optional[str] = None): ) +class CustomHookHandlerNotDefinedV0Error(ErrorStatus): + code: int = 500 + type: str = f"{ERRORS_BASE_URL}#v0:workflows:custom-hook-handler-not-defined" + + def __init__(self) -> None: + super().__init__( + code=self.code, + type=self.type, + message=( + "Custom hook has no handler. Define a local handler on the workflow, " + "or set remote=True to forward to its configured url." + ), + ) + + class CustomCodeServerV0Error(ErrorStatus): code: int = 500 type: str = f"{ERRORS_BASE_URL}#v0:workflows:custom-code-server-error" diff --git a/sdks/python/agenta/sdk/engines/running/handlers.py b/sdks/python/agenta/sdk/engines/running/handlers.py index fbc515674a..5ea02b4184 100644 --- a/sdks/python/agenta/sdk/engines/running/handlers.py +++ b/sdks/python/agenta/sdk/engines/running/handlers.py @@ -41,6 +41,7 @@ from agenta.sdk.engines.running.templates import EVALUATOR_TEMPLATES from agenta.sdk.engines.running.errors import ( CustomCodeServerV0Error, + CustomHookHandlerNotDefinedV0Error, ErrorStatus, InvalidConfigurationParametersV0Error, InvalidConfigurationParameterV0Error, @@ -2223,8 +2224,15 @@ async def chat_v0( return message.model_dump(exclude_none=True) # type: ignore -@instrument(ignore_inputs=["parameters"]) -async def hook_v0( +def _extract_revision_field(value: Optional[Data], field: str) -> Optional[Any]: + if isinstance(value, dict): + data = value.get("data") if "data" in value else value + if isinstance(data, dict): + return data.get(field) + return None + + +async def remote_forward_v0( request: Optional[Data] = None, revision: Optional[Data] = None, # @@ -2235,37 +2243,21 @@ async def hook_v0( trace: Optional[Data] = None, testcase: Optional[Data] = None, ) -> Any: - """ - Webhook-based application handler for CUSTOM app types. - - Forwards the request to an external webhook URL and returns the response. - The webhook URL is read from the workflow interface (``url`` field in - revision data), not from ``parameters``. - - Args: - request: Optional canonical request envelope. - revision: Optional revision data containing the webhook URL. - parameters: Configuration parameters forwarded to the webhook. - inputs: Inputs to forward to the webhook. - outputs: Optional outputs to forward to the webhook. - trace: Optional trace data to forward to the webhook. - testcase: Optional testcase data to forward to the webhook. + """Run a workflow remotely by forwarding the request to its ``url``. - Returns: - The response from the webhook. + Selected when a workflow declares ``remote=True``. Reads ``url`` (and optional + ``headers``) from the revision data, POSTs to ``{url}/invoke``, and returns the + response. This is execution-location logic, not a per-URI handler. """ from agenta.sdk.contexts.running import RunningContext - def _extract_webhook_url(value: Optional[Data]) -> Optional[str]: - if isinstance(value, dict): - data = value.get("data") if "data" in value else value - if isinstance(data, dict): - url = data.get("url") - return str(url) if url else None - return None - ctx = RunningContext.get() - webhook_url = _extract_webhook_url(revision) or _extract_webhook_url(ctx.revision) + webhook_url = _extract_revision_field(revision, "url") or _extract_revision_field( + ctx.revision, "url" + ) + headers = _extract_revision_field(revision, "headers") or _extract_revision_field( + ctx.revision, "headers" + ) if not webhook_url: raise MissingConfigurationParameterV0Error(path="url") @@ -2280,6 +2272,12 @@ def _extract_webhook_url(value: Optional[Data]) -> Optional[str]: got=webhook_url, ) from exc + # The stored url is the service base (pre-/invoke); the invoke surface lives + # at /invoke and is always appended. + target_url = f"{webhook_url.rstrip('/')}/invoke" + + log.info("remote_forward_v0 POST", url=target_url) + json_payload = { "inputs": inputs or {}, "parameters": parameters or {}, @@ -2291,11 +2289,19 @@ def _extract_webhook_url(value: Optional[Data]) -> Optional[str]: if testcase is not None: json_payload["testcase"] = testcase + # httpx requires str->str headers; coerce values from revision data. + request_headers = ( + {str(k): str(v) for k, v in headers.items()} + if isinstance(headers, dict) + else None + ) + async with httpx.AsyncClient() as client: try: response = await client.post( - url=webhook_url, + url=target_url, json=json_payload, + headers=request_headers, timeout=httpx.Timeout(30.0, connect=5.0), ) except Exception as e: @@ -2327,6 +2333,26 @@ def _extract_webhook_url(value: Optional[Data]) -> Optional[str]: return response_bytes.decode("utf-8") +async def hook_v0( + request: Optional[Data] = None, + revision: Optional[Data] = None, + # + parameters: Optional[Data] = None, + inputs: Optional[Data] = None, + outputs: Optional[Union[Data, str]] = None, + # + trace: Optional[Data] = None, + testcase: Optional[Data] = None, +) -> Any: + """Placeholder for the custom-hook URI. Reaching it is a misconfiguration. + + A custom hook must run its own installed handler (local) or forward to its url + (``remote=True``). The URI never resolves to this function in either path, so + being here means a custom hook was invoked without a defined handler. + """ + raise CustomHookHandlerNotDefinedV0Error() + + def _resolve_reference_value(reference: Any, request: Dict[str, Any]) -> Any: """Resolve a reference that may be a JSONPath/Pointer selector or a literal value. diff --git a/sdks/python/agenta/sdk/middlewares/running/resolver.py b/sdks/python/agenta/sdk/middlewares/running/resolver.py index 556dc21b61..e702be9308 100644 --- a/sdks/python/agenta/sdk/middlewares/running/resolver.py +++ b/sdks/python/agenta/sdk/middlewares/running/resolver.py @@ -587,7 +587,11 @@ async def __call__( except Exception: raise - handler = await resolve_handler(uri=(revision.uri if revision else None)) + # Keep a handler the decorator already installed (local handler or remote + # forwarder); only resolve from the URI registry for pure URI dispatch. + handler = ctx.handler or await resolve_handler( + uri=(revision.uri if revision else None) + ) ctx.revision = ( {"data": revision.model_dump(mode="json", exclude_none=True)} diff --git a/sdks/python/oss/tests/pytest/acceptance/workflows/test_new_uri_handlers.py b/sdks/python/oss/tests/pytest/acceptance/workflows/test_new_uri_handlers.py index ae6e7bbb93..027ca2b11d 100644 --- a/sdks/python/oss/tests/pytest/acceptance/workflows/test_new_uri_handlers.py +++ b/sdks/python/oss/tests/pytest/acceptance/workflows/test_new_uri_handlers.py @@ -24,10 +24,14 @@ from agenta.sdk.contexts.running import RunningContext, running_context_manager from agenta.sdk.models.workflows import WorkflowRevisionData -from agenta.sdk.workflows.errors import FeedbackV0Error +from agenta.sdk.workflows.errors import ( + CustomHookHandlerNotDefinedV0Error, + FeedbackV0Error, +) from agenta.sdk.workflows.handlers import ( code_v0, hook_v0, + remote_forward_v0, llm_v0, match_v0, feedback_v0, @@ -146,7 +150,7 @@ def test_hook_calls_local_server_and_returns_json(self): url = f"http://127.0.0.1:{port}/eval" try: - _hook_v0 = hook_v0.__wrapped__ + _hook_v0 = remote_forward_v0 revision = WorkflowRevisionData(uri="agenta:custom:hook:v0", url=url) ctx = RunningContext(revision=revision.model_dump(mode="json")) @@ -188,7 +192,7 @@ def log_message(self, *args): url = f"http://127.0.0.1:{port}/eval" try: - _hook_v0 = hook_v0.__wrapped__ + _hook_v0 = remote_forward_v0 revision = WorkflowRevisionData(uri="agenta:custom:hook:v0", url=url) ctx = RunningContext(revision=revision.model_dump(mode="json")) @@ -229,7 +233,7 @@ def log_message(self, *args): url = f"http://127.0.0.1:{port}/eval" try: - _hook_v0 = hook_v0.__wrapped__ + _hook_v0 = remote_forward_v0 revision = WorkflowRevisionData(uri="agenta:custom:hook:v0", url=url) ctx = RunningContext(revision=revision.model_dump(mode="json")) @@ -247,6 +251,11 @@ def log_message(self, *args): assert "testcase" in received_payload assert received_payload["testcase"] == {"correct_answer": "4"} + def test_hook_v0_stub_raises(self): + """hook_v0 is a placeholder; reaching it is a misconfiguration.""" + with pytest.raises(CustomHookHandlerNotDefinedV0Error): + run(hook_v0(inputs={"q": "x"})) + # --------------------------------------------------------------------------- # TestCodeV0Acceptance diff --git a/sdks/python/oss/tests/pytest/utils/test_hook_v0.py b/sdks/python/oss/tests/pytest/utils/test_hook_v0.py index f489fe5a04..62d80a8149 100644 --- a/sdks/python/oss/tests/pytest/utils/test_hook_v0.py +++ b/sdks/python/oss/tests/pytest/utils/test_hook_v0.py @@ -10,7 +10,7 @@ 5. Error handling — non-200 status codes, client-side network errors, oversized response. async handlers are called via asyncio.run() so no pytest-asyncio marker is needed. -The @instrument() decorator is bypassed via __wrapped__. +Forwarding now lives in remote_forward_v0 (undecorated), called directly. """ import asyncio @@ -27,9 +27,10 @@ WebhookClientV0Error, WebhookServerV0Error, ) -from agenta.sdk.workflows.handlers import hook_v0 +from agenta.sdk.workflows.handlers import remote_forward_v0 -_hook_v0 = hook_v0.__wrapped__ +# Forwarding moved from hook_v0 to remote_forward_v0 (undecorated plumbing). +_hook_v0 = remote_forward_v0 # --------------------------------------------------------------------------- diff --git a/web/AGENTS.md b/web/AGENTS.md index c1a8d1c3cb..476bbeaad4 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -389,6 +389,13 @@ const items = useMemo(() => [ ``` +### Keep in-code comments terse + +**Hard rule.** At most ONE short line per comment. No multi-line blocks narrating *why* +in prose, no restating what the code shows. Before writing any comment, ask "can this be +one line?" — if not, cut it. Longer comments only for a genuinely surprising constraint +(documented bug, race, ordering requirement), and even then a sentence or two max. + ## Packages, entities, and code placement The `@agenta/*` workspace packages share UI, state, and utilities across OSS and EE. The diff --git a/web/packages/agenta-entities/src/workflow/api/api.ts b/web/packages/agenta-entities/src/workflow/api/api.ts index b3f25b6a80..44ee390dae 100644 --- a/web/packages/agenta-entities/src/workflow/api/api.ts +++ b/web/packages/agenta-entities/src/workflow/api/api.ts @@ -39,7 +39,7 @@ import { type WorkflowFlags, type WorkflowQueryFlags, } from "../core" -import type {WorkflowDetailParams, WorkflowListParams} from "../core" +import type {WorkflowDetailParams, WorkflowListParams, WorkflowData} from "../core" const toUnixMs = (value: string | null | undefined): number => { if (!value) return 0 @@ -683,18 +683,7 @@ export interface CreateWorkflowPayload { meta?: Record | null /** Commit message for the initial revision */ message?: string | null - data?: { - uri?: string | null - url?: string | null - headers?: Record | null - schemas?: { - parameters?: Record | null - inputs?: Record | null - outputs?: Record | null - } | null - script?: Record | null - parameters?: Record | null - } | null + data?: WorkflowData | null } /** @@ -910,18 +899,7 @@ export interface UpdateWorkflowPayload { meta?: Record | null /** Commit message for the new revision */ message?: string | null - data?: { - uri?: string | null - url?: string | null - headers?: Record | null - schemas?: { - parameters?: Record | null - inputs?: Record | null - outputs?: Record | null - } | null - script?: Record | null - parameters?: Record | null - } | null + data?: WorkflowData | null } /** diff --git a/web/packages/agenta-entities/src/workflow/core/schema.ts b/web/packages/agenta-entities/src/workflow/core/schema.ts index 61ec61d81f..ae021a8961 100644 --- a/web/packages/agenta-entities/src/workflow/core/schema.ts +++ b/web/packages/agenta-entities/src/workflow/core/schema.ts @@ -167,7 +167,7 @@ export const workflowDataSchema = z.object({ /** Script content for custom code workflows */ script: z.string().nullable().optional(), /** Runtime identifier for code-backed evaluators */ - runtime: z.string().nullable().optional(), + runtime: z.enum(["python", "typescript", "javascript"]).nullable().optional(), }) export type WorkflowData = z.infer diff --git a/web/packages/agenta-entities/src/workflow/snapshotAdapter.ts b/web/packages/agenta-entities/src/workflow/snapshotAdapter.ts index 516d341cfb..ec1b8e3689 100644 --- a/web/packages/agenta-entities/src/workflow/snapshotAdapter.ts +++ b/web/packages/agenta-entities/src/workflow/snapshotAdapter.ts @@ -32,12 +32,23 @@ import { // PATCH VALIDATION SCHEMA // ============================================================================ -/** - * Zod schema for validating Workflow draft patches. - */ -const workflowPatchSchema = z.object({ - parameters: z.record(z.string(), z.unknown()), -}) +// Shallow diff over the whole `data` object (any changed top-level key). +const workflowPatchSchema = z.record(z.string(), z.unknown()) + +// Merge a data patch over the server baseline: parameters shallow-merge, rest replace. +function mergeDataPatch( + remoteData: Workflow | null | undefined, + patch: Record, +): Record { + const baseData = (remoteData?.data ?? {}) as Record + const remoteParams = (remoteData?.data?.parameters as Record) ?? {} + const {parameters, ...rest} = patch + const merged: Record = {...baseData, ...rest} + if (parameters && typeof parameters === "object") { + merged.parameters = applyShallowPatch(remoteParams, parameters as Record) + } + return merged +} // ============================================================================ // ADAPTER IMPLEMENTATION @@ -127,27 +138,26 @@ export const workflowSnapshotAdapter: RunnableSnapshotAdapter = { } } - // Get effective current parameters from the entity (clone + draft overlay) - const entityData = store.get(workflowEntityAtomFamily(revisionId)) - const entityParams = (entityData?.data?.parameters as Record) ?? {} - - // Compare with source server data to detect actual changes. - // workflowServerDataSelectorFamily redirects local drafts to the - // source entity's live server data automatically. - const serverData = store.get(workflowServerDataSelectorFamily(revisionId)) - const serverParams = (serverData?.data?.parameters as Record) ?? {} - - // Compute shallow diff — only include top-level keys that changed - const diff = computeShallowDiff(entityParams, serverParams) - if (!diff) { + // Effective current data (clone + draft overlay) vs source server baseline. + const localData = store.get(workflowEntityAtomFamily(revisionId)) + const remoteData = store.get(workflowServerDataSelectorFamily(revisionId)) + const localRec = (localData?.data ?? {}) as Record + const remoteRec = (remoteData?.data ?? {}) as Record + + // Shallow diff over every data key, with parameters diffed at its own level. + const patch = computeShallowDiff(localRec, remoteRec) ?? {} + const paramDiff = computeShallowDiff( + (localRec.parameters as Record) ?? {}, + (remoteRec.parameters as Record) ?? {}, + ) + if (paramDiff) patch.parameters = paramDiff + else delete patch.parameters + + if (Object.keys(patch).length === 0) { return {hasDraft: false, patch: null, sourceRevisionId: revisionId} } - return { - hasDraft: true, - patch: {parameters: diff}, - sourceRevisionId: revisionId, - } + return {hasDraft: true, patch, sourceRevisionId: revisionId} }, applyDraftPatch(revisionId: string, patch: RunnableDraftPatch): boolean { @@ -160,26 +170,16 @@ export const workflowSnapshotAdapter: RunnableSnapshotAdapter = { return false } - // Empty patch means "no changes" — skip writing to avoid overwriting - // existing parameters with an empty object during draft merge. - const isEmptyPatch = - !parseResult.data.parameters || Object.keys(parseResult.data.parameters).length === 0 - - if (isEmptyPatch) { - return true - } + const patchData = parseResult.data + if (Object.keys(patchData).length === 0) return true const store = getDefaultStore() + const mergedData = mergeDataPatch( + store.get(workflowServerDataSelectorFamily(revisionId)), + patchData, + ) - // Get server parameters as merge base, then shallow-merge the patch. - // This handles both full-params patches (old format) and diff patches (new format). - const serverData = store.get(workflowServerDataSelectorFamily(revisionId)) - const serverParams = (serverData?.data?.parameters as Record) ?? {} - const mergedParams = applyShallowPatch(serverParams, parseResult.data.parameters) - - store.set(updateWorkflowDraftAtom, revisionId, { - data: {parameters: mergedParams}, - }) + store.set(updateWorkflowDraftAtom, revisionId, {data: mergedData}) return true }, @@ -221,25 +221,14 @@ export const workflowSnapshotAdapter: RunnableSnapshotAdapter = { return null } - // Only apply the draft overlay if the patch has actual parameter changes. - // An empty patch ({parameters: {}}) means "no changes from source" — the - // local clone already has the full source data, so setting an empty draft - // would overwrite the cloned parameters during merge. - const isEmptyPatch = - !parseResult.data.parameters || - Object.keys(parseResult.data.parameters).length === 0 - - if (!isEmptyPatch) { + // Skip an empty patch — the clone already holds the full source data. + if (Object.keys(parseResult.data).length > 0) { const store = getDefaultStore() - - // Get the cloned server data as merge base, then shallow-merge the patch. - const clonedData = store.get(workflowServerDataSelectorFamily(localDraftId)) - const clonedParams = (clonedData?.data?.parameters as Record) ?? {} - const mergedParams = applyShallowPatch(clonedParams, parseResult.data.parameters) - - store.set(updateWorkflowDraftAtom, localDraftId, { - data: {parameters: mergedParams}, - }) + const mergedData = mergeDataPatch( + store.get(workflowServerDataSelectorFamily(localDraftId)), + parseResult.data, + ) + store.set(updateWorkflowDraftAtom, localDraftId, {data: mergedData}) } return localDraftId diff --git a/web/packages/agenta-entities/src/workflow/state/commit.ts b/web/packages/agenta-entities/src/workflow/state/commit.ts index 50d16d6fb5..374316ebd1 100644 --- a/web/packages/agenta-entities/src/workflow/state/commit.ts +++ b/web/packages/agenta-entities/src/workflow/state/commit.ts @@ -278,9 +278,14 @@ export const commitWorkflowRevisionAtom = atom( variantId: variantId ?? undefined, name: entity.name ?? undefined, message: _commitMessage ?? undefined, + // All WorkflowData fields (backend forbids extras), with + // parameters/schemas prepared. data: { uri: entity.data.uri, url: entity.data.url, + headers: entity.data.headers, + script: entity.data.script, + runtime: entity.data.runtime, parameters: prepareCommitParameters(entity, flatParams), schemas: prepareCommitSchemas(entity, flatSchemas), }, @@ -451,7 +456,7 @@ export const createWorkflowVariantAtom = atom( }, }) - // 4. Commit actual data revision (v1) with full parameters + // 4. Commit actual data revision (v1) with full data const newRevision = await commitWorkflowRevisionApi(projectId, { workflowId, variantId: newVariant.id, @@ -460,6 +465,9 @@ export const createWorkflowVariantAtom = atom( data: { uri: entity.data.uri, url: entity.data.url, + headers: entity.data.headers, + script: entity.data.script, + runtime: entity.data.runtime, parameters: prepareCommitParameters(entity, flatParams), schemas: prepareCommitSchemas(entity, flatSchemas), }, diff --git a/web/packages/agenta-entities/src/workflow/state/runnableSetup.ts b/web/packages/agenta-entities/src/workflow/state/runnableSetup.ts index 2038748871..61ea2dc7c3 100644 --- a/web/packages/agenta-entities/src/workflow/state/runnableSetup.ts +++ b/web/packages/agenta-entities/src/workflow/state/runnableSetup.ts @@ -603,8 +603,9 @@ export const requestPayloadAtomFamily = atomFamily((workflowId: string) => __appWorkflow: true, // Marker for buildExecutionItem to apply app-specific transforms ...(iface ? {interface: iface} : {}), data: { - inputs: {}, + revision: entity, parameters: agConfig && Object.keys(agConfig).length > 0 ? agConfig : undefined, + inputs: {}, }, references, // Pass through metadata needed by execution pipeline diff --git a/web/packages/agenta-entities/src/workflow/state/store.ts b/web/packages/agenta-entities/src/workflow/state/store.ts index 64e2f457f9..e67c1c4c2e 100644 --- a/web/packages/agenta-entities/src/workflow/state/store.ts +++ b/web/packages/agenta-entities/src/workflow/state/store.ts @@ -1737,8 +1737,10 @@ export const workflowIsDirtyAtomFamily = atomFamily((workflowId: string) => } } - // Get the effective current parameters (base entity = server/clone + draft overlay, - // without schema resolution — isDirty only compares parameters, never schemas) + // Effective current data: base entity (server/clone + draft overlay). + // isDirty diffs the whole data object (parameters, schemas, url, headers, + // script, runtime); evaluator parameters and schemas are nested to match + // the entity side before comparison. const entityData = get(workflowBaseEntityAtomFamily(workflowId)) // Get the comparison baseline — for local drafts this redirects to the @@ -1785,13 +1787,6 @@ export const workflowIsDirtyAtomFamily = atomFamily((workflowId: string) => serverParams = syncPromptInputKeysInParameters(serverParams) as typeof serverParams } - // No parameters on entity side — check for other data changes - if (!entityParams) { - if (!entityData.data) return false - const dataKeys = Object.keys(entityData.data as Record) - return dataKeys.length > 0 - } - // Recursively sort object keys for consistent comparison // This handles json_schema property order differences const sortObjectKeys = (obj: unknown): unknown => { @@ -1830,9 +1825,41 @@ export const workflowIsDirtyAtomFamily = atomFamily((workflowId: string) => return sortObjectKeys(normalized) } - // Deep compare normalized parameters using fast-deep-equal - const normalizedEntity = normalizeForComparison(entityParams) - const normalizedServer = normalizeForComparison(serverParams) + // Server schemas.parameters stays flat for evaluators while the entity + // side is nested (nestEvaluatorSchema in workflowBaseEntityAtomFamily). + // Nest the server schema too so the whole-data diff is like-for-like. + const rawServerSchemaParams = serverData.data?.schemas?.parameters as + | Record + | undefined + const serverSchemas = + rawServerSchemaParams && serverData.flags?.is_evaluator + ? { + ...serverData.data?.schemas, + parameters: nestEvaluatorSchema(rawServerSchemaParams), + } + : serverData.data?.schemas + + // Compare the whole data object (so code/hook fields register as dirty), + // keeping parameters and schemas normalized to avoid false positives. + const normalizeData = ( + data: Record | null | undefined, + normalizedParams: unknown, + normalizedSchemas: unknown, + ): unknown => { + const base = (data ?? {}) as Record + return sortObjectKeys({ + ...base, + parameters: normalizeForComparison(normalizedParams), + ...(normalizedSchemas !== undefined ? {schemas: normalizedSchemas} : {}), + }) + } + + const normalizedEntity = normalizeData( + entityData.data, + entityParams, + entityData.data?.schemas, + ) + const normalizedServer = normalizeData(serverData.data, serverParams, serverSchemas) const isDirty = !isEqual(normalizedEntity, normalizedServer) return isDirty diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/CodeConfigControl.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/CodeConfigControl.tsx new file mode 100644 index 0000000000..447c578da5 --- /dev/null +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/CodeConfigControl.tsx @@ -0,0 +1,134 @@ +/** Renders the code workflow script editor (runtime picker lives in the section header). */ + +import {memo, useCallback, useState} from "react" + +import {CollapseToggleButton} from "@agenta/ui/components/presentational" +import {EditorProvider} from "@agenta/ui/editor" +import {SharedEditor} from "@agenta/ui/shared-editor" +import {CopySimple} from "@phosphor-icons/react" +import {Button, Tooltip, Typography} from "antd" +import clsx from "clsx" + +type EditorLanguage = "python" | "javascript" | "typescript" + +// Map the runtime selection to an editor language (1:1 today; map shields +// against runtime values that don't match an editor language). +const RUNTIME_TO_LANGUAGE: Record = { + python: "python", + javascript: "javascript", + typescript: "typescript", +} + +function runtimeToLanguage(runtime: string | undefined): EditorLanguage { + return (runtime && RUNTIME_TO_LANGUAGE[runtime]) || "python" +} + +interface ScriptEditorProps { + value: string + language: EditorLanguage + onChange: (val: string) => void + disabled?: boolean +} + +/** Script editor with the tool-card chrome: line numbers, copy + collapse. */ +const ScriptEditor = memo(function ScriptEditor({ + value, + language, + onChange, + disabled, +}: ScriptEditorProps) { + const [minimized, setMinimized] = useState(false) + + const header = ( +
+ + Script + +
+ +
+
+ ) + + return ( +
+ +
+ ) +}) + +export interface CodeConfigControlProps { + /** Current code group value: {script, runtime}. */ + value: Record | null | undefined + /** Emits the full updated group object. */ + onChange: (value: Record) => void + disabled?: boolean + className?: string +} + +/** Renders the Code (script + runtime) section body. */ +export const CodeConfigControl = memo(function CodeConfigControl({ + value, + onChange, + disabled = false, + className, +}: CodeConfigControlProps) { + const group = (value ?? {}) as Record + + const patch = useCallback( + (field: string, fieldValue: unknown) => { + onChange({...group, [field]: fieldValue}) + }, + [group, onChange], + ) + + const language = runtimeToLanguage(group.runtime as string | undefined) + + return ( +
+ + patch("script", val)} + disabled={disabled} + /> + +
+ ) +}) diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/HookConfigControl.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/HookConfigControl.tsx new file mode 100644 index 0000000000..88c18aa2df --- /dev/null +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/HookConfigControl.tsx @@ -0,0 +1,142 @@ +/** Renders the hook workflow data fields (url, headers). */ + +import {memo, useCallback, useMemo} from "react" + +import {LabeledField} from "@agenta/ui/components/presentational" +import {Plus, Trash} from "@phosphor-icons/react" +import {Button, Input} from "antd" +import clsx from "clsx" + +type HeadersValue = Record + +interface HeadersControlProps { + value: HeadersValue + onChange: (next: HeadersValue) => void + disabled?: boolean +} + +/** Key/value rows for hook headers, with an Add row. */ +const HeadersControl = memo(function HeadersControl({ + value, + onChange, + disabled, +}: HeadersControlProps) { + const rows = useMemo(() => Object.entries(value ?? {}), [value]) + + const setRow = useCallback( + (index: number, key: string, val: string) => { + const next: HeadersValue = {} + rows.forEach(([k, v], i) => { + if (i === index) next[key] = val + else next[k] = v + }) + onChange(next) + }, + [rows, onChange], + ) + + const removeRow = useCallback( + (index: number) => { + const next: HeadersValue = {} + rows.forEach(([k, v], i) => { + if (i !== index) next[k] = v + }) + onChange(next) + }, + [rows, onChange], + ) + + const addRow = useCallback(() => { + // Object-keyed headers can't hold two blank keys; don't stack empties. + if (Object.prototype.hasOwnProperty.call(value ?? {}, "")) return + onChange({...value, "": ""}) + }, [value, onChange]) + + return ( + +
+ {rows.map(([key, val], index) => ( +
+ setRow(index, e.target.value, String(val ?? ""))} + /> + setRow(index, key, e.target.value)} + /> +
+ ))} + +
+
+ ) +}) + +export interface HookConfigControlProps { + /** Current hook group value: {url, headers}. */ + value: Record | null | undefined + /** Emits the full updated group object. */ + onChange: (value: Record) => void + disabled?: boolean + className?: string +} + +/** Renders the Hook (url + headers) section body. */ +export const HookConfigControl = memo(function HookConfigControl({ + value, + onChange, + disabled = false, + className, +}: HookConfigControlProps) { + const group = (value ?? {}) as Record + + const patch = useCallback( + (field: string, fieldValue: unknown) => { + onChange({...group, [field]: fieldValue}) + }, + [group, onChange], + ) + + return ( +
+ + patch("url", e.target.value)} + /> + + patch("headers", next)} + disabled={disabled} + /> +
+ ) +}) diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SchemasConfigControl.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SchemasConfigControl.tsx new file mode 100644 index 0000000000..7a02becd74 --- /dev/null +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SchemasConfigControl.tsx @@ -0,0 +1,158 @@ +/** Renders data.schemas (parameters/inputs/outputs) as collapsible JSON editors. */ + +import {memo, useCallback, useMemo, useState} from "react" + +import {CollapseToggleButton} from "@agenta/ui/components/presentational" +import {EditorProvider} from "@agenta/ui/editor" +import {SharedEditor} from "@agenta/ui/shared-editor" +import {CopySimple} from "@phosphor-icons/react" +import {Button, Tooltip, Typography} from "antd" +import clsx from "clsx" + +const SCHEMA_FIELDS = ["parameters", "inputs", "outputs"] as const +type SchemaField = (typeof SCHEMA_FIELDS)[number] + +const FIELD_LABELS: Record = { + parameters: "Parameters", + inputs: "Inputs", + outputs: "Outputs", +} + +function toJsonText(value: unknown): string { + if (value === undefined || value === null) return "{}" + try { + return JSON.stringify(value, null, 2) + } catch { + return "{}" + } +} + +interface SchemaEditorProps { + field: SchemaField + value: unknown + onChange: (next: unknown) => void + disabled?: boolean +} + +/** One JSON-schema editor with tool-card chrome, collapsed by default. */ +const SchemaEditor = memo(function SchemaEditor({ + field, + value, + onChange, + disabled, +}: SchemaEditorProps) { + const [minimized, setMinimized] = useState(true) + const text = useMemo(() => toJsonText(value), [value]) + + const handleChange = useCallback( + (raw: string) => { + try { + onChange(JSON.parse(raw)) + } catch { + // ignore invalid JSON mid-edit; keep the last valid value + } + }, + [onChange], + ) + + const header = ( +
+ + {FIELD_LABELS[field]} + +
+ +
+
+ ) + + // !min-h-0 drops EditorProvider's min-h-[70px] so collapsed cards hug their + // header (mirrors the tools list in PromptSchemaControl). + return ( + +
+ +
+
+ ) +}) + +export interface SchemasConfigControlProps { + /** The schemas object: {parameters, inputs, outputs} — each a JSON schema. */ + value: Record | null | undefined + /** Emits the full updated schemas object. */ + onChange: (value: Record) => void + disabled?: boolean + className?: string +} + +export const SchemasConfigControl = memo(function SchemasConfigControl({ + value, + onChange, + disabled = false, + className, +}: SchemasConfigControlProps) { + const schemas = (value ?? {}) as Record + + const patch = useCallback( + (field: SchemaField, fieldValue: unknown) => { + onChange({...schemas, [field]: fieldValue}) + }, + [schemas, onChange], + ) + + return ( +
+ {SCHEMA_FIELDS.map((field) => ( + patch(field, next)} + disabled={disabled} + /> + ))} +
+ ) +}) diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/index.ts b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/index.ts index 218c423d3a..294964134b 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/index.ts +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/index.ts @@ -26,6 +26,15 @@ export type {TextInputControlProps} from "./TextInputControl" export {EnumSelectControl} from "./EnumSelectControl" export type {EnumSelectControlProps} from "./EnumSelectControl" +export {HookConfigControl} from "./HookConfigControl" +export type {HookConfigControlProps} from "./HookConfigControl" + +export {CodeConfigControl} from "./CodeConfigControl" +export type {CodeConfigControlProps} from "./CodeConfigControl" + +export {SchemasConfigControl} from "./SchemasConfigControl" +export type {SchemasConfigControlProps} from "./SchemasConfigControl" + // ============================================================================ // CONTROLS WITH CONTEXT INJECTION // ============================================================================ diff --git a/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx b/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx index 0618b14bd8..f72e1dd822 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx @@ -20,6 +20,7 @@ import { getSchemaAtPath as getSchemaAtPathUtil, } from "@agenta/entities/shared" import {workflowMolecule} from "@agenta/entities/workflow" +import type {Workflow} from "@agenta/entities/workflow" import type {DataPath} from "@agenta/shared/utils" import {getOptionsFromSchema, getValueAtPath, setValueAtPath} from "@agenta/shared/utils" import {HeightCollapse} from "@agenta/ui" @@ -33,14 +34,21 @@ import {useDrillInUI} from "@agenta/ui/drill-in" import {formatLabel} from "@agenta/ui/drill-in" import {SharedEditor} from "@agenta/ui/shared-editor" import {ArrowLeft, CaretDown, CaretRight, MagicWand} from "@phosphor-icons/react" -import {Button, Popover, Tabs, Tooltip, Typography} from "antd" +import {Button, Dropdown, Popover, Tabs, Tooltip, Typography} from "antd" import clsx from "clsx" import type {Atom, WritableAtom} from "jotai" import {atom} from "jotai" import {useAtom, useAtomValue, useSetAtom} from "jotai" import yaml from "js-yaml" -import {getModelSchema, getLLMConfigValue, getLLMConfigProperties} from "../SchemaControls" +import { + getModelSchema, + getLLMConfigValue, + getLLMConfigProperties, + HookConfigControl, + CodeConfigControl, + SchemasConfigControl, +} from "../SchemaControls" import {feedbackConfigModeAtomFamily} from "../SchemaControls/FeedbackConfigurationControl" import { validateConfigAgainstSchema, @@ -287,13 +295,98 @@ function memoAtom(factory: (id: string) => Atom): (id: string) => Atom // DEFAULT ADAPTER (workflowMolecule — direct molecule access) // ============================================================================ +const RUNTIME_SELECT_OPTIONS = ["python", "typescript", "javascript"].map((value) => ({ + label: {value}, + value, +})) + +// Virtual section keys for sibling data fields; `["hook","url"]` maps to `data.url`. +const SIBLING_GROUPS = { + hook: ["url", "headers"], + code: ["script", "runtime"], + schemas: ["parameters", "inputs", "outputs"], +} as const +type SiblingGroupKey = keyof typeof SIBLING_GROUPS +const SIBLING_GROUP_KEYS = Object.keys(SIBLING_GROUPS) as SiblingGroupKey[] + +function isSiblingGroupKey(key: unknown): key is SiblingGroupKey { + return typeof key === "string" && key in SIBLING_GROUPS +} + +// custom:hook → hook group, custom:code → code group (uri = provider:kind:key:version). +function allowedSiblingGroup(uri: unknown): SiblingGroupKey | null { + if (typeof uri !== "string") return null + const [, kind, key] = uri.split(":") + if (kind !== "custom") return null + return isSiblingGroupKey(key) ? key : null +} + +// Adapter data: `parameters` + one sibling group (hook/code) as a single section. +function mergeSiblingFields( + config: Record | null, + full: {data?: Record | null} | null, +): Record | null { + const fullData = (full?.data ?? null) as Record | null + const group = allowedSiblingGroup(fullData?.uri) + const merged: Record = {parameters: (config ?? {}) as Record} + if (group && fullData) { + const SIBLING_FIELD_DEFAULTS: Record = {headers: {}, runtime: "python"} + const fields: Record = {} + for (const field of SIBLING_GROUPS[group]) { + fields[field] = fullData[field] ?? SIBLING_FIELD_DEFAULTS[field] ?? "" + } + merged[group] = fields + } + // `schemas` nests under data.schemas; shown for any workflow that has it. + const schemas = fullData?.schemas as Record | null | undefined + if (schemas && typeof schemas === "object") { + const fields: Record = {} + for (const field of SIBLING_GROUPS.schemas) { + fields[field] = schemas[field] ?? {} + } + merged.schemas = fields + } + if (!config && !group && !merged.schemas) return null + return merged +} + +/** Path targets a sibling group (top-level group key). */ +function pathTargetsSibling(path: DataPath): boolean { + return path.length > 0 && isSiblingGroupKey(path[0]) +} + +// Renderable if there are parameters OR any sibling group (hook/code/schemas), +// so sibling-only workflows aren't hidden as "No configuration needed". +function hasRenderableConfigSections(data: unknown): boolean { + if (!data || typeof data !== "object") return false + const record = data as Record + if (hasParameters(record)) return true + return SIBLING_GROUP_KEYS.some((group) => record[group] !== undefined) +} + +// Tagged `{__siblingData}` payloads route to the raw draft action (merges +// `data`, keeps `parameters`); everything else is a parameter write. +const configUpdateRouterAtom = atom( + null, + (_get, set, id: string, changes: Record) => { + const siblingData = (changes as {__siblingData?: Record}).__siblingData + if (siblingData) { + set(workflowMolecule.actions.update, id, {data: siblingData} as Partial) + return + } + set(workflowMolecule.actions.updateConfiguration, id, changes) + }, +) + /** * Build adapter backed by workflowMolecule. * * Data mapping: - * - workflowMolecule.selectors.configuration(id) → adapter's `parameters` (for UI display) - * - workflowMolecule.actions.updateConfiguration → adapter's reducers.update - * - workflowMolecule.selectors.parametersSchema(id) → adapter's agConfigSchema + * - workflowMolecule.selectors.configuration(id) → `parameters` fields (UI display) + * - sibling `data.*` fields (script/runtime/url/headers) surfaced alongside, + * read from the full resolved data and written via the raw draft action + * - workflowMolecule.actions.updateConfiguration → parameter writes + * - workflowMolecule.selectors.parametersSchema(id) → agConfigSchema */ function buildWorkflowMoleculeAdapter(): ConfigSectionMoleculeAdapter { return { @@ -301,15 +394,15 @@ function buildWorkflowMoleculeAdapter(): ConfigSectionMoleculeAdapter { data: memoAtom((id: string) => atom((get) => { const config = get(workflowMolecule.selectors.configuration(id)) - if (!config) return null - return {parameters: config as Record} + const full = get(workflowMolecule.selectors.resolvedData(id)) + return mergeSiblingFields(config as Record | null, full) }), ), serverData: memoAtom((id: string) => atom((get) => { const config = get(workflowMolecule.selectors.serverConfiguration(id)) - if (!config) return null - return {parameters: config as Record} + const full = get(workflowMolecule.selectors.data(id)) + return mergeSiblingFields(config as Record | null, full) }), ), draft: (id: string) => workflowMolecule.atoms.draft(id), @@ -335,7 +428,7 @@ function buildWorkflowMoleculeAdapter(): ConfigSectionMoleculeAdapter { ), }, reducers: { - update: workflowMolecule.actions.updateConfiguration as WritableAtom< + update: configUpdateRouterAtom as WritableAtom< unknown, [id: string, changes: Record], void @@ -343,36 +436,69 @@ function buildWorkflowMoleculeAdapter(): ConfigSectionMoleculeAdapter { discard: workflowMolecule.actions.discard, }, drillIn: { + // Flatten parameter keys + sibling fields to one level (each its own section). getRootData: (data: unknown) => { const d = data as {parameters?: Record} | null - const rootData = - d?.parameters && Object.keys(d.parameters).length > 0 ? d.parameters : d - return rootData + if (!d) return {} + const {parameters, ...siblings} = d + return {...(parameters ?? {}), ...siblings} }, getRootItems: (data: unknown) => { const d = data as {parameters?: Record} | null + const items: {key: string; name: string; value: unknown}[] = [] const params = d?.parameters - if (!params || typeof params !== "object") return [] - return Object.entries(params).map(([key, value]) => ({ - key, - name: key, - value, - })) + if (params && typeof params === "object") { + for (const [key, value] of Object.entries(params)) { + items.push({key, name: key, value}) + } + } + for (const group of SIBLING_GROUP_KEYS) { + if (d && group in d) { + items.push({key: group, name: group, value: d[group as keyof typeof d]}) + } + } + return items }, getValueAtPath: (data: unknown, path: DataPath) => { const d = data as {parameters?: Record} | null - if (!d?.parameters) return undefined + if (!d) return undefined + if (pathTargetsSibling(path)) return getValueAtPath(d, path) + if (!d.parameters) return undefined return getValueAtPath(d.parameters, path) }, getChangesFromPath: (data: unknown, path: DataPath, value: unknown) => { const d = data as {parameters?: Record} | null - const params = {...(d?.parameters ?? {})} - setValueAtPath(params, path, value) - return params + // Sibling edits emit a tagged __siblingData payload. setValueAtPath + // is immutable, so use its return value. + if (pathTargetsSibling(path)) { + const group = path[0] as SiblingGroupKey + const base = (d as Record)?.[group] ?? {} + const fields = setValueAtPath(base, path.slice(1), value) as Record< + string, + unknown + > + if (group === "schemas") { + return {__siblingData: {schemas: fields}} as Record + } + return {__siblingData: fields} as Record + } + return setValueAtPath(d?.parameters ?? {}, path, value) as Record }, - getChangesFromRoot: (_entity: unknown, rootData: unknown, _path: DataPath) => { - // rootData is the updated parameters object - return rootData as Record + // rootData flattened: a sibling group edit emits a tagged payload of the + // group's fields (paths drop the virtual group key); params emit param keys. + getChangesFromRoot: (_entity: unknown, rootData: unknown, path: DataPath) => { + const root = {...(rootData as Record)} + if (pathTargetsSibling(path)) { + const group = path[0] as SiblingGroupKey + const fields = (root[group] ?? {}) as Record + // schemas nests under data.schemas; hook/code fields sit flat on data. + if (group === "schemas") { + return {__siblingData: {schemas: {...fields}}} as Record + } + return {__siblingData: {...fields}} as Record + } + for (const group of SIBLING_GROUP_KEYS) delete root[group] + return root }, }, selectors: { @@ -385,6 +511,17 @@ function buildWorkflowMoleculeAdapter(): ConfigSectionMoleculeAdapter { } } +// Minimal object schema so the section is recognized; the hook/code body renders +// via HookCodeConfigControl, not the schema renderer, so no field schemas needed. +function siblingSchemaAtPath(path: (string | number)[]): PathSchema | null { + const group = path[0] + if (!isSiblingGroupKey(group)) return null + if (path.length === 1) { + return {type: "object", title: group} as EntitySchemaProperty + } + return null +} + /** Wrap schemaAtPath to work with the adapter's (id, path) → atom interface */ const moleculeSchemaAtPathCache = new Map>() function moleculeSchemaAtPath(params: {id: string; path: (string | number)[]}): Atom { @@ -392,6 +529,9 @@ function moleculeSchemaAtPath(params: {id: string; path: (string | number)[]}): let cached = moleculeSchemaAtPathCache.get(key) if (!cached) { cached = atom((get) => { + if (pathTargetsSibling(params.path as DataPath)) { + return siblingSchemaAtPath(params.path) + } const schema = get(workflowMolecule.selectors.parametersSchema(params.id)) if (!isEntitySchema(schema)) return null const resolved = getSchemaAtPathUtil(schema, params.path) ?? null @@ -496,6 +636,28 @@ function PlaygroundConfigSection({ const parameters = (activeData?.parameters ?? {}) as Record + // Sibling group (hook/code) surfaced as a section, if present. + const siblingGroups = useMemo(() => { + const out: Record = {} + const d = activeData as Record | null + if (d) { + for (const group of SIBLING_GROUP_KEYS) { + if (group in d) out[group] = d[group] + } + } + return out + }, [activeData]) + + const codeRuntime = (siblingGroups.code as Record | undefined)?.runtime as + | string + | undefined + const handleRuntimeChange = useCallback( + (runtime: string) => { + dispatchUpdate(revisionId, {__siblingData: {runtime}}) + }, + [dispatchUpdate, revisionId], + ) + // ========== ADAPTER ========== // Build adapter with schema support, swapping data source for useServerData const drillInAdapter = useMemo( @@ -646,6 +808,8 @@ function PlaygroundConfigSection({ // ========== COLLAPSE STATE ========== const [collapsedSections, setCollapsedSections] = useState>({ advanced_settings: true, + // Section open; individual schema cards default closed (SchemaEditor). + schemas: false, }) const toggleSection = useCallback((key: string) => { @@ -1322,14 +1486,17 @@ function PlaygroundConfigSection({ return null } - // Simple scalar fields and arrays rendered inline by SchemaPropertyRenderer - // don't need collapsible section headers — only plain objects do. - const fieldValue = parameters[fieldKey] + // Sibling group (hook/code) always gets a section header. + const isSibling = isSiblingGroupKey(fieldKey) + + // Scalar/array params render inline (no header); only objects get one. + const fieldValue = isSibling ? siblingGroups[fieldKey] : parameters[fieldKey] if ( - fieldValue === null || - fieldValue === undefined || - typeof fieldValue !== "object" || - Array.isArray(fieldValue) + !isSibling && + (fieldValue === null || + fieldValue === undefined || + typeof fieldValue !== "object" || + Array.isArray(fieldValue)) ) { return null } @@ -1362,7 +1529,11 @@ function PlaygroundConfigSection({ return (
toggleSection(fieldKey)} >
@@ -1421,6 +1592,33 @@ function PlaygroundConfigSection({
)} + {/* Code: runtime picker in section header (like the model picker). */} + {fieldKey === "code" && ( +
e.stopPropagation()} + className="flex items-center gap-2 flex-shrink-0" + > + ({ + key: o.value, + label: o.label, + })), + selectedKeys: codeRuntime ? [codeRuntime] : [], + onClick: ({key}) => handleRuntimeChange(key), + }} + > + + +
+ )} + {/* Feedback config: Advanced Mode toggle in section header */} {fieldKey === "feedback_config" && (
+
+ {fieldKey === "schemas" ? ( + } + onChange={(next) => props.onChange(next)} + disabled={disabled} + /> + ) : fieldKey === "hook" ? ( + } + onChange={(next) => props.onChange(next)} + disabled={disabled} + /> + ) : ( + } + onChange={(next) => props.onChange(next)} + disabled={disabled} + /> + )} +
+ + ) + } + + // Scalar/array params render directly, without HeightCollapse. const fieldValue = parameters[fieldKey] if ( fieldValue === null || @@ -1486,19 +1720,17 @@ function PlaygroundConfigSection({ return
{props.defaultRender()}
} - const isCollapsed = !!collapsedSections[fieldKey] - return (
{props.defaultRender()}
) }, - [collapsedSections, parameters, promptModelInfo?.isRootLevel], + [collapsedSections, parameters, siblingGroups, disabled, promptModelInfo?.isRootLevel], ) // ========== LOADING / EMPTY STATE ========== - const isConfigLoading = schemaQuery.isPending && !hasParameters(activeData) + const isConfigLoading = schemaQuery.isPending && !hasRenderableConfigSections(activeData) if (isConfigLoading) { return ( @@ -1510,7 +1742,7 @@ function PlaygroundConfigSection({ ) } - if (!hasParameters(activeData)) { + if (!hasRenderableConfigSections(activeData)) { return (
| null, - serverConfig: Record | null, + localData: Record | null, + remoteData: Record | null, version: number | undefined, isLocalDraft: boolean, ): CommitContext { const currentVersion = version ?? 0 const targetVersion = currentVersion + 1 - const normalizedCurrentConfig = - (syncPromptInputKeysInParameters(currentConfig) as Record | null) ?? - currentConfig + // Diff the whole data object; parameters keep metadata-strip + input-key sync. + const buildSide = (data: Record | null, syncParams: boolean) => { + const d = data ?? {} + const params = (d.parameters as Record | null) ?? {} + const normalizedParams = syncParams + ? ((syncPromptInputKeysInParameters(params) as Record | null) ?? + params) + : params + return {...d, parameters: stripAgentaMetadataDeep(normalizedParams)} + } - const original = stableStringify({parameters: stripAgentaMetadataDeep(serverConfig ?? {})}) - const modified = stableStringify({ - parameters: stripAgentaMetadataDeep(normalizedCurrentConfig ?? {}), - }) + const original = stableStringify(buildSide(remoteData, false)) + const modified = stableStringify(buildSide(localData, true)) const hasDiff = original !== modified const descriptions: string[] = [] @@ -105,16 +110,15 @@ function buildGenericCommitContext( const variantCommitContextAtom = (revisionId: string, _metadata?: Record) => atom((get): CommitContext | null => { const isLocalDraft = isLocalDraftId(revisionId) - const workflowData = get(workflowMolecule.selectors.data(revisionId)) - const currentConfig = get(workflowMolecule.selectors.configuration(revisionId)) - const serverConfig = get(workflowMolecule.selectors.serverConfiguration(revisionId)) + const localData = get(workflowMolecule.selectors.data(revisionId)) + const remoteData = get(workflowMolecule.selectors.serverData(revisionId)) - if (!workflowData) return null + if (!localData) return null return buildGenericCommitContext( - currentConfig, - serverConfig, - workflowData.version ?? undefined, + (localData.data as Record | null) ?? null, + (remoteData?.data as Record | null) ?? null, + localData.version ?? undefined, isLocalDraft, ) })