diff --git a/docs/design/agent-config-section-drawers/design.md b/docs/design/agent-config-section-drawers/design.md index d69098f2be..65f64a9e1c 100644 --- a/docs/design/agent-config-section-drawers/design.md +++ b/docs/design/agent-config-section-drawers/design.md @@ -102,6 +102,40 @@ switch, mirroring the model warning. Tracked as the harness-gating follow-up. first-class via `ModelSpec` (`../agent-workflows/scratch/notes-model-auth.md`, R2), re-bind or clear the slug when the derived provider changes. Raised from PR #4923 review. +## Schema-driven section work package + +After #4913 landed the nested `agent-template` catalog type, the panel can read more from the +template shape instead of hardcoding it. The control is schema-*gated* (which sections/options +exist) but not yet schema-*driven* (identity, structure, controls, discrimination). This package +closes that to the extent #4913's schema supports; the gated tail waits on later schema steps. + +What #4913 makes available: every field has a `title`/`description`; `harness.kind` carries +`x-ag-harness-ref: "harness"` plus a `oneOf` of `{const, title}`; `ToolConfig` is a real +`discriminator: "type"` union (builtin/gateway/code/client/reference/platform) exposed in the JSON +schema; `harness.permissions` / `sandbox.permissions` are fully typed sub-schemas; `instructions` +→ `x-ag-type: "textarea"`, `llm.model` → `x-parameter: "grouped_choice"`. + +- [ ] **G1 — Section identity from schema.** Derive each per-field section's title + description + (tooltip) from `props..title` / `.description` instead of literals; order the list sections + by schema property order. Composite sections (Model & harness = `llm`+`harness`, Advanced = + `runner`+`sandbox`+`harness.extras`/`permissions`) keep their FE labels, and Triggers (non-schema) + stays FE. Icons stay FE (not in schema). +- [ ] **G2 — Discriminator-aware item classification.** Read the declared discriminator (`ToolConfig` + `discriminator: "type"`) from the schema where present, falling back to today's sniffing. MCP + (`transport` presence) and embed/skill refs (`@ag.embed`, `x-ag-type-ref: "skill-template"`) have + no discriminator declared, so they stay sniffed. *Gated tail:* fully adopting the six-kind tool + union (the FE's inline `type:"function"` shape is not in `ToolConfig`) belongs with the + schema-driven-config redesign (CHANGE-3). +- [ ] **G3 — Follow `x-ag-harness-ref`.** Resolve the harness capability catalog from the schema's + `harness.kind["x-ag-harness-ref"]` declaration rather than the hardcoded `/catalog/harnesses/` + assumption; behavior is identical today (one catalog) but the dependency is now declared, not + assumed. +- [ ] **G4 — Schema-sourced permission editor.** Source the harness-permissions editor's option set + (`default_mode` enum) and field labels/tooltips from the typed `harness.permissions` sub-schema + instead of hardcoded literals, keeping the rich control. *Gated tail:* per-harness show/hide still keys off + the harness value (`=== "claude"`) because neither the schema nor the harness catalog yet carries a + per-harness "is-gating" capability flag; make it schema-driven when that flag exists. + ## Verification `tsc` + `eslint` clean on `@agenta/ui` and `@agenta/entity-ui`; package unit tests for the new diff --git a/sdks/python/agenta/sdk/utils/types.py b/sdks/python/agenta/sdk/utils/types.py index c80d0bb439..3c7be5414f 100644 --- a/sdks/python/agenta/sdk/utils/types.py +++ b/sdks/python/agenta/sdk/utils/types.py @@ -1118,7 +1118,7 @@ def _harness_field_schema_extra() -> Dict[str, Any]: class _ConnectionSchema(BaseModel): """The model credential connection (the existing ``ModelRef.connection``).""" - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra="forbid", title="Connection") mode: Literal["agenta", "self_managed"] = Field( default="agenta", @@ -1139,7 +1139,7 @@ class _LlmSchema(BaseModel): carry the structured intent when authored; ``extras`` is the neutral knobs bag (was ``ModelRef.params``).""" - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra="forbid", title="Model") model: str = Field( default=_DEFAULT_AGENT_MODEL, @@ -1169,7 +1169,7 @@ class _InstructionsSchema(BaseModel): file (becomes the harness ``AGENTS.md``); the object wraps it so later instruction kinds have a home.""" - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra="forbid", title="Instructions") agents_md: str = Field( default=_DEFAULT_AGENTS_MD, @@ -1259,7 +1259,7 @@ class _HarnessPermissionsSchema(BaseModel): permission mode; ``allow`` / ``ask`` / ``deny`` are per-tool rule strings. A non-gating harness (Pi) leaves this empty.""" - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra="forbid", title="Permissions") default_mode: Optional[ Literal["default", "acceptEdits", "plan", "bypassPermissions"] @@ -1293,7 +1293,7 @@ class _HarnessSchema(BaseModel): ``permissions`` is the gating posture for harnesses that gate tool use (Claude). ``extras`` is the per-harness escape hatch (Pi's ``system`` / ``append_system`` prompt overrides).""" - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra="forbid", title="Harness") kind: str = Field( default=_DEFAULT_HARNESS, @@ -1327,7 +1327,7 @@ class _InteractionsSchema(BaseModel): The runner enforces it (``services/agent/src/responder.ts``). ``input`` and ``client_tool`` interaction kinds extend this section in a later step.""" - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra="forbid", title="Interactions") headless: Literal["auto", "deny"] = Field( default=_DEFAULT_PERMISSION_POLICY, @@ -1347,7 +1347,7 @@ class _RunnerSchema(BaseModel): hatch. The rest of the runner surface (per-kind interaction handling, delivery channel, hooks, loop controls) is a later step.""" - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra="forbid", title="Runner") kind: Literal["sidecar"] = Field( default="sidecar", @@ -1373,7 +1373,7 @@ class _SandboxSchema(BaseModel): ``kind`` is the sandbox provider; ``permissions`` is the security boundary folded in first-class (was ``sandbox_permission``). ``extras`` is the per-sandbox escape hatch.""" - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra="forbid", title="Sandbox") kind: Literal["local", "daytona"] = Field( default=_DEFAULT_SANDBOX, diff --git a/web/packages/agenta-entities/src/workflow/state/inspectMeta.ts b/web/packages/agenta-entities/src/workflow/state/inspectMeta.ts index a6bfd73172..07784d12c4 100644 --- a/web/packages/agenta-entities/src/workflow/state/inspectMeta.ts +++ b/web/packages/agenta-entities/src/workflow/state/inspectMeta.ts @@ -48,9 +48,10 @@ export const harnessCatalogQueryAtom = atomWithQuery(() /** * The per-harness capability map from the `harnesses` catalog. `null` until the catalog resolves. - * Keyed for signature compatibility with consumers; the data itself is not revision-scoped. + * Keyed by the harness ref (a template's `x-ag-harness-ref` value) that selects this catalog; the + * catalog data itself is global, so the key only documents which ref drove the lookup. */ -export const harnessCapabilitiesAtomFamily = atomFamily((_revisionId: string) => +export const harnessCapabilitiesAtomFamily = atomFamily((_harnessRef: string) => atom((get) => { const query = get(harnessCatalogQueryAtom) return query.data ?? null diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx index 9293b6e42b..cf89257d2c 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx @@ -159,7 +159,7 @@ export function AgentTemplateControl({ // Model & harness + Advanced own a lot of coupled, stateful logic (the model/connection state // feeds both sections), so they live in their own hook that returns the summaries + bodies. - const mh = useModelHarness({schema, config, onChange, revisionId, disabled, withTooltip}) + const mh = useModelHarness({schema, config, onChange, disabled, withTooltip}) // Tool add/remove (inline function, builtin, gateway, workflow reference) lives in its own hook. const { @@ -204,6 +204,22 @@ export function AgentTemplateControl({ const hasMcp = Boolean(props.mcps) const hasSkills = Boolean(props.skills) + // Per-field section headers read their label from the template schema (`props..title`), + // so a field rename propagates without editing this file; the literal is a fallback. Composite + // sections (Model & harness, Advanced) and Triggers keep their FE labels, and icons aren't in + // the schema. + // + // Guard: schema-gen emits the wrapper class name as `title` for single nested-model fields + // (e.g. `instructions` -> "_InstructionsSchema"), so reject leading-underscore titles and fall + // back to the literal. List fields (tools/mcps/skills) carry real titles and pass through. + const fieldTitle = useCallback( + (field: string, fallback: string): string => { + const t = (props[field] as {title?: unknown} | undefined)?.title + return typeof t === "string" && t.trim() && !t.startsWith("_") ? t : fallback + }, + [props], + ) + // Shared props for the tool picker, so the in-body popover and the header quick-add trigger // drive the same add flow. const toolSelectorProps = { @@ -242,7 +258,7 @@ export function AgentTemplateControl({ hasInstructions && { key: "instructions", icon: , - title: "Instructions", + title: fieldTitle("instructions", "Instructions"), summary: countSummary(1, "file"), // The + is inert until the backend stores multiple instruction files; the section is // already a list so it lights up with no rework when that lands. @@ -272,7 +288,7 @@ export function AgentTemplateControl({ hasTools && { key: "tools", icon: , - title: "Tools", + title: fieldTitle("tools", "Tools"), summary: countSummary(tools.length, "tool"), extra: !disabled ? ( , - title: "MCP servers", + title: fieldTitle("mcps", "MCP servers"), summary: countSummary(mcpServers.length, "server"), extra: !disabled ? headerAddButton("Add MCP server", handleAddMcpServer) : undefined, defaultOpen: mcpServers.length > 0, @@ -330,7 +346,7 @@ export function AgentTemplateControl({ hasSkills && { key: "skills", icon: , - title: "Skills", + title: fieldTitle("skills", "Skills"), summary: countSummary(skills.length, "skill"), extra: !disabled ? headerAddButton("Add skill", handleAddSkill) : undefined, defaultOpen: skills.length > 0, diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/ClaudePermissionsControl.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/ClaudePermissionsControl.tsx index b24dd70c2c..611648aad6 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/ClaudePermissionsControl.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/ClaudePermissionsControl.tsx @@ -36,8 +36,17 @@ export interface ClaudePermissionsControlProps { disabled?: boolean /** Additional CSS classes */ className?: string + /** + * The `default_mode` field schema (its `enum` + `title`/`description`). When provided, the mode + * option set and the field label/description follow the template schema instead of the + * hardcoded fallback, so a backend mode change propagates without editing this control. The enum + * may sit directly on the node or, for an `Optional[Literal]`, inside `anyOf`. + */ + modeSchema?: {enum?: unknown; anyOf?: unknown; title?: unknown; description?: unknown} | null } +// FE display copy per mode value. The set of values is the schema's `default_mode` enum (passed via +// `modeSchema`); this only supplies the friendlier label, falling back to the raw value. const MODE_OPTIONS: {value: ClaudePermissionMode; label: string}[] = [ {value: "default", label: "Default (prompt on each gated tool)"}, {value: "acceptEdits", label: "Accept edits (auto-accept file edits)"}, @@ -74,9 +83,38 @@ export const ClaudePermissionsControl = memo(function ClaudePermissionsControl({ onChange, disabled = false, className, + modeSchema, }: ClaudePermissionsControlProps) { const current = useMemo(() => readValue(value), [value]) + // The mode option set comes from the schema's `default_mode` enum when available (FE labels by + // value, falling back to the raw value); otherwise the hardcoded MODE_OPTIONS. The field + // label/description likewise prefer the schema's title/description. + const modeOptions = useMemo(() => { + // The enum is on the node for a bare Literal, or inside `anyOf` for an `Optional[Literal]` + // (`anyOf: [{enum:[...]}, {type:"null"}]`). + const direct = Array.isArray(modeSchema?.enum) ? (modeSchema!.enum as unknown[]) : null + const fromAnyOf = Array.isArray(modeSchema?.anyOf) + ? (modeSchema!.anyOf as {enum?: unknown}[]).find((a) => Array.isArray(a?.enum))?.enum + : null + const en = (direct ?? fromAnyOf) as unknown[] | null | undefined + if (Array.isArray(en) && en.length) { + const labelOf = (v: string) => MODE_OPTIONS.find((o) => o.value === v)?.label ?? v + return en + .filter((v): v is string => typeof v === "string") + .map((v) => ({value: v as ClaudePermissionMode, label: labelOf(v)})) + } + return MODE_OPTIONS + }, [modeSchema]) + const modeLabel = + typeof modeSchema?.title === "string" && modeSchema.title + ? modeSchema.title + : "Permission mode" + const modeDescription = + typeof modeSchema?.description === "string" && modeSchema.description + ? modeSchema.description + : "Claude Code's default permission mode for this headless run." + // Compose the full permissions object, overriding one slice. `default_mode` is only written // when set so an author who only edits rules never emits a null mode. const write = useCallback( @@ -100,15 +138,11 @@ export const ClaudePermissionsControl = memo(function ClaudePermissionsControl({ return (
- + value={current.defaultMode ?? undefined} onChange={(v) => write({defaultMode: v ?? null})} - options={MODE_OPTIONS} + options={modeOptions} disabled={disabled} placeholder="Claude default" allowClear diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx index ec1b2696ff..b5799244e2 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx @@ -39,14 +39,12 @@ export function useModelHarness({ schema, config, onChange, - revisionId, disabled, withTooltip, }: { schema?: SchemaProperty | null config: Record onChange: (next: Record) => void - revisionId: string | null disabled?: boolean withTooltip?: boolean }) { @@ -105,12 +103,19 @@ export function useModelHarness({ const modelId = useMemo(() => modelIdFromConfig(llm), [llm]) const connection = useMemo(() => connectionFromConfig(llm), [llm]) - // Per-harness capability map from the `/inspect` response meta, keyed by the open revision. - // Null when inspect hasn't resolved or the agent didn't publish it (older agents / standalone), - // in which case the connectionUtils helpers fall back permissively. - const capabilities = useAtomValue( - useMemo(() => harnessCapabilitiesAtomFamily(revisionId ?? ""), [revisionId]), + // Harness capability map, resolved from the schema's declared `x-ag-harness-ref` on the harness + // `kind` field (its target is the `harnesses` catalog). The ref is what opts this field into + // catalog-driven capabilities: we only apply the map when the schema declares it, otherwise the + // connectionUtils helpers fall back to a permissive, unfiltered picker. The catalog itself is + // global, so the ref string also keys the atom. + const harnessRef = (harnessProps.kind as Record | undefined)?.[ + "x-ag-harness-ref" + ] + const harnessRefKey = typeof harnessRef === "string" && harnessRef ? harnessRef : null + const capabilitiesFromCatalog = useAtomValue( + useMemo(() => harnessCapabilitiesAtomFamily(harnessRefKey ?? ""), [harnessRefKey]), ) + const capabilities = harnessRefKey ? capabilitiesFromCatalog : null // The project's stored connections (read-only) for the connection picker. The transformed vault // list surfaces custom-provider connections as {type, name, provider}; the resolver matches a @@ -760,6 +765,15 @@ export function useModelHarness({ value={claudePermissions} onChange={setClaudePermissions} disabled={disabled} + // Mode options + labels come from the harness `permissions` + // sub-schema (`default_mode` enum) so they follow the template. + modeSchema={ + ( + harnessProps.permissions?.properties as + | Record + | undefined + )?.default_mode + } />
) : null}