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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/design/agent-config-section-drawers/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<field>.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
Expand Down
16 changes: 8 additions & 8 deletions sdks/python/agenta/sdk/utils/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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",
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,10 @@ export const harnessCatalogQueryAtom = atomWithQuery<HarnessCapabilitiesMap>(()

/**
* 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<HarnessCapabilitiesMap | null>((get) => {
const query = get(harnessCatalogQueryAtom)
return query.data ?? null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.<field>.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 = {
Expand Down Expand Up @@ -242,7 +258,7 @@ export function AgentTemplateControl({
hasInstructions && {
key: "instructions",
icon: <FileText size={16} />,
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.
Expand Down Expand Up @@ -272,7 +288,7 @@ export function AgentTemplateControl({
hasTools && {
key: "tools",
icon: <Wrench size={16} />,
title: "Tools",
title: fieldTitle("tools", "Tools"),
summary: countSummary(tools.length, "tool"),
extra: !disabled ? (
<ToolSelectorPopover
Expand Down Expand Up @@ -311,7 +327,7 @@ export function AgentTemplateControl({
hasMcp && {
key: "mcp",
icon: <Plugs size={16} />,
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,
Expand All @@ -330,7 +346,7 @@ export function AgentTemplateControl({
hasSkills && {
key: "skills",
icon: <GraduationCap size={16} />,
title: "Skills",
title: fieldTitle("skills", "Skills"),
summary: countSummary(skills.length, "skill"),
extra: !disabled ? headerAddButton("Add skill", handleAddSkill) : undefined,
defaultOpen: skills.length > 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)"},
Expand Down Expand Up @@ -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(
Expand All @@ -100,15 +138,11 @@ export const ClaudePermissionsControl = memo(function ClaudePermissionsControl({

return (
<div className={cn("flex flex-col gap-2", className)}>
<LabeledField
label="Permission mode"
description="Claude Code's default permission mode for this headless run."
withTooltip
>
<LabeledField label={modeLabel} description={modeDescription} withTooltip>
<Select<ClaudePermissionMode>
value={current.defaultMode ?? undefined}
onChange={(v) => write({defaultMode: v ?? null})}
options={MODE_OPTIONS}
options={modeOptions}
disabled={disabled}
placeholder="Claude default"
allowClear
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,12 @@ export function useModelHarness({
schema,
config,
onChange,
revisionId,
disabled,
withTooltip,
}: {
schema?: SchemaProperty | null
config: Record<string, unknown>
onChange: (next: Record<string, unknown>) => void
revisionId: string | null
disabled?: boolean
withTooltip?: boolean
}) {
Expand Down Expand Up @@ -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<string, unknown> | 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
Expand Down Expand Up @@ -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<string, SchemaProperty>
| undefined
)?.default_mode
}
/>
</div>
) : null}
Expand Down
Loading