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
134 changes: 134 additions & 0 deletions docs/design-docs/corpilot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# CorPilot MVP

Task-scoped steering surface built on top of Cortex chat.

## Goal

Make tasks steerable after creation without turning the task board into a generic chat product.

CorPilot is a constrained, task-scoped copilot:

- refine a task
- add missing context
- split or reorganize subtasks
- inspect execution
- steer active work

It is not a general admin chat and it should not mutate unrelated tasks.

## Product Shape

CorPilot lives inside the task detail view as a dedicated panel/tab.

The user sees:

- task overview
- CorPilot

CorPilot is attached to one task and one task only.

## MVP Scope

### Supported

- persistent task-scoped thread
- task context injected into the cortex prompt
- conversational refinement of the current task
- task updates via existing `task_update`
- execution steering for in-progress tasks
- worker inspection for the task's active worker

### Explicitly out of scope

- global freeform cortex chat from the task surface
- editing unrelated tasks from CorPilot
- new task comments/events tables
- multi-task planning views
- autonomous execution policy changes

## Behavior Rules

### Backlog / Pending Approval / Ready

CorPilot may:

- rewrite title
- rewrite description
- change priority
- change subtasks
- move status

### In Progress

CorPilot should prefer:

- adding context
- steering the worker
- explaining blockers
- proposing replans

CorPilot should avoid silently rewriting the core task specification while work is already running.

## Technical Design

### Thread identity

Use a deterministic cortex thread per task for MVP.

Format:

`corpilot:task:<task-id>`

This avoids a schema migration and keeps history stable.

### Context injection

Extend cortex chat send requests with `task_number`.

When present, cortex prompt building loads:

- task title
- description
- status
- priority
- subtasks
- created/updated metadata
- active worker id
- latest worker summary if available

### Prompt constraints

When `task_context` exists, the cortex prompt explicitly switches into CorPilot mode:

- stay scoped to this task
- prefer task operations
- do not mutate unrelated tasks
- be conservative while task is `in_progress`

### UI

Embed CorPilot into task detail.

Requirements:

- fixed thread id
- no thread picker
- no new-thread button
- task-specific starter prompts
- task summary header above the chat

## Why this MVP

The backend already has:

- task CRUD and mutation
- cortex chat streaming
- worker inspection

So the main missing pieces are:

- task scoping
- prompt constraints
- task detail embedding

This keeps the first implementation small enough to test quickly.
3 changes: 2 additions & 1 deletion interface/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1741,7 +1741,7 @@ export const api = {
if (threadId) search.set("thread_id", threadId);
return fetchJson<CortexChatMessagesResponse>(`/cortex-chat/messages?${search}`);
},
cortexChatSend: (agentId: string, threadId: string, message: string, channelId?: string) =>
cortexChatSend: (agentId: string, threadId: string, message: string, channelId?: string, taskNumber?: number) =>
fetch(`${API_BASE}/cortex-chat/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
Expand All @@ -1750,6 +1750,7 @@ export const api = {
thread_id: threadId,
message,
channel_id: channelId ?? null,
task_number: taskNumber ?? null,
}),
}),
cortexChatThreads: (agentId: string) =>
Expand Down
49 changes: 41 additions & 8 deletions interface/src/components/CortexChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ interface CortexChatPanelProps {
initialPrompt?: string;
/** If true, hides the header bar (useful when embedded in another dialog). */
hideHeader?: boolean;
title?: string;
description?: string;
inputPlaceholder?: string;
starterPrompts?: StarterPrompt[];
fixedThreadId?: string;
taskNumber?: number;
disableThreadControls?: boolean;
}

interface StarterPrompt {
Expand Down Expand Up @@ -103,10 +110,16 @@ function EmptyCortexState({
channelId,
onStarterPrompt,
disabled,
title,
description,
starterPrompts,
}: {
channelId?: string;
onStarterPrompt: (prompt: string) => void;
disabled: boolean;
title: string;
description: string;
starterPrompts: StarterPrompt[];
}) {
const contextHint = channelId
? "Current channel transcript is injected for this send only."
Expand All @@ -116,16 +129,15 @@ function EmptyCortexState({
<div className="mx-auto w-full max-w-md">
<div className="rounded-2xl border border-app-line/40 bg-app-darkBox/15 p-5">
<h3 className="font-plex text-base font-medium text-ink">
Cortex chat
{title}
</h3>
<p className="mt-2 text-sm leading-relaxed text-ink-dull">
System-level control for this agent: memory, tasks, worker inspection,
and direct tool execution.
{description}
</p>
<p className="mt-2 text-tiny text-ink-faint">{contextHint}</p>

<div className="mt-4 grid grid-cols-2 gap-2">
{STARTER_PROMPTS.map((item) => (
{starterPrompts.map((item) => (
<button
key={item.label}
type="button"
Expand Down Expand Up @@ -169,11 +181,13 @@ function CortexChatInput({
onChange,
onSubmit,
isStreaming,
placeholder,
}: {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
isStreaming: boolean;
placeholder: string;
}) {
const textareaRef = useRef<HTMLTextAreaElement>(null);

Expand Down Expand Up @@ -214,7 +228,7 @@ function CortexChatInput({
onChange={(event) => onChange(event.target.value)}
onKeyDown={handleKeyDown}
placeholder={
isStreaming ? "Waiting for response..." : "Message the cortex..."
isStreaming ? "Waiting for response..." : placeholder
}
disabled={isStreaming}
rows={1}
Expand Down Expand Up @@ -360,6 +374,13 @@ export function CortexChatPanel({
onClose,
initialPrompt,
hideHeader,
title = "Cortex",
description = "System-level control for this agent: memory, tasks, worker inspection, and direct tool execution.",
inputPlaceholder = "Message the cortex...",
starterPrompts = STARTER_PROMPTS,
fixedThreadId,
taskNumber,
disableThreadControls = false,
}: CortexChatPanelProps) {
const {
messages,
Expand All @@ -370,7 +391,11 @@ export function CortexChatPanel({
sendMessage,
newThread,
loadThread,
} = useCortexChat(agentId, channelId, {freshThread: !!initialPrompt});
} = useCortexChat(agentId, channelId, {
freshThread: !!initialPrompt && !fixedThreadId,
fixedThreadId,
taskNumber,
});
const [input, setInput] = useState("");
const [threadListOpen, setThreadListOpen] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -407,12 +432,12 @@ export function CortexChatPanel({
};

return (
<div className="flex h-full w-full flex-col p-2">
<div className="flex h-full min-h-0 w-full flex-col overflow-hidden p-2">
{/* Header */}
{!hideHeader && (
<div className="flex h-10 items-center justify-between border-b border-app-line/50 px-3">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-ink">Cortex</span>
<span className="text-sm font-medium text-ink">{title}</span>
{channelId && (
<span className="rounded-full bg-app-box px-2 py-0.5 text-tiny text-ink-faint">
{channelId.length > 20
Expand All @@ -422,6 +447,7 @@ export function CortexChatPanel({
)}
</div>
<div className="flex items-center gap-0.5">
{!disableThreadControls && (
<Popover open={threadListOpen} onOpenChange={setThreadListOpen}>
<PopoverTrigger asChild>
<Button
Expand Down Expand Up @@ -450,6 +476,8 @@ export function CortexChatPanel({
/>
</PopoverContent>
</Popover>
)}
{!disableThreadControls && (
<Button
onClick={newThread}
variant="ghost"
Expand All @@ -460,6 +488,7 @@ export function CortexChatPanel({
>
<HugeiconsIcon icon={PlusSignIcon} className="h-3.5 w-3.5" />
</Button>
)}
{onClose && (
<Button
onClick={onClose}
Expand Down Expand Up @@ -530,6 +559,9 @@ export function CortexChatPanel({
channelId={channelId}
onStarterPrompt={handleStarterPrompt}
disabled={isStreaming || !threadId}
title={title}
description={description}
starterPrompts={starterPrompts}
/>
</div>
)}
Expand All @@ -541,6 +573,7 @@ export function CortexChatPanel({
onChange={setInput}
onSubmit={handleSubmit}
isStreaming={isStreaming}
placeholder={inputPlaceholder}
/>
</div>
</div>
Expand Down
36 changes: 29 additions & 7 deletions interface/src/hooks/useCortexChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,19 +51,34 @@ function generateThreadId(): string {
return generateId();
}

export function useCortexChat(agentId: string, channelId?: string, options?: { freshThread?: boolean }) {
export function useCortexChat(
agentId: string,
channelId?: string,
options?: { freshThread?: boolean; fixedThreadId?: string; taskNumber?: number },
) {
const [messages, setMessages] = useState<CortexChatMessage[]>([]);
const [threadId, setThreadId] = useState<string | null>(null);
const [threadId, setThreadId] = useState<string | null>(options?.fixedThreadId ?? null);
const [isStreaming, setIsStreaming] = useState(false);
const [error, setError] = useState<string | null>(null);
const [toolActivity, setToolActivity] = useState<ToolActivity[]>([]);
const loadedRef = useRef(false);

// Load latest thread on mount, or start fresh if requested
// Load latest thread on mount, or use a fixed/fresh thread if requested
useEffect(() => {
if (loadedRef.current) return;
loadedRef.current = true;

if (options?.fixedThreadId) {
api.cortexChatMessages(agentId, options.fixedThreadId).then((data) => {
setThreadId(data.thread_id);
setMessages(data.messages);
}).catch((error) => {
console.warn("Failed to load fixed cortex chat thread:", error);
setThreadId(options.fixedThreadId ?? null);
});
return;
}

if (options?.freshThread) {
setThreadId(generateThreadId());
return;
Expand All @@ -76,7 +91,7 @@ export function useCortexChat(agentId: string, channelId?: string, options?: { f
console.warn("Failed to load cortex chat history:", error);
setThreadId(generateThreadId());
});
}, [agentId]);
}, [agentId, options?.fixedThreadId, options?.freshThread]);

const sendMessage = useCallback(async (text: string) => {
if (isStreaming || !threadId) return;
Expand All @@ -97,7 +112,13 @@ export function useCortexChat(agentId: string, channelId?: string, options?: { f
setMessages((prev) => [...prev, userMessage]);

try {
const response = await api.cortexChatSend(agentId, threadId, text, channelId);
const response = await api.cortexChatSend(
agentId,
threadId,
text,
channelId,
options?.taskNumber,
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
Expand Down Expand Up @@ -167,7 +188,7 @@ export function useCortexChat(agentId: string, channelId?: string, options?: { f
setIsStreaming(false);
setToolActivity([]);
}
}, [agentId, channelId, threadId, isStreaming]);
}, [agentId, channelId, threadId, isStreaming, options?.taskNumber]);

// Listen for auto-triggered cortex chat messages (e.g. worker results)
// delivered via the global SSE stream.
Expand Down Expand Up @@ -198,11 +219,12 @@ export function useCortexChat(agentId: string, channelId?: string, options?: { f
}, [agentId, threadId, channelId]);

const newThread = useCallback(() => {
if (options?.fixedThreadId) return;
setThreadId(generateThreadId());
setMessages([]);
setError(null);
setToolActivity([]);
}, []);
}, [options?.fixedThreadId]);

const loadThread = useCallback(async (targetThreadId: string) => {
if (isStreaming) return;
Expand Down
Loading
Loading