From eb6de4c482ab3c2b23c302734d57ea21c635d82e Mon Sep 17 00:00:00 2001 From: Juan Pablo Vega Date: Tue, 16 Jun 2026 00:26:30 +0200 Subject: [PATCH 1/8] feat(frontend): surface and persist all workflow data fields (code/hook config) Generalize the playground config pipeline from data.parameters-only to the whole data object, so code (script/runtime) and hook (url/headers) workflow fields are rendered, diffed, and committed. Covers all five layers: render, dirty detection, patch build, diff preview, and commit payload. Sibling sections are uri-gated (custom:hook -> url/headers, custom:code -> script/runtime). Reuses the canonical WorkflowData type and aligns runtime to the backend enum. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/AGENTS.md | 7 + .../agenta-entities/src/workflow/api/api.ts | 28 +-- .../src/workflow/core/schema.ts | 2 +- .../src/workflow/snapshotAdapter.ts | 109 +++++----- .../src/workflow/state/commit.ts | 10 +- .../src/workflow/state/store.ts | 22 +- .../components/PlaygroundConfigSection.tsx | 191 +++++++++++++++--- .../src/adapters/variantAdapters.ts | 36 ++-- 8 files changed, 260 insertions(+), 145 deletions(-) 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..3f30807e77 100644 --- a/web/packages/agenta-entities/src/workflow/snapshotAdapter.ts +++ b/web/packages/agenta-entities/src/workflow/snapshotAdapter.ts @@ -32,12 +32,25 @@ import { // PATCH VALIDATION SCHEMA // ============================================================================ -/** - * Zod schema for validating Workflow draft patches. - */ -const workflowPatchSchema = z.object({ - parameters: z.record(z.string(), z.unknown()), -}) +// Patch is a shallow diff over the whole `data` object (any changed top-level +// key: uri, schemas, url, headers, script, runtime, parameters). +const workflowPatchSchema = z.record(z.string(), z.unknown()) + +// Merge a data patch over a server baseline: parameters shallow-merge (nested +// diff), every other key replaces. +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 +140,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 +172,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 +223,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/store.ts b/web/packages/agenta-entities/src/workflow/state/store.ts index 64e2f457f9..c2d39bd4bc 100644 --- a/web/packages/agenta-entities/src/workflow/state/store.ts +++ b/web/packages/agenta-entities/src/workflow/state/store.ts @@ -1785,13 +1785,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 +1823,18 @@ 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) + // Compare the whole data object (so code/hook fields register as dirty), + // keeping parameters normalized to avoid false positives. + const normalizeData = ( + data: Record | null | undefined, + normalizedParams: unknown, + ): unknown => { + const base = (data ?? {}) as Record + return sortObjectKeys({...base, parameters: normalizeForComparison(normalizedParams)}) + } + + const normalizedEntity = normalizeData(entityData.data, entityParams) + const normalizedServer = normalizeData(serverData.data, serverParams) const isDirty = !isEqual(normalizedEntity, normalizedServer) return isDirty 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..20a5b77e78 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" @@ -287,13 +288,79 @@ function memoAtom(factory: (id: string) => Atom): (id: string) => Atom // DEFAULT ADAPTER (workflowMolecule — direct molecule access) // ============================================================================ +// `data` fields living beside `parameters` (code/hook config). +const SIBLING_DATA_FIELDS = ["url", "headers", "script", "runtime"] as const +type SiblingDataField = (typeof SIBLING_DATA_FIELDS)[number] + +function isSiblingDataField(key: unknown): key is SiblingDataField { + return typeof key === "string" && (SIBLING_DATA_FIELDS as readonly string[]).includes(key) +} + +// url/headers for custom:hook, script/runtime for custom:code (uri = provider:kind:key:version). +function allowedSiblingFields(uri: unknown): readonly SiblingDataField[] { + if (typeof uri !== "string") return [] + const [, kind, key] = uri.split(":") + if (kind !== "custom") return [] + if (key === "hook") return ["url", "headers"] + if (key === "code") return ["script", "runtime"] + return [] +} + +// Adapter data: `parameters` + uri-allowed sibling fields hoisted as sections. +function mergeSiblingFields( + config: Record | null, + full: {data?: Record | null} | null, +): Record | null { + const fullData = (full?.data ?? null) as Record | null + const allowed = allowedSiblingFields(fullData?.uri) + const siblings: Record = {} + if (fullData) { + for (const field of allowed) { + const value = fullData[field] + siblings[field] = value ?? (field === "headers" ? {} : "") + } + } + if (!config && Object.keys(siblings).length === 0) return null + return {parameters: (config ?? {}) as Record, ...siblings} +} + +/** Root path key is a sibling data field (top-level only). */ +function pathTargetsSibling(path: DataPath): boolean { + return path.length > 0 && isSiblingDataField(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 +368,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 +402,7 @@ function buildWorkflowMoleculeAdapter(): ConfigSectionMoleculeAdapter { ), }, reducers: { - update: workflowMolecule.actions.updateConfiguration as WritableAtom< + update: configUpdateRouterAtom as WritableAtom< unknown, [id: string, changes: Record], void @@ -343,36 +410,56 @@ 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 field of SIBLING_DATA_FIELDS) { + if (d && field in d) { + items.push({key: field, name: field, value: d[field 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 + if (pathTargetsSibling(path)) { + const next = {...(d ?? {})} + setValueAtPath(next, path, value) + return next + } const params = {...(d?.parameters ?? {})} setValueAtPath(params, path, value) return params }, - getChangesFromRoot: (_entity: unknown, rootData: unknown, _path: DataPath) => { - // rootData is the updated parameters object - return rootData as Record + // rootData is flattened: sibling edits emit a tagged payload, params emit param keys. + getChangesFromRoot: (_entity: unknown, rootData: unknown, path: DataPath) => { + const root = {...(rootData as Record)} + if (pathTargetsSibling(path)) { + const field = path[0] as SiblingDataField + return {__siblingData: {[field]: root[field]}} as Record + } + for (const field of SIBLING_DATA_FIELDS) delete root[field] + return root }, }, selectors: { @@ -385,6 +472,22 @@ function buildWorkflowMoleculeAdapter(): ConfigSectionMoleculeAdapter { } } +// Synthetic schemas to pick a control for sibling fields (absent from the params schema). +const SIBLING_FIELD_SCHEMA: Record = { + url: {type: "string", title: "URL", "x-parameter": "text"}, + headers: {type: "object", title: "Headers", additionalProperties: {type: "string"}}, + script: {type: "string", title: "Code", "x-parameter": "code"}, + runtime: {type: "string", title: "Runtime", enum: ["python", "typescript", "javascript"]}, +} as Record + +function siblingSchemaAtPath(path: (string | number)[]): PathSchema | null { + const field = path[0] + if (!isSiblingDataField(field)) return null + const root = SIBLING_FIELD_SCHEMA[field] + if (path.length === 1) return root + return getSchemaAtPathUtil(root, path.slice(1)) ?? 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 +495,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 +602,18 @@ function PlaygroundConfigSection({ const parameters = (activeData?.parameters ?? {}) as Record + // Sibling data fields (script/runtime/url/headers) surfaced as sections. + const siblingFields = useMemo(() => { + const out: Record = {} + const d = activeData as Record | null + if (d) { + for (const field of SIBLING_DATA_FIELDS) { + if (field in d) out[field] = d[field] + } + } + return out + }, [activeData]) + // ========== ADAPTER ========== // Build adapter with schema support, swapping data source for useServerData const drillInAdapter = useMemo( @@ -1322,14 +1440,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 fields always get a section header, even when scalar. + const isSibling = isSiblingDataField(fieldKey) + + // Scalar/array params render inline (no header); only objects get one. + const fieldValue = isSibling ? siblingFields[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 } @@ -1475,13 +1596,17 @@ function PlaygroundConfigSection({ return null } - // Simple scalar fields and inline arrays render directly without HeightCollapse wrapper + // Sibling fields get the collapsible body wrapper even when scalar. + const isSibling = isSiblingDataField(fieldKey) + + // Scalar/array params render directly, without HeightCollapse. const fieldValue = 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
{props.defaultRender()}
} diff --git a/web/packages/agenta-entity-ui/src/adapters/variantAdapters.ts b/web/packages/agenta-entity-ui/src/adapters/variantAdapters.ts index 32816df885..041af19bfb 100644 --- a/web/packages/agenta-entity-ui/src/adapters/variantAdapters.ts +++ b/web/packages/agenta-entity-ui/src/adapters/variantAdapters.ts @@ -55,22 +55,27 @@ function stableStringify(value: unknown): string { * Compares server configuration vs current configuration as JSON. */ function buildGenericCommitContext( - currentConfig: Record | 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, ) }) From faf710f67645f220fc8a7107590a822415d4e65f Mon Sep 17 00:00:00 2001 From: Juan Pablo Vega Date: Tue, 16 Jun 2026 01:02:26 +0200 Subject: [PATCH 2/8] feat(frontend): code/hook workflow config UI Render the code/hook data fields as one grouped 'Code'/'Hook' accordion via a dedicated HookCodeConfigControl (no synthetic schema): hook shows a URL input + key/value Headers editor; code shows a script editor with tool-card chrome (copy, collapse, line numbers) whose language follows the runtime, plus a runtime picker in the section header styled like the model picker. Sections match the prompt accordion (full-bleed header, spaced from params). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SchemaControls/HookCodeConfigControl.tsx | 252 ++++++++++++++++++ .../src/DrillInView/SchemaControls/index.ts | 3 + .../components/PlaygroundConfigSection.tsx | 198 +++++++++----- 3 files changed, 389 insertions(+), 64 deletions(-) create mode 100644 web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/HookCodeConfigControl.tsx diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/HookCodeConfigControl.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/HookCodeConfigControl.tsx new file mode 100644 index 0000000000..c351d0a43b --- /dev/null +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/HookCodeConfigControl.tsx @@ -0,0 +1,252 @@ +/** + * HookCodeConfigControl + * + * Dedicated controls for the fixed code/hook workflow data fields. These fields + * (url, headers, script, runtime) are not schema-driven, so they render directly + * with purpose-built controls instead of going through SchemaPropertyRenderer. + */ + +import {memo, useCallback, useMemo, useState} from "react" + +import {CollapseToggleButton} from "@agenta/ui/components/presentational" +import {LabeledField} from "@agenta/ui/components/presentational" +import {EditorProvider} from "@agenta/ui/editor" +import {SharedEditor} from "@agenta/ui/shared-editor" +import {CopySimple, Plus, Trash} from "@phosphor-icons/react" +import {Button, Input, 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" +} + +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(() => { + onChange({...value, "": ""}) + }, [value, onChange]) + + return ( + +
+ {rows.map(([key, val], index) => ( +
+ setRow(index, e.target.value, String(val ?? ""))} + /> + setRow(index, key, e.target.value)} + /> +
+ ))} + +
+
+ ) +}) + +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 HookCodeConfigControlProps { + /** Which group to render. */ + kind: "hook" | "code" + /** Current group value, e.g. {url, headers} or {script, runtime}. */ + value: Record | null | undefined + /** Emits the full updated group object. */ + onChange: (value: Record) => void + disabled?: boolean + className?: string +} + +/** Renders the Hook (url + headers) or Code (script + runtime) section body. */ +export const HookCodeConfigControl = memo(function HookCodeConfigControl({ + kind, + value, + onChange, + disabled = false, + className, +}: HookCodeConfigControlProps) { + const group = (value ?? {}) as Record + + const patch = useCallback( + (field: string, fieldValue: unknown) => { + onChange({...group, [field]: fieldValue}) + }, + [group, onChange], + ) + + if (kind === "hook") { + return ( +
+ + patch("url", e.target.value)} + /> + + patch("headers", next)} + disabled={disabled} + /> +
+ ) + } + + 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/index.ts b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/index.ts index 218c423d3a..d476fae747 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,9 @@ export type {TextInputControlProps} from "./TextInputControl" export {EnumSelectControl} from "./EnumSelectControl" export type {EnumSelectControlProps} from "./EnumSelectControl" +export {HookCodeConfigControl} from "./HookCodeConfigControl" +export type {HookCodeConfigControlProps} from "./HookCodeConfigControl" + // ============================================================================ // 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 20a5b77e78..595d9fcb0e 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx @@ -34,14 +34,19 @@ 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, + HookCodeConfigControl, +} from "../SchemaControls" import {feedbackConfigModeAtomFamily} from "../SchemaControls/FeedbackConfigurationControl" import { validateConfigAgainstSchema, @@ -288,45 +293,55 @@ function memoAtom(factory: (id: string) => Atom): (id: string) => Atom // DEFAULT ADAPTER (workflowMolecule — direct molecule access) // ============================================================================ -// `data` fields living beside `parameters` (code/hook config). -const SIBLING_DATA_FIELDS = ["url", "headers", "script", "runtime"] as const -type SiblingDataField = (typeof SIBLING_DATA_FIELDS)[number] - -function isSiblingDataField(key: unknown): key is SiblingDataField { - return typeof key === "string" && (SIBLING_DATA_FIELDS as readonly string[]).includes(key) +const RUNTIME_SELECT_OPTIONS = ["python", "typescript", "javascript"].map((value) => ({ + label: {value}, + value, +})) + +// Sibling `data` fields grouped into one section per workflow kind. The section +// key (`hook`/`code`) is virtual: a `["hook","url"]` path maps to `data.url`. +const SIBLING_GROUPS = { + hook: ["url", "headers"], + code: ["script", "runtime"], +} 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 } -// url/headers for custom:hook, script/runtime for custom:code (uri = provider:kind:key:version). -function allowedSiblingFields(uri: unknown): readonly SiblingDataField[] { - if (typeof uri !== "string") return [] +// 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 [] - if (key === "hook") return ["url", "headers"] - if (key === "code") return ["script", "runtime"] - return [] + if (kind !== "custom") return null + return isSiblingGroupKey(key) ? key : null } -// Adapter data: `parameters` + uri-allowed sibling fields hoisted as sections. +// Adapter data: `parameters` + one uri-allowed sibling GROUP (hook/code) holding +// its fields, surfaced 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 allowed = allowedSiblingFields(fullData?.uri) - const siblings: Record = {} - if (fullData) { - for (const field of allowed) { - const value = fullData[field] - siblings[field] = value ?? (field === "headers" ? {} : "") + const group = allowedSiblingGroup(fullData?.uri) + const merged: Record = {parameters: (config ?? {}) as Record} + if (group && fullData) { + const fields: Record = {} + for (const field of SIBLING_GROUPS[group]) { + fields[field] = fullData[field] ?? (field === "headers" ? {} : "") } + merged[group] = fields } - if (!config && Object.keys(siblings).length === 0) return null - return {parameters: (config ?? {}) as Record, ...siblings} + if (!config && !group) return null + return merged } -/** Root path key is a sibling data field (top-level only). */ +/** Path targets a sibling group (top-level group key). */ function pathTargetsSibling(path: DataPath): boolean { - return path.length > 0 && isSiblingDataField(path[0]) + return path.length > 0 && isSiblingGroupKey(path[0]) } // Renderable if there are parameters OR any sibling group (hook/code/schemas), @@ -426,9 +441,9 @@ function buildWorkflowMoleculeAdapter(): ConfigSectionMoleculeAdapter { items.push({key, name: key, value}) } } - for (const field of SIBLING_DATA_FIELDS) { - if (d && field in d) { - items.push({key: field, name: field, value: d[field as keyof typeof d]}) + 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 @@ -451,14 +466,16 @@ function buildWorkflowMoleculeAdapter(): ConfigSectionMoleculeAdapter { setValueAtPath(params, path, value) return params }, - // rootData is flattened: sibling edits emit a tagged payload, params emit param keys. + // 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 field = path[0] as SiblingDataField - return {__siblingData: {[field]: root[field]}} as Record + const group = path[0] as SiblingGroupKey + const fields = (root[group] ?? {}) as Record + return {__siblingData: {...fields}} as Record } - for (const field of SIBLING_DATA_FIELDS) delete root[field] + for (const group of SIBLING_GROUP_KEYS) delete root[group] return root }, }, @@ -472,20 +489,15 @@ function buildWorkflowMoleculeAdapter(): ConfigSectionMoleculeAdapter { } } -// Synthetic schemas to pick a control for sibling fields (absent from the params schema). -const SIBLING_FIELD_SCHEMA: Record = { - url: {type: "string", title: "URL", "x-parameter": "text"}, - headers: {type: "object", title: "Headers", additionalProperties: {type: "string"}}, - script: {type: "string", title: "Code", "x-parameter": "code"}, - runtime: {type: "string", title: "Runtime", enum: ["python", "typescript", "javascript"]}, -} as Record - +// 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 field = path[0] - if (!isSiblingDataField(field)) return null - const root = SIBLING_FIELD_SCHEMA[field] - if (path.length === 1) return root - return getSchemaAtPathUtil(root, path.slice(1)) ?? 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 */ @@ -602,18 +614,28 @@ function PlaygroundConfigSection({ const parameters = (activeData?.parameters ?? {}) as Record - // Sibling data fields (script/runtime/url/headers) surfaced as sections. - const siblingFields = useMemo(() => { + // 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 field of SIBLING_DATA_FIELDS) { - if (field in d) out[field] = d[field] + 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( @@ -1440,11 +1462,11 @@ function PlaygroundConfigSection({ return null } - // Sibling fields always get a section header, even when scalar. - const isSibling = isSiblingDataField(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 ? siblingFields[fieldKey] : parameters[fieldKey] + const fieldValue = isSibling ? siblingGroups[fieldKey] : parameters[fieldKey] if ( !isSibling && (fieldValue === null || @@ -1483,7 +1505,11 @@ function PlaygroundConfigSection({ return (
toggleSection(fieldKey)} >
@@ -1542,6 +1568,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" && (
+
+ } + onChange={(next) => props.onChange(next)} + disabled={disabled} + /> +
+ + ) + } // Scalar/array params render directly, without HeightCollapse. const fieldValue = parameters[fieldKey] if ( - !isSibling && - (fieldValue === null || - fieldValue === undefined || - typeof fieldValue !== "object" || - Array.isArray(fieldValue)) + fieldValue === null || + fieldValue === undefined || + typeof fieldValue !== "object" || + Array.isArray(fieldValue) ) { return
{props.defaultRender()}
} - const isCollapsed = !!collapsedSections[fieldKey] - return (
{props.defaultRender()}
) }, - [collapsedSections, parameters, promptModelInfo?.isRootLevel], + [collapsedSections, parameters, siblingGroups, disabled, promptModelInfo?.isRootLevel], ) // ========== LOADING / EMPTY STATE ========== From 24d6451ce0e10e3650b05a42d0ef96519660e6ec Mon Sep 17 00:00:00 2001 From: Juan Pablo Vega Date: Tue, 16 Jun 2026 10:50:59 +0200 Subject: [PATCH 3/8] fix(frontend): forward full revision in hook/code invoke payload The playground app-workflow invoke payload sent data.{inputs,parameters} only. hook_v0 resolves the webhook url/headers from data.revision (a full WorkflowRevision), so without it the call had no url to forward to. Forward the whole revision under data.revision. --- .../agenta-entities/src/workflow/state/runnableSetup.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From 570e64fdc6272e71b43cd01ec5c7084ac5d79263 Mon Sep 17 00:00:00 2001 From: Juan Pablo Vega Date: Wed, 17 Jun 2026 12:01:03 +0200 Subject: [PATCH 4/8] feat(frontend): add Schemas section; split Hook/Code controls - New Schemas section (parameters/inputs/outputs JSON-schema editors) shown for both hook and code workflows, sourced from data.schemas. Section and each field collapsed by default; tool-card chrome (copy/collapse/line numbers). Writes route under data.schemas via {__siblingData:{schemas:...}}. - Split HookCodeConfigControl into dedicated HookConfigControl and CodeConfigControl (one component per kind), consistent with SchemasConfigControl. --- .../SchemaControls/CodeConfigControl.tsx | 139 ++++++++++ .../SchemaControls/HookCodeConfigControl.tsx | 252 ------------------ .../SchemaControls/HookConfigControl.tsx | 147 ++++++++++ .../SchemaControls/SchemasConfigControl.tsx | 165 ++++++++++++ .../src/DrillInView/SchemaControls/index.ts | 10 +- .../components/PlaygroundConfigSection.tsx | 48 +++- 6 files changed, 499 insertions(+), 262 deletions(-) create mode 100644 web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/CodeConfigControl.tsx delete mode 100644 web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/HookCodeConfigControl.tsx create mode 100644 web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/HookConfigControl.tsx create mode 100644 web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SchemasConfigControl.tsx 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..4422ee6fca --- /dev/null +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/CodeConfigControl.tsx @@ -0,0 +1,139 @@ +/** + * CodeConfigControl + * + * Dedicated control for the code workflow data fields (script, runtime). These + * are not schema-driven, so they render directly instead of via SchemaPropertyRenderer. + */ + +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/HookCodeConfigControl.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/HookCodeConfigControl.tsx deleted file mode 100644 index c351d0a43b..0000000000 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/HookCodeConfigControl.tsx +++ /dev/null @@ -1,252 +0,0 @@ -/** - * HookCodeConfigControl - * - * Dedicated controls for the fixed code/hook workflow data fields. These fields - * (url, headers, script, runtime) are not schema-driven, so they render directly - * with purpose-built controls instead of going through SchemaPropertyRenderer. - */ - -import {memo, useCallback, useMemo, useState} from "react" - -import {CollapseToggleButton} from "@agenta/ui/components/presentational" -import {LabeledField} from "@agenta/ui/components/presentational" -import {EditorProvider} from "@agenta/ui/editor" -import {SharedEditor} from "@agenta/ui/shared-editor" -import {CopySimple, Plus, Trash} from "@phosphor-icons/react" -import {Button, Input, 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" -} - -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(() => { - onChange({...value, "": ""}) - }, [value, onChange]) - - return ( - -
- {rows.map(([key, val], index) => ( -
- setRow(index, e.target.value, String(val ?? ""))} - /> - setRow(index, key, e.target.value)} - /> -
- ))} - -
-
- ) -}) - -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 HookCodeConfigControlProps { - /** Which group to render. */ - kind: "hook" | "code" - /** Current group value, e.g. {url, headers} or {script, runtime}. */ - value: Record | null | undefined - /** Emits the full updated group object. */ - onChange: (value: Record) => void - disabled?: boolean - className?: string -} - -/** Renders the Hook (url + headers) or Code (script + runtime) section body. */ -export const HookCodeConfigControl = memo(function HookCodeConfigControl({ - kind, - value, - onChange, - disabled = false, - className, -}: HookCodeConfigControlProps) { - const group = (value ?? {}) as Record - - const patch = useCallback( - (field: string, fieldValue: unknown) => { - onChange({...group, [field]: fieldValue}) - }, - [group, onChange], - ) - - if (kind === "hook") { - return ( -
- - patch("url", e.target.value)} - /> - - patch("headers", next)} - disabled={disabled} - /> -
- ) - } - - 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..7cbe4cf3ff --- /dev/null +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/HookConfigControl.tsx @@ -0,0 +1,147 @@ +/** + * HookConfigControl + * + * Dedicated control for the hook workflow data fields (url, headers). These are + * not schema-driven, so they render directly instead of via SchemaPropertyRenderer. + */ + +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..a50337a723 --- /dev/null +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SchemasConfigControl.tsx @@ -0,0 +1,165 @@ +/** + * SchemasConfigControl + * + * Renders the workflow's data.schemas (parameters / inputs / outputs) as three + * JSON-schema editors. Shown for both code and hook workflows. Each field is a + * JSON schema, edited as JSON with tool-card chrome (copy + collapse), and + * collapsed by default. + */ + +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 d476fae747..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,8 +26,14 @@ export type {TextInputControlProps} from "./TextInputControl" export {EnumSelectControl} from "./EnumSelectControl" export type {EnumSelectControlProps} from "./EnumSelectControl" -export {HookCodeConfigControl} from "./HookCodeConfigControl" -export type {HookCodeConfigControlProps} from "./HookCodeConfigControl" +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 595d9fcb0e..f3bbf9be84 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx @@ -45,7 +45,9 @@ import { getModelSchema, getLLMConfigValue, getLLMConfigProperties, - HookCodeConfigControl, + HookConfigControl, + CodeConfigControl, + SchemasConfigControl, } from "../SchemaControls" import {feedbackConfigModeAtomFamily} from "../SchemaControls/FeedbackConfigurationControl" import { @@ -303,6 +305,7 @@ const RUNTIME_SELECT_OPTIONS = ["python", "typescript", "javascript"].map((value 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[] @@ -335,7 +338,17 @@ function mergeSiblingFields( } merged[group] = fields } - if (!config && !group) return null + // `schemas` is nested under data.schemas and shown for any workflow that has it + // (not URI-gated like hook/code). Each field is a JSON schema object. + 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 } @@ -473,6 +486,10 @@ function buildWorkflowMoleculeAdapter(): ConfigSectionMoleculeAdapter { 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] @@ -786,6 +803,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) => { @@ -1661,12 +1680,25 @@ function PlaygroundConfigSection({ return (
- } - onChange={(next) => props.onChange(next)} - disabled={disabled} - /> + {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} + /> + )}
) From 669ef4c79c8918302d4b9b341e3ebd846132d0ef Mon Sep 17 00:00:00 2001 From: Juan Pablo Vega Date: Tue, 16 Jun 2026 10:52:55 +0200 Subject: [PATCH 5/8] fix(sdk): run custom hooks locally; make remote a workflow flag A workflow declaring uri=*:custom:hook:* used to resolve to the managed hook_v0 forwarder, which posted to the revision url verbatim. When the workflow IS the url's target, that meant it never ran its own handler and called itself. - @ag.workflow gains remote: bool = False. remote=True forwards to {url}/invoke (new remote_forward_v0, which also rstrips and appends /invoke, and forwards headers); remote=False runs the installed handler in-process. - A custom-hook URI no longer binds a managed handler, so the decorator's own handler runs. The resolver keeps a handler the decorator already installed. - hook_v0 is now a raising stub (CustomHookHandlerNotDefinedV0Error); reaching it means a custom hook had no handler and no remote. --- sdks/python/agenta/sdk/decorators/running.py | 67 ++++++++++++---- .../agenta/sdk/engines/running/errors.py | 16 ++++ .../agenta/sdk/engines/running/handlers.py | 76 ++++++++++++------- .../sdk/middlewares/running/resolver.py | 6 +- .../components/PlaygroundConfigSection.tsx | 4 +- 5 files changed, 121 insertions(+), 48 deletions(-) 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..8840c5352d 100644 --- a/sdks/python/agenta/sdk/engines/running/errors.py +++ b/sdks/python/agenta/sdk/engines/running/errors.py @@ -250,6 +250,22 @@ 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=( + "You reached the custom-hook placeholder handler. A custom hook must " + "define its own handler (run it locally) or set remote=True (forward " + "to its url). You shouldn't be here." + ), + ) + + 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..6f6337f4d6 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,11 @@ def _extract_webhook_url(value: Optional[Data]) -> Optional[str]: got=webhook_url, ) from exc + # The stored url is the service base; the invoke surface lives at /invoke. + target_url = f"{webhook_url.rstrip('/')}/invoke" + + log.info("remote_forward_v0 POST", url=target_url) + json_payload = { "inputs": inputs or {}, "parameters": parameters or {}, @@ -2294,8 +2291,9 @@ def _extract_webhook_url(value: Optional[Data]) -> Optional[str]: async with httpx.AsyncClient() as client: try: response = await client.post( - url=webhook_url, + url=target_url, json=json_payload, + headers=headers if isinstance(headers, dict) else None, timeout=httpx.Timeout(30.0, connect=5.0), ) except Exception as e: @@ -2327,6 +2325,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/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx b/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx index f3bbf9be84..5a88fa65c2 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx @@ -1725,7 +1725,7 @@ function PlaygroundConfigSection({ ) // ========== LOADING / EMPTY STATE ========== - const isConfigLoading = schemaQuery.isPending && !hasParameters(activeData) + const isConfigLoading = schemaQuery.isPending && !hasRenderableConfigSections(activeData) if (isConfigLoading) { return ( @@ -1737,7 +1737,7 @@ function PlaygroundConfigSection({ ) } - if (!hasParameters(activeData)) { + if (!hasRenderableConfigSections(activeData)) { return (
Date: Wed, 17 Jun 2026 15:39:51 +0200 Subject: [PATCH 6/8] fix(frontend): nest evaluator schemas in isDirty diff The whole-data dirty check compared server schemas.parameters (flat for evaluators) against the nested entity schema, so every evaluator workflow reported dirty on load. Nest the server schema to match. Fix stale comment. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/workflow/state/store.ts | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/web/packages/agenta-entities/src/workflow/state/store.ts b/web/packages/agenta-entities/src/workflow/state/store.ts index c2d39bd4bc..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 @@ -1823,18 +1825,41 @@ export const workflowIsDirtyAtomFamily = atomFamily((workflowId: string) => return sortObjectKeys(normalized) } + // 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 normalized to avoid false positives. + // 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)}) + return sortObjectKeys({ + ...base, + parameters: normalizeForComparison(normalizedParams), + ...(normalizedSchemas !== undefined ? {schemas: normalizedSchemas} : {}), + }) } - const normalizedEntity = normalizeData(entityData.data, entityParams) - const normalizedServer = normalizeData(serverData.data, serverParams) + const normalizedEntity = normalizeData( + entityData.data, + entityParams, + entityData.data?.schemas, + ) + const normalizedServer = normalizeData(serverData.data, serverParams, serverSchemas) const isDirty = !isEqual(normalizedEntity, normalizedServer) return isDirty From e994bfe91da17fd4311718bcfef88851d9bfa108 Mon Sep 17 00:00:00 2001 From: Juan Pablo Vega Date: Wed, 17 Jun 2026 16:11:20 +0200 Subject: [PATCH 7/8] fix: address review on code/hook config and custom-hook handlers - Migrate hook forwarding tests to remote_forward_v0 (forwarding moved off hook_v0, now a raising stub); add a stub-raises test. - Coerce forwarded webhook headers to str->str for httpx. - Neutral wording for the custom-hook-not-defined error. - mergeSiblingFields: default runtime to python (no false 'Select runtime'). - getChangesFromPath: emit tagged __siblingData for sibling paths. - URL placeholder drops /invoke (always appended by remote_forward_v0). - Condense multi-line comments per AGENTS.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../agenta/sdk/engines/running/errors.py | 5 ++-- .../agenta/sdk/engines/running/handlers.py | 12 ++++++++-- .../workflows/test_new_uri_handlers.py | 17 +++++++++---- .../oss/tests/pytest/utils/test_hook_v0.py | 7 +++--- .../src/workflow/snapshotAdapter.ts | 6 ++--- .../SchemaControls/CodeConfigControl.tsx | 7 +----- .../SchemaControls/HookConfigControl.tsx | 9 ++----- .../SchemaControls/SchemasConfigControl.tsx | 9 +------ .../components/PlaygroundConfigSection.tsx | 24 +++++++++++-------- 9 files changed, 49 insertions(+), 47 deletions(-) diff --git a/sdks/python/agenta/sdk/engines/running/errors.py b/sdks/python/agenta/sdk/engines/running/errors.py index 8840c5352d..327eb18bfe 100644 --- a/sdks/python/agenta/sdk/engines/running/errors.py +++ b/sdks/python/agenta/sdk/engines/running/errors.py @@ -259,9 +259,8 @@ def __init__(self) -> None: code=self.code, type=self.type, message=( - "You reached the custom-hook placeholder handler. A custom hook must " - "define its own handler (run it locally) or set remote=True (forward " - "to its url). You shouldn't be here." + "Custom hook has no handler. Define a local handler on the workflow, " + "or set remote=True to forward to its configured url." ), ) diff --git a/sdks/python/agenta/sdk/engines/running/handlers.py b/sdks/python/agenta/sdk/engines/running/handlers.py index 6f6337f4d6..5ea02b4184 100644 --- a/sdks/python/agenta/sdk/engines/running/handlers.py +++ b/sdks/python/agenta/sdk/engines/running/handlers.py @@ -2272,7 +2272,8 @@ async def remote_forward_v0( got=webhook_url, ) from exc - # The stored url is the service base; the invoke surface lives at /invoke. + # 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) @@ -2288,12 +2289,19 @@ async def remote_forward_v0( 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=target_url, json=json_payload, - headers=headers if isinstance(headers, dict) else None, + headers=request_headers, timeout=httpx.Timeout(30.0, connect=5.0), ) except Exception as e: 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/packages/agenta-entities/src/workflow/snapshotAdapter.ts b/web/packages/agenta-entities/src/workflow/snapshotAdapter.ts index 3f30807e77..ec1b8e3689 100644 --- a/web/packages/agenta-entities/src/workflow/snapshotAdapter.ts +++ b/web/packages/agenta-entities/src/workflow/snapshotAdapter.ts @@ -32,12 +32,10 @@ import { // PATCH VALIDATION SCHEMA // ============================================================================ -// Patch is a shallow diff over the whole `data` object (any changed top-level -// key: uri, schemas, url, headers, script, runtime, parameters). +// 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 a server baseline: parameters shallow-merge (nested -// diff), every other key replaces. +// Merge a data patch over the server baseline: parameters shallow-merge, rest replace. function mergeDataPatch( remoteData: Workflow | null | undefined, patch: Record, diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/CodeConfigControl.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/CodeConfigControl.tsx index 4422ee6fca..447c578da5 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/CodeConfigControl.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/CodeConfigControl.tsx @@ -1,9 +1,4 @@ -/** - * CodeConfigControl - * - * Dedicated control for the code workflow data fields (script, runtime). These - * are not schema-driven, so they render directly instead of via SchemaPropertyRenderer. - */ +/** Renders the code workflow script editor (runtime picker lives in the section header). */ import {memo, useCallback, useState} from "react" diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/HookConfigControl.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/HookConfigControl.tsx index 7cbe4cf3ff..88c18aa2df 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/HookConfigControl.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/HookConfigControl.tsx @@ -1,9 +1,4 @@ -/** - * HookConfigControl - * - * Dedicated control for the hook workflow data fields (url, headers). These are - * not schema-driven, so they render directly instead of via SchemaPropertyRenderer. - */ +/** Renders the hook workflow data fields (url, headers). */ import {memo, useCallback, useMemo} from "react" @@ -130,7 +125,7 @@ export const HookConfigControl = memo(function HookConfigControl({
| null, full: {data?: Record | null} | null, @@ -332,14 +330,14 @@ function mergeSiblingFields( 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] ?? (field === "headers" ? {} : "") + fields[field] = fullData[field] ?? SIBLING_FIELD_DEFAULTS[field] ?? "" } merged[group] = fields } - // `schemas` is nested under data.schemas and shown for any workflow that has it - // (not URI-gated like hook/code). Each field is a JSON schema object. + // `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 = {} @@ -470,10 +468,16 @@ function buildWorkflowMoleculeAdapter(): ConfigSectionMoleculeAdapter { }, getChangesFromPath: (data: unknown, path: DataPath, value: unknown) => { const d = data as {parameters?: Record} | null + // Sibling edits emit a tagged __siblingData payload (like + // getChangesFromRoot); params emit plain param keys. if (pathTargetsSibling(path)) { - const next = {...(d ?? {})} - setValueAtPath(next, path, value) - return next + const group = path[0] as SiblingGroupKey + const fields = {...((d as Record)?.[group] ?? {})} + setValueAtPath(fields, path.slice(1), value) + if (group === "schemas") { + return {__siblingData: {schemas: fields}} as Record + } + return {__siblingData: fields} as Record } const params = {...(d?.parameters ?? {})} setValueAtPath(params, path, value) From 358e67a60521d140f0ff0d37c176d89cc1480399 Mon Sep 17 00:00:00 2001 From: Juan Pablo Vega Date: Wed, 17 Jun 2026 16:31:56 +0200 Subject: [PATCH 8/8] fix(frontend): use setValueAtPath return value in getChangesFromPath setValueAtPath is immutable; the sibling/param branches ignored its return value and emitted the pre-update object. Use the returned value. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/PlaygroundConfigSection.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) 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 e78d5c5b08..f72e1dd822 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx @@ -468,20 +468,21 @@ function buildWorkflowMoleculeAdapter(): ConfigSectionMoleculeAdapter { }, getChangesFromPath: (data: unknown, path: DataPath, value: unknown) => { const d = data as {parameters?: Record} | null - // Sibling edits emit a tagged __siblingData payload (like - // getChangesFromRoot); params emit plain param keys. + // 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 fields = {...((d as Record)?.[group] ?? {})} - setValueAtPath(fields, path.slice(1), value) + 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 } - const params = {...(d?.parameters ?? {})} - setValueAtPath(params, path, value) - return params + return setValueAtPath(d?.parameters ?? {}, path, value) 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.