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
14 changes: 9 additions & 5 deletions src/core/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { BackgroundTaskManager } from './background-tasks.js';
import { SkillBatcher } from '../skills/batcher.js';
import type { SkillLoader } from '../skills/loader.js';
import { logger } from '../utils/logger.js';
import { ChatGPTWebProvider } from '../providers/chatgpt-web.js';
import { CLIChannel } from '../channels/cli.js';
import { TelegramChannel } from '../channels/telegram.js';
import { formatToolStep, formatNarrative, type NarrativeStep } from '../utils/tool-label.js';
Expand Down Expand Up @@ -1126,9 +1127,12 @@ export class Agent {
for (const provider of fallbackIterator) {
try {
this.markProgress(`Calling ${provider.name}...`);
const deepseekProviderOptions = provider instanceof DeepSeekProvider && provider.isReasoner
? { deepseek: { thinking: { type: 'enabled' as const } } }
: undefined;
let providerOptions: Record<string, any> | undefined;
if (provider instanceof DeepSeekProvider && provider.isReasoner) {
providerOptions = { deepseek: { thinking: { type: 'enabled' as const } } };
} else if (provider instanceof ChatGPTWebProvider) {
providerOptions = { openai: { store: false } };
}

logger.info({ provider: provider.name, model: provider.getModel(), steps: MAX_STEPS, stream: canStream }, 'Generating agentic response');

Expand All @@ -1141,7 +1145,7 @@ export class Agent {
maxOutputTokens: MAX_RESPONSE_TOKENS,
stopWhen: stepCountIs(MAX_STEPS),
abortSignal: loopAbortController.signal,
...(deepseekProviderOptions ? { providerOptions: deepseekProviderOptions } : {}),
...(providerOptions ? { providerOptions } : {}),
onStepFinish: async ({ toolCalls, toolResults }) => {
this.completedStepCount++;
if (toolCalls && toolCalls.length > 0) {
Expand Down Expand Up @@ -1384,7 +1388,7 @@ export class Agent {
maxOutputTokens: MAX_RESPONSE_TOKENS,
stopWhen: stepCountIs(MAX_STEPS),
abortSignal: loopAbortController.signal,
...(deepseekProviderOptions ? { providerOptions: deepseekProviderOptions } : {}),
...(providerOptions ? { providerOptions } : {}),
onStepFinish: async ({ toolCalls, toolResults }) => {
this.completedStepCount++;
if (toolCalls && toolCalls.length > 0) {
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import { runWithWatchdog } from './cli/watchdog.js';
import { setGitHubToken } from './utils/github.js';
import { selectWithArrowKeys } from './utils/arrow-select.js';
import { ProviderModelFetchError, fetchProviderModelCatalog } from './utils/provider-models.js';
import { startWebServer, stopWebServer, updateStatus as updateWebStatus, setUserMemory as setWebUserMemory, setWebChannel as setWebWebChannel, setScheduler as setWebScheduler, setAgentSupervisor as setWebSupervisor, setBackgroundTaskManager as setWebBgTasks, setSpotifyClient as setWebSpotify, setProgrammingMode as setWebProgrammingMode, setModelSwitchCallback as setWebModelSwitch, setCurrentProviderCallback as setWebCurrentProvider, setKanbanSupervisor as setWebKanban, setKanbanBoardManager as setWebBoardManager, setKanbanProviders as setWebKanbanProviders, setIDEProviders as setWebIDEProviders } from './web/server.js';
import { startWebServer, stopWebServer, updateStatus as updateWebStatus, setUserMemory as setWebUserMemory, setWebChannel as setWebWebChannel, setScheduler as setWebScheduler, setPermissionManager as setWebPermissionManager, setAgentSupervisor as setWebSupervisor, setBackgroundTaskManager as setWebBgTasks, setSpotifyClient as setWebSpotify, setProgrammingMode as setWebProgrammingMode, setModelSwitchCallback as setWebModelSwitch, setCurrentProviderCallback as setWebCurrentProvider, setKanbanSupervisor as setWebKanban, setKanbanBoardManager as setWebBoardManager, setKanbanProviders as setWebKanbanProviders, setIDEProviders as setWebIDEProviders } from './web/server.js';
import { isWebAuthInitialized, setWebPassword } from './web/auth.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
Expand Down Expand Up @@ -1368,6 +1368,7 @@ async function runAgent(isDaemon: boolean = false): Promise<void> {
const webChannel = new WebChannel(config.identity.name);
channels.register('web', webChannel);
const capabilities = new CapabilityRegistry(skillLoader, scheduler, tokenBudget, undefined, userMemory ?? undefined);
setWebPermissionManager(capabilities.permissions);

let supervisor: SubAgentSupervisor | undefined;
if (config.subagents.enabled) {
Expand Down
30 changes: 30 additions & 0 deletions src/providers/chatgpt-web.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { sanitiseBody } from './chatgpt-web.js';

describe('sanitiseBody', () => {
it('uses non-persistent ChatGPT Codex responses and strips stale item references', () => {
const sanitized = JSON.parse(sanitiseBody(JSON.stringify({
model: 'gpt-5.5',
input: [
{ role: 'user', content: [{ type: 'input_text', text: 'read the project' }] },
{ type: 'item_reference', id: 'rs_example' },
],
store: undefined,
stream: false,
max_output_tokens: 4096,
temperature: 0,
include: ['reasoning.encrypted_content'],
instructions: null,
tools: [{ type: 'function', name: 'list_files' }],
})));

expect(sanitized.store).toBe(false);
expect(sanitized.stream).toBe(true);
expect(sanitized.instructions).toBe('You are a helpful assistant.');
expect(sanitized.input).not.toContainEqual({ type: 'item_reference', id: 'rs_example' });
expect(sanitized.include).toEqual(['reasoning.encrypted_content']);
expect(sanitized.tools).toEqual([{ type: 'function', name: 'list_files' }]);
expect(sanitized).not.toHaveProperty('max_output_tokens');
expect(sanitized).not.toHaveProperty('temperature');
});
});
9 changes: 7 additions & 2 deletions src/providers/chatgpt-web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,19 @@ const UNSUPPORTED_FIELDS = [
/**
* Sanitise an outgoing request body for the ChatGPT codex/responses endpoint.
*/
function sanitiseBody(raw: string): string {
export function sanitiseBody(raw: string): string {
const body = JSON.parse(raw);

// Required
// The ChatGPT Codex endpoint rejects persisted responses, so the AI SDK must
// inline prior messages/tool results instead of sending item_reference rows.
body.store = false;
body.stream = true;
if (!body.instructions) body.instructions = 'You are a helpful assistant.';

if (Array.isArray(body.input)) {
body.input = body.input.filter((item: any) => item?.type !== 'item_reference');
}

// Strip unsupported & undefined/null
for (const key of UNSUPPORTED_FIELDS) delete body[key];
for (const key of Object.keys(body)) {
Expand Down
13 changes: 11 additions & 2 deletions src/web/api/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,20 @@ import cron from 'node-cron';

const system = new Hono();
let scheduler: Scheduler | null = null;
let permissionManager: PermissionManager | null = null;

export function setScheduler(s: Scheduler | null): void {
scheduler = s;
}

export function setPermissionManager(manager: PermissionManager | null): void {
permissionManager = manager;
}

function getPermissionManager(): PermissionManager {
return permissionManager ?? new PermissionManager();
}

system.get('/api/skills', (c) => {
const loader = new SkillLoader();
const all = loader.getAllSkills();
Expand Down Expand Up @@ -79,14 +88,14 @@ system.delete('/api/skills/:name', (c) => {
});

system.get('/api/permissions', (c) => {
const manager = new PermissionManager();
const manager = getPermissionManager();
const manifest = manager.getManifest();
return c.json({ manifest });
});

system.put('/api/permissions', async (c) => {
const body = await c.req.json();
const manager = new PermissionManager();
const manager = getPermissionManager();
const current = manager.getManifest();
const next: PermissionsManifest = {
capabilities: {
Expand Down
11 changes: 6 additions & 5 deletions src/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import authRoutes from './api/auth.js';
import statusRoutes, { updateStatus } from './api/status.js';
import providerRoutes from './api/providers.js';
import configRoutes from './api/config.js';
import systemRoutes, { setScheduler } from './api/system.js';
import systemRoutes, { setScheduler, setPermissionManager } from './api/system.js';
import brainRoutes, { setUserMemory } from './api/brain.js';
import chatRoutes, { setWebChannel, setProgrammingMode, setModelSwitchCallback, setCurrentProviderCallback } from './api/chat.js';
import agentRoutes, { setAgentSupervisor, setBackgroundTaskManager } from './api/agents.js';
Expand Down Expand Up @@ -173,7 +173,7 @@ if (spaAvailable) {
});
}

export { updateStatus, setUserMemory, setWebChannel, setScheduler, setAgentSupervisor, setBackgroundTaskManager, setSpotifyClient, setProgrammingMode, setModelSwitchCallback, setCurrentProviderCallback, setKanbanSupervisor, setKanbanBoardManager, setKanbanProviders, setIDEProviders };
export { updateStatus, setUserMemory, setWebChannel, setScheduler, setPermissionManager, setAgentSupervisor, setBackgroundTaskManager, setSpotifyClient, setProgrammingMode, setModelSwitchCallback, setCurrentProviderCallback, setKanbanSupervisor, setKanbanBoardManager, setKanbanProviders, setIDEProviders };

let webServer: ReturnType<typeof createAdaptorServer> | null = null;

Expand All @@ -197,6 +197,7 @@ process.on('unhandledRejection', (reason: any) => {

export function startWebServer(): { port: number; url: string } {
const port = getWebPort();
const host = process.env.MERCURY_WEB_HOST || '127.0.0.1';
initWebAuth();

if (spaAvailable) {
Expand All @@ -216,11 +217,11 @@ export function startWebServer(): { port: number; url: string } {
}
});

server.listen(port, '127.0.0.1', () => {
logger.info(`Web dashboard: http://127.0.0.1:${port}`);
server.listen(port, host, () => {
logger.info(`Web dashboard: http://${host}:${port}`);
});

return { port, url: `http://127.0.0.1:${port}` };
return { port, url: `http://${host}:${port}` };
}

export function stopWebServer(): Promise<void> {
Expand Down
21 changes: 16 additions & 5 deletions ui/src/components/chat/PermissionPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,28 @@ export function PermissionPrompt({

{resolved ? (
<p className="text-xs text-muted-foreground">
{resolved === "allow" ? "Allowed" : "Denied"}
{resolved === "always"
? "Always allowed and saved"
: resolved === "yes"
? "Allowed for this request"
: "Denied"}
</p>
Comment on lines 49 to 56
) : (
<div className="flex items-center gap-2">
<Button size="sm" onClick={() => handleAction("allow")}>
Allow
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" onClick={() => handleAction("yes")}>
Allow Once
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleAction("always")}
>
Always
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleAction("deny")}
onClick={() => handleAction("no")}
>
Deny
</Button>
Expand Down
91 changes: 61 additions & 30 deletions ui/src/pages/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,21 +110,31 @@ function WaitingIndicator() {
function PermissionPrompt({
data,
}: {
data: { id: string; tool?: string; description?: string };
data: { id: string; tool?: string; description?: string; prompt?: string };
}) {
const [resolving, setResolving] = useState(false);
const [resolved, setResolved] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);

async function handle(action: string) {
setResolving(true);
setError(null);
try {
await api.chat.permission(data.id, action);
const res = await api.chat.permission(data.id, action);
if (!res.resolved) {
setError("Permission request expired.");
return;
}
setResolved(action);
} catch {
// ignore
setError("Could not resolve permission request.");
} finally {
setResolving(false);
}
}

const description = data.description ?? data.prompt;

return (
<div className="flex w-full justify-start">
<div className="max-w-[85%] rounded-2xl border border-amber-500/30 bg-amber-500/5 px-4 py-3">
Expand All @@ -137,34 +147,55 @@ function PermissionPrompt({
Tool: <span className="font-medium text-foreground">{data.tool}</span>
</p>
)}
{data.description && (
<p className="mt-1 text-sm text-foreground">{data.description}</p>
{description && (
<p className="mt-1 whitespace-pre-wrap text-sm text-foreground">{description}</p>
)}
<div className="mt-3 flex items-center gap-2">
<Button
size="sm"
className="h-7 gap-1 text-xs"
disabled={resolving}
onClick={() => handle("allow")}
>
{resolving ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
{resolved ? (
<p className="mt-3 text-xs text-muted-foreground">
{resolved === "always"
? "Always allowed and saved."
: resolved === "yes"
? "Allowed for this request."
: "Denied."}
</p>
Comment on lines +153 to +160
) : (
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
size="sm"
className="h-7 gap-1 text-xs"
disabled={resolving}
onClick={() => handle("yes")}
>
{resolving ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Check className="h-3 w-3" />
)}
Allow Once
</Button>
<Button
size="sm"
variant="outline"
className="h-7 gap-1 text-xs"
disabled={resolving}
onClick={() => handle("always")}
>
<Check className="h-3 w-3" />
)}
Allow
</Button>
<Button
size="sm"
variant="outline"
className="h-7 gap-1 text-xs"
disabled={resolving}
onClick={() => handle("deny")}
>
<X className="h-3 w-3" />
Deny
</Button>
</div>
Always
</Button>
<Button
size="sm"
variant="outline"
className="h-7 gap-1 text-xs"
disabled={resolving}
onClick={() => handle("no")}
>
<X className="h-3 w-3" />
Deny
</Button>
</div>
)}
{error && <p className="mt-2 text-xs text-destructive">{error}</p>}
</div>
</div>
);
Expand Down Expand Up @@ -706,7 +737,7 @@ export function ChatPage() {
if (msg.role === "system") {
try {
const parsed = JSON.parse(msg.content);
if (parsed.id && (parsed.tool || parsed.description)) {
if (parsed.id && (parsed.tool || parsed.description || parsed.prompt)) {
return <PermissionPrompt key={msg.id} data={parsed} />;
}
} catch {
Expand Down
Loading