Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@red-hat-developer-hub/backstage-plugin-catalog-backend-module-ai-resource-agent': minor
---

Validate the new optional `spec.spawn` field on agent AiResource entities. The processor and `collectAgentErrors` now accept `'none'`, `'local'`, or `'ephemeral'` and reject invalid values.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@red-hat-developer-hub/backstage-plugin-catalog-model-ai-resource-agent': minor
---

Add optional `spec.spawn` field to the agent AiResource entity definition. Accepts `'none'`, `'local'`, or `'ephemeral'` to declare the agent's execution spawn mode. Omitting the field preserves backward compatibility.
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,4 @@ spec:
- run-diagnostics
- search-knowledge-base
toolUseBehavior: run_llm_again
spawn: local
Original file line number Diff line number Diff line change
Expand Up @@ -67,20 +67,21 @@ Agent configuration fields live in **metadata/spec**, not in agent-specific anno

The “Research reference” column records where the catalog field was derived from during the spike; it is not a compatibility guarantee.

| Research reference (SDK `AgentConfiguration`) | AiResource | Required | Notes |
| --------------------------------------------- | -------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `name` | `metadata.name` | Yes | No `spec.name`; optional `metadata.title` for display |
| `instructions` | `spec.instructions` | No | Optional string (no function form in YAML). Omit when the agent image/runtime already bakes in a default prompt. |
| `handoffDescription` | `spec.handoffDescription` | No | string |
| `model` | `spec.model` | No | string model id |
| `handoffs` | `spec.handoffs` | No | `string[]`, opaque (no entity-ref format check) |
| `tools` | `spec.tools` | No | `string[]`, opaque (no entity-ref format check) |
| `toolUseBehavior` | `spec.toolUseBehavior` | No | `'run_llm_again'` \| `'stop_on_first_tool'` \| `string[]`; function/object callback forms OOS |
| `resetToolChoice` | `spec.resetToolChoice` | No | boolean |
| `modelSettings.temperature` | `spec.modelSettings.temperature` | No | number |
| `modelSettings.maxTokens` | `spec.modelSettings.maxTokens` | No | number |
| `modelSettings.toolChoice` | `spec.modelSettings.toolChoice` | No | string (`auto` / `none` / `required`) or structured object as typed in schema |
| `outputType` | `spec.outputSchema` | No | **Deliberate adaptation**: catalog uses JSON Schema object and/or simple string type name, not a runtime type/class reference |
| Research reference (SDK `AgentConfiguration`) | AiResource | Required | Notes |
| --------------------------------------------- | -------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `name` | `metadata.name` | Yes | No `spec.name`; optional `metadata.title` for display |
| `instructions` | `spec.instructions` | No | Optional string (no function form in YAML). Omit when the agent image/runtime already bakes in a default prompt. |
| `handoffDescription` | `spec.handoffDescription` | No | string |
| `model` | `spec.model` | No | string model id |
| `handoffs` | `spec.handoffs` | No | `string[]`, opaque (no entity-ref format check) |
| `tools` | `spec.tools` | No | `string[]`, opaque (no entity-ref format check) |
| `toolUseBehavior` | `spec.toolUseBehavior` | No | `'run_llm_again'` \| `'stop_on_first_tool'` \| `string[]`; function/object callback forms OOS |
| `resetToolChoice` | `spec.resetToolChoice` | No | boolean |
| `modelSettings.temperature` | `spec.modelSettings.temperature` | No | number |
| `modelSettings.maxTokens` | `spec.modelSettings.maxTokens` | No | number |
| `modelSettings.toolChoice` | `spec.modelSettings.toolChoice` | No | string (`auto` / `none` / `required`) or structured object as typed in schema |
| `outputType` | `spec.outputSchema` | No | **Deliberate adaptation**: catalog uses JSON Schema object and/or simple string type name, not a runtime type/class reference |
| _(no SDK equivalent)_ | `spec.spawn` | No | `'none'` \| `'local'` \| `'ephemeral'`; declares whether the agent runs in-process, in a child process, or in a predefined sandbox |

**Explicitly OOS / follow-up** (not in v1 catalog schema): other `modelSettings` (`parallelToolCalls`, `reasoning`, `truncation`, penalties, prompt-cache, `providerData`, …), prompt objects, guardrail hooks, MCP object graphs, Augment-only keys (`enableRAG`, `asTools`, `publishAs`, …), function-valued `instructions` / `toolUseBehavior`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ These are schema-layer requirements for agent authoring and tests; catalog proce

The agent schema MUST allow the optional agent fields defined in this change’s `design.md` mapping table (under `spec` / `metadata`, not agent-specific annotations). Optional field membership and shapes SHALL follow that mapping rather than a divergent inventory in code.

At minimum, the mapping’s optional catalog fields include: `spec.instructions`, `spec.handoffDescription`, `spec.model`, `spec.handoffs` (`string[]`), `spec.tools` (`string[]`), `spec.toolUseBehavior`, `spec.resetToolChoice`, `spec.modelSettings` (`temperature`, `maxTokens`, `toolChoice` only for v1), and `spec.outputSchema`.
At minimum, the mapping’s optional catalog fields include: `spec.instructions`, `spec.handoffDescription`, `spec.model`, `spec.handoffs` (`string[]`), `spec.tools` (`string[]`), `spec.toolUseBehavior`, `spec.resetToolChoice`, `spec.modelSettings` (`temperature`, `maxTokens`, `toolChoice` only for v1), `spec.outputSchema`, and `spec.spawn`.

#### Scenario: Optional fields from mapping accepted

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,18 @@ Runs only for `kind: AiResource`. When `spec.type` is `'agent'`, validates
agent-specific fields and collects all violations into a single error.
Non-agent AiResource entities (`skill`, `rule`, `model`, …) are unaffected.

| Field | Rule |
| ------------------------- | ------------------------------------------- |
| `spec.instructions` | Optional; must be a string if present |
| `spec.handoffs` | Optional; must be an array if present |
| `spec.tools` | Optional; must be an array if present |
| `spec.resetToolChoice` | Optional; must be a boolean if present |
| `spec.modelSettings` | Optional; must be a plain object if present |
| `spec.toolUseBehavior` | Optional; string or string array if present |
| `spec.outputSchema` | Optional; string or plain object if present |
| `spec.handoffDescription` | Optional; must be a string if present |
| `spec.model` | Optional; must be a string if present |
| Field | Rule |
| ------------------------- | ---------------------------------------------------------------- |
| `spec.instructions` | Optional; must be a string if present |
| `spec.handoffs` | Optional; must be an array if present |
| `spec.tools` | Optional; must be an array if present |
| `spec.resetToolChoice` | Optional; must be a boolean if present |
| `spec.modelSettings` | Optional; must be a plain object if present |
| `spec.toolUseBehavior` | Optional; string or string array if present |
| `spec.outputSchema` | Optional; string or plain object if present |
| `spec.handoffDescription` | Optional; must be a string if present |
| `spec.model` | Optional; must be a string if present |
| `spec.spawn` | Optional; must be one of 'none', 'local', 'ephemeral' if present |

`handoffs` / `tools` accept opaque string arrays without entity-ref format
enforcement. Core entity fields such as `spec.owner` and `spec.lifecycle`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,58 @@ describe('AiResourceAgentProcessor', () => {
expect(result).toEqual(entity);
});

it('should accept agent with valid spawn value', async () => {
const entity = makeAiResource({
type: 'agent',
lifecycle: 'production',
owner: 'ai-platform-team',
instructions: 'Agent in sandbox.',
spawn: 'ephemeral',
});

const result = await processor.preProcessEntity(entity, location, emit);

expect(result).toEqual(entity);
});

it('should accept agent without spawn', async () => {
const entity = makeAiResource({
type: 'agent',
lifecycle: 'production',
owner: 'ai-platform-team',
});

const result = await processor.preProcessEntity(entity, location, emit);

expect(result).toEqual(entity);
});

it('should reject agent with invalid spawn value', async () => {
const entity = makeAiResource({
type: 'agent',
lifecycle: 'production',
owner: 'ai-platform-team',
spawn: 'invalid',
});

await expect(
processor.preProcessEntity(entity, location, emit),
).rejects.toThrow('spec.spawn');
});

it('should reject agent with spawn as boolean', async () => {
const entity = makeAiResource({
type: 'agent',
lifecycle: 'production',
owner: 'ai-platform-team',
spawn: true,
});

await expect(
processor.preProcessEntity(entity, location, emit),
).rejects.toThrow('spec.spawn');
});

it('should report multiple agent errors together', async () => {
const entity = makeAiResource({
type: 'agent',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,46 @@ describe('collectAgentErrors', () => {
});
});

describe('spec.spawn (optional, must be one of none, local, ephemeral)', () => {
it.each(['none', 'local', 'ephemeral'] as const)(
'accepts spawn: %s',
spawn => {
const entity = makeAgent({ spawn });
expect(collectAgentErrors(entity)).toEqual([]);
},
);

it('accepts missing spawn', () => {
const entity = makeAgent();
expect(collectAgentErrors(entity)).toEqual([]);
});

it('rejects spawn as a boolean', () => {
const entity = makeAgent({ spawn: true });

const errors = collectAgentErrors(entity);
expect(errors).toHaveLength(1);
expect(errors[0]).toContain('spec.spawn');
});

it('rejects spawn as a number', () => {
const entity = makeAgent({ spawn: 42 });

const errors = collectAgentErrors(entity);
expect(errors).toHaveLength(1);
expect(errors[0]).toContain('spec.spawn');
});

it('rejects spawn with invalid string value', () => {
const entity = makeAgent({ spawn: 'other' });

const errors = collectAgentErrors(entity);
expect(errors).toHaveLength(1);
expect(errors[0]).toContain('spec.spawn');
expect(errors[0]).toContain("'other'");
});
});

describe('multiple errors reported together', () => {
it('collects all agent field errors in a single array', () => {
const entity = makeAgent({
Expand Down Expand Up @@ -409,6 +449,7 @@ describe('collectAgentErrors', () => {
type: 'object',
properties: { result: { type: 'string' } },
},
spawn: 'ephemeral',
});

expect(collectAgentErrors(entity)).toEqual([]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,23 @@ export function collectAgentErrors(entity: Entity): string[] {
errors.push(`spec.model must be a string; got ${typeLabel(spec.model)}`);
}

// Optional: spec.spawn — must be 'none', 'local', or 'ephemeral' if present
const validSpawnValues = ['none', 'local', 'ephemeral'];
if (spec.spawn !== undefined) {
if (
typeof spec.spawn !== 'string' ||
!validSpawnValues.includes(spec.spawn as string)
) {
errors.push(
`spec.spawn must be one of 'none', 'local', 'ephemeral'; got ${
typeof spec.spawn === 'string'
? `'${spec.spawn}'`
: typeLabel(spec.spawn)
}`,
);
}
}

return errors;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface AgentAiResourceEntityV1alpha1 extends Entity {
resetToolChoice?: boolean;
modelSettings?: AgentAiResourceModelSettings;
outputSchema?: Record<string, JsonValue | undefined> | string;
spawn?: 'none' | 'local' | 'ephemeral';
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ function makeFullAgent(): Entity {
type: 'object',
properties: { result: { type: 'string' } },
},
spawn: 'ephemeral',
},
} as Entity;
}
Expand Down Expand Up @@ -140,6 +141,23 @@ describe('agentAiResourceEntityV1alpha1Validator', () => {
);
expect(result).toBe(true);
});

it.each(['none', 'local', 'ephemeral'] as const)(
'accepts agent with spawn: %s',
async spawn => {
const result = await agentAiResourceEntityV1alpha1Validator.check(
makeMinimalAgent({ spawn }),
);
expect(result).toBe(true);
},
);

it('accepts agent without spawn (field absent)', async () => {
const entity = makeMinimalAgent();
delete (entity as any).spec.spawn;
const result = await agentAiResourceEntityV1alpha1Validator.check(entity);
expect(result).toBe(true);
});
});

describe('reject paths', () => {
Expand Down Expand Up @@ -241,6 +259,30 @@ describe('agentAiResourceEntityV1alpha1Validator', () => {
).rejects.toThrow();
});

it('rejects spawn as boolean', async () => {
await expect(
agentAiResourceEntityV1alpha1Validator.check(
makeMinimalAgent({ spawn: true as any }),
),
).rejects.toThrow();
});

it('rejects spawn as number', async () => {
await expect(
agentAiResourceEntityV1alpha1Validator.check(
makeMinimalAgent({ spawn: 42 as any }),
),
).rejects.toThrow();
});

it('rejects spawn with invalid string value', async () => {
await expect(
agentAiResourceEntityV1alpha1Validator.check(
makeMinimalAgent({ spawn: 'other' as any }),
),
).rejects.toThrow();
});

it('rejects tools as non-array', async () => {
await expect(
agentAiResourceEntityV1alpha1Validator.check(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@
"owner": "ai-platform-team",
"instructions": "You are a support router agent. Analyze incoming support requests and delegate them to the appropriate specialist agent."
}
},
{
"apiVersion": "backstage.io/v1alpha1",
"kind": "AiResource",
"metadata": {
"name": "sandboxed-code-agent",
"description": "Agent that runs in an ephemeral sandbox"
},
"spec": {
"type": "agent",
"lifecycle": "production",
"owner": "ai-platform-team",
"instructions": "You are a code execution agent that runs in a sandboxed environment.",
"spawn": "ephemeral"
}
}
],
"allOf": [
Expand Down Expand Up @@ -146,6 +161,11 @@
"minLength": 1
}
]
},
"spawn": {
"type": "string",
"enum": ["none", "local", "ephemeral"],
"description": "Execution spawn mode — declares whether this agent runs in-process, in a child process, or in a predefined sandbox."
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,5 +79,15 @@ export interface AgentAiResourceEntityV1alpha1 extends Entity {
modelSettings?: AgentAiResourceModelSettings;
/** JSON Schema object or simple type name describing the agent's output format. */
outputSchema?: Record<string, JsonValue | undefined> | string;
/**
* Execution spawn mode — declares whether this agent runs in-process,
* in a child process, or in a predefined sandbox.
*
* - `none` — runs in the LCore process, without a sandbox.
* - `local` — runs in a child process, without a sandbox.
* - `ephemeral` — runs in the administrator-predefined sandbox profile.
* - Omitted — stays unset; LCore applies its default.
*/
spawn?: 'none' | 'local' | 'ephemeral';
};
}
Loading