Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -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 none|local|ephemeral)', () => {
Comment thread
yangcao77 marked this conversation as resolved.
Resolved
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