Skip to content
Open
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
2 changes: 1 addition & 1 deletion cloud-agent-next/src/execution/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,8 @@ export class ExecutionOrchestrator {
const result = await wrapperClient.prompt({
prompt,
model: wrapper.model,
agent: normalizedMode,
variant: wrapper.variant,
agent: normalizedMode,
});
logger.withFields({ inflightId: result.messageId }).info('Prompt sent to wrapper');
} catch (error) {
Expand Down
2 changes: 1 addition & 1 deletion cloud-agent-next/src/execution/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,9 +308,9 @@ export type WrapperPlan = {
kiloSessionId?: string;
kiloSessionTitle?: string;
model?: ModelConfig;
variant?: string;
autoCommit?: boolean;
condenseOnComplete?: boolean;
variant?: string;
};

// ---------------------------------------------------------------------------
Expand Down
40 changes: 30 additions & 10 deletions cloud-agent-next/src/kilo/server-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,36 +388,56 @@ export async function createKiloCliSession(
logger.withFields({ port }).debug('Creating kilo CLI session');

// Execute curl from within the session since the server runs on localhost inside the container
// -f: Fail silently on HTTP errors (exit code 22 for 4xx/5xx)
// -s: Silent mode (no progress)
// -S: Show errors when -s is used
// -w '\n%{http_code}': Append HTTP status code on a new line after the response body
// NOTE: We intentionally do NOT use -f so that we capture the response body on error
// --max-time: Timeout for the entire operation
const result = await session.exec(
`curl -f -s -S --max-time ${KILO_CLI_SESSION_CREATE_TIMEOUT_SECONDS} -X POST -H "Content-Type: application/json" -d "{}" "${url}"`
`curl -s -S -w '\\n%{http_code}' --max-time ${KILO_CLI_SESSION_CREATE_TIMEOUT_SECONDS} -X POST -H "Content-Type: application/json" -d "{}" "${url}"`
);

if (result.exitCode !== 0) {
// Exit code 22 = HTTP error (4xx/5xx), 28 = timeout
// Exit code 28 = timeout, 7 = connection refused, etc.
const exitCodeInfo =
result.exitCode === 22
? 'HTTP error from server'
: result.exitCode === 28
? 'Request timed out'
result.exitCode === 28
? 'Request timed out'
: result.exitCode === 7
? 'Connection refused'
: `exit code ${result.exitCode}`;
throw new Error(
`Failed to create kilo CLI session: ${exitCodeInfo} - ${result.stderr || result.stdout}`
);
}

// Parse the response: body is everything except the last line, HTTP status is the last line
const lines = result.stdout.trimEnd().split('\n');
const httpStatus = parseInt(lines[lines.length - 1] ?? '', 10);
const responseBody = lines.slice(0, -1).join('\n');

if (isNaN(httpStatus) || httpStatus >= 400) {
logger
.withFields({
port,
httpStatus: isNaN(httpStatus) ? 'unknown' : httpStatus,
responseBody: responseBody.slice(0, 2000),
stderr: result.stderr?.slice(0, 1000),
})
.error('Kilo CLI session creation failed');
throw new Error(
`Failed to create kilo CLI session: HTTP ${isNaN(httpStatus) ? 'unknown' : httpStatus} - ${responseBody || result.stderr || '(empty response)'}`
);
}

let kiloSession: KiloCliSession;
try {
kiloSession = JSON.parse(result.stdout) as KiloCliSession;
kiloSession = JSON.parse(responseBody) as KiloCliSession;
} catch {
throw new Error(`Failed to parse kilo CLI session response: ${result.stdout}`);
throw new Error(`Failed to parse kilo CLI session response: ${responseBody}`);
}

if (!kiloSession.id) {
throw new Error(`Invalid kilo CLI session response - missing id: ${result.stdout}`);
throw new Error(`Invalid kilo CLI session response - missing id: ${responseBody}`);
}

logger.withFields({ port, kiloSessionId: kiloSession.id }).info('Created kilo CLI session');
Expand Down
2 changes: 1 addition & 1 deletion cloud-agent-next/src/kilo/wrapper-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ export type WrapperPromptOptions = {
prompt?: string;
parts?: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
model?: { providerID?: string; modelID: string };
variant?: string;
agent?: string;
messageId?: string;
system?: string;
tools?: Record<string, boolean>;
variant?: string;
};

export type WrapperPermissionResponse = 'always' | 'once' | 'reject';
Expand Down
2 changes: 1 addition & 1 deletion cloud-agent-next/src/persistence/CloudAgentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1438,9 +1438,9 @@ export class CloudAgentSession extends DurableObject {
wrapper: {
kiloSessionId: params.kiloSessionId,
model: params.model ? { modelID: params.model.replace(/^kilo\//, '') } : undefined,
variant: params.variant,
autoCommit: params.autoCommit,
condenseOnComplete: params.condenseOnComplete,
variant: params.variant,
},
};
}
Expand Down
6 changes: 5 additions & 1 deletion cloud-agent-next/src/persistence/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,11 @@ export const MetadataSchema = z.object({
prompt: z.string().max(Limits.MAX_PROMPT_LENGTH).optional(),
mode: AgentModeSchema.optional(),
model: z.string().optional(),
variant: z.string().max(50).optional(),
variant: z
.string()
.max(50)
.regex(/^[a-zA-Z]+$/)
.optional(),
autoCommit: z.boolean().optional(),
condenseOnComplete: z.boolean().optional(),
appendSystemPrompt: z.string().max(10000).optional(),
Expand Down
19 changes: 16 additions & 3 deletions cloud-agent-next/src/router/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ export const PromptPayload = z.object({
prompt: z.string().min(1, 'Prompt is required').describe('The task prompt for Kilo Code'),
mode: AgentModeSchema.describe('Kilo Code execution mode (required)'),
model: modelIdSchema.describe('AI model to use (required)'),
variant: z.string().max(50).optional(),
variant: z
.string()
.max(50)
.regex(/^[a-zA-Z]+$/)
.optional(),
});

/**
Expand Down Expand Up @@ -128,7 +132,11 @@ export const PrepareSessionInput = z
.describe('The task prompt for Kilo Code'),
mode: AgentModeSchema.describe('Kilo Code execution mode'),
model: modelIdSchema.describe('AI model to use'),
variant: z.string().max(50).optional(),
variant: z
.string()
.max(50)
.regex(/^[a-zA-Z]+$/)
.optional(),

// Repository - one of these pairs required
githubRepo: githubRepoSchema
Expand Down Expand Up @@ -240,7 +248,12 @@ export const UpdateSessionInput = z
// Scalar fields - null to clear, value to set, undefined to skip
mode: AgentModeSchema.nullable().optional().describe('Mode to set (null to clear)'),
model: modelIdSchema.nullable().optional().describe('Model to set (null to clear)'),
variant: z.string().max(50).nullable().optional(),
variant: z
.string()
.max(50)
.regex(/^[a-zA-Z]+$/)
.nullable()
.optional(),
githubToken: z.string().nullable().optional().describe('GitHub token to set (null to clear)'),
gitToken: z.string().nullable().optional().describe('Git token to set (null to clear)'),
upstreamBranch: branchNameSchema
Expand Down
21 changes: 9 additions & 12 deletions cloud-agent-next/src/session-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1185,21 +1185,18 @@ describe('SessionService', () => {

const callArgs = sandboxCreateSession.mock.calls[0][0] as { env: Record<string, string> };
const configContent = JSON.parse(callArgs.env.KILO_CONFIG_CONTENT) as {
autoApproval?: {
execute?: {
denied?: string[];
};
write?: {
enabled?: boolean;
protected?: boolean;
};
permission?: {
bash?: Record<string, string>;
edit?: string;
read?: string;
};
};

expect(configContent.autoApproval?.execute?.denied).toContain('git commit');
expect(configContent.autoApproval?.execute?.denied).toContain('gh pr merge');
expect(configContent.autoApproval?.write?.enabled).toBe(false);
expect(configContent.autoApproval?.write?.protected).toBe(true);
// Denied commands use "cmd *" glob pattern format
expect(configContent.permission?.bash?.['git commit *']).toBe('deny');
expect(configContent.permission?.bash?.['gh pr merge *']).toBe('deny');
expect(configContent.permission?.edit).toBe('deny');
expect(configContent.permission?.read).toBe('allow');
});
});

Expand Down
38 changes: 22 additions & 16 deletions cloud-agent-next/src/session-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,22 +572,28 @@ export class SessionService {
};

if (commandGuardPolicy) {
configContent.autoApproval = {
enabled: true,
read: { enabled: true, outside: false },
write: { enabled: false, outside: false, protected: true },
browser: { enabled: false },
retry: { enabled: false, delay: 10 },
mcp: { enabled: true },
mode: { enabled: true },
subtasks: { enabled: true },
execute: {
enabled: true,
allowed: commandGuardPolicy.allowed,
denied: commandGuardPolicy.denied,
},
question: { enabled: false, timeout: 60 },
todo: { enabled: true },
// Build bash permission rules from guard policy: allowed commands get "allow",
// denied commands get "deny". Patterns use glob-style matching (e.g. "ls *").
const bashPermissions: Record<string, string> = {};
for (const cmd of commandGuardPolicy.denied) {
bashPermissions[`${cmd} *`] = 'deny';
}
for (const cmd of commandGuardPolicy.allowed) {
bashPermissions[`${cmd} *`] = 'allow';
}

// Merge guard policy permissions into the existing permission block
const existingPermission = configContent.permission as Record<string, unknown>;
configContent.permission = {
...existingPermission,
read: 'allow',
edit: 'deny',
bash: bashPermissions,
webfetch: 'deny',
websearch: 'deny',
codesearch: 'deny',
todowrite: 'allow',
todoread: 'allow',
};

logger
Expand Down
5 changes: 3 additions & 2 deletions cloud-agent-next/wrapper/src/kilo-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export type SendPromptOptions = {
prompt?: string;
/** Full parts array (takes precedence over prompt) */
parts?: MessagePart[];
/** Thinking effort variant name (e.g. "high", "max") */
variant?: string;
/** Agent mode (e.g., 'code', 'architect', 'ask') */
agent?: string;
/** Model configuration */
Expand All @@ -46,7 +48,6 @@ export type SendPromptOptions = {
system?: string;
/** Enable/disable specific tools */
tools?: Record<string, boolean>;
variant?: string;
};

/**
Expand Down Expand Up @@ -164,6 +165,7 @@ export function createKiloClient(baseUrl: string): KiloClient {
await requestNoContent('POST', `/session/${opts.sessionId}/prompt_async`, {
parts,
messageID: opts.messageId,
variant: opts.variant,
agent: opts.agent,
model: opts.model
? {
Expand All @@ -174,7 +176,6 @@ export function createKiloClient(baseUrl: string): KiloClient {
noReply: opts.noReply,
system: opts.system,
tools: opts.tools,
variant: opts.variant,
});
},

Expand Down
4 changes: 2 additions & 2 deletions cloud-agent-next/wrapper/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,11 @@ type PromptBody = {
/** Message parts - only text parts are supported (file parts require URL upload which isn't implemented) */
parts?: Array<{ type: 'text'; text: string }>;
model?: { providerID?: string; modelID: string };
variant?: string;
agent?: string;
messageId?: string;
system?: string;
tools?: Record<string, boolean>;
variant?: string;
};

type CommandBody = {
Expand Down Expand Up @@ -312,11 +312,11 @@ function createPromptHandler(deps: ServerDependencies) {
sessionId: job.kiloSessionId,
parts: body.parts,
prompt: body.prompt,
variant: body.variant,
agent: body.agent,
model: body.model,
system: body.system,
tools: body.tools,
variant: body.variant,
});
logToFile(`job/prompt: sent messageId=${messageId}`);
} catch (error) {
Expand Down
2 changes: 2 additions & 0 deletions cloud-agent/src/router/handlers/session-init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export function createSessionInitHandlers() {
for await (const event of streamKilocodeExec(input.mode, input.prompt, {
sessionId,
images: input.images,
variant: input.variant,
})) {
yield event;
if (event.streamEventType === 'interrupted') {
Expand Down Expand Up @@ -314,6 +315,7 @@ export function createSessionInitHandlers() {
sessionId,
skipInterruptPolling: false,
images: input.images,
variant: input.variant,
})) {
yield event;
if (event.streamEventType === 'interrupted' || event.streamEventType === 'error') {
Expand Down
10 changes: 10 additions & 0 deletions cloud-agent/src/router/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ export const PromptPayload = z.object({
.enum(['architect', 'code', 'ask', 'debug', 'orchestrator'])
.describe('Kilo Code execution mode (required)'),
model: z.string().min(1, 'Model is required').describe('AI model to use (required)'),
variant: z
.string()
.max(50)
.regex(/^[a-zA-Z]+$/)
.optional(),
});

/**
Expand Down Expand Up @@ -297,6 +302,11 @@ export const InitiateSessionAsyncInput = z
.enum(['architect', 'code', 'ask', 'debug', 'orchestrator'])
.describe('Kilo Code execution mode (required)'),
model: z.string().min(1, 'Model is required').describe('AI model to use (required)'),
variant: z
.string()
.max(50)
.regex(/^[a-zA-Z]+$/)
.optional(),
// Callback fields (required for async)
callbackUrl: z.string().url().describe('URL to POST completion/error notification to'),
callbackHeaders: z
Expand Down
22 changes: 19 additions & 3 deletions cloud-agent/src/session-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -742,7 +742,12 @@ export class SessionService {
streamKilocodeExec: async function* (
mode: string,
prompt: string,
options?: { sessionId?: string; skipInterruptPolling?: boolean; images?: Images }
options?: {
sessionId?: string;
skipInterruptPolling?: boolean;
images?: Images;
variant?: string;
}
) {
const currentIsFirst = isFirstCall;
isFirstCall = false;
Expand All @@ -756,7 +761,13 @@ export class SessionService {
context,
mode,
prompt,
{ ...options, isFirstExecution: currentIsFirst, kiloSessionId, images: options?.images },
{
...options,
isFirstExecution: currentIsFirst,
kiloSessionId,
images: options?.images,
variant: options?.variant,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: Redundant explicit property — variant is already included via ...options spread

images: options?.images (line above) has the same redundancy and predates this PR, so this is a pre-existing pattern. Both are no-ops since ...options already spreads images and variant. Not a bug, but worth noting for a future cleanup pass.

},
env
)) {
// Capture kiloSessionId from session_created event for subsequent calls
Expand Down Expand Up @@ -1721,7 +1732,12 @@ export interface PreparedSession {
streamKilocodeExec: (
mode: string,
prompt: string,
options?: { sessionId?: string; skipInterruptPolling?: boolean; images?: Images }
options?: {
sessionId?: string;
skipInterruptPolling?: boolean;
images?: Images;
variant?: string;
}
) => AsyncGenerator<StreamEvent>;
}

Expand Down
Loading