diff --git a/app/src/components/intelligence/IntelligenceTasksTab.tsx b/app/src/components/intelligence/IntelligenceTasksTab.tsx index 406cab331..9122fb626 100644 --- a/app/src/components/intelligence/IntelligenceTasksTab.tsx +++ b/app/src/components/intelligence/IntelligenceTasksTab.tsx @@ -214,6 +214,26 @@ export default function IntelligenceTasksTab() { }; }, [loadAll]); + // Background board mutations — the dispatcher poller claiming a card, + // `update_task` from an autonomous run, `write_back`, triage, stale-reclaim — + // update the persisted board but emit no live socket event to this tab (the + // progress→socket bridge only fires inside an interactive, client-connected + // turn). So the user-tasks + task-sources boards would otherwise look frozen + // while work happens in the background. Re-read just those two on a light + // interval while the tab is visible; they're local in-process RPC, so it's + // cheap. (A push-based fix would need a core DomainEvent + socket broadcast; + // this keeps the fix isolated to the tab and catches every mutation source.) + useEffect(() => { + const POLL_MS = 4000; + const tick = () => { + if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return; + void fetchPersonalBoard(); + void fetchTaskSourcesBoard(); + }; + const handle = window.setInterval(tick, POLL_MS); + return () => window.clearInterval(handle); + }, [fetchPersonalBoard, fetchTaskSourcesBoard]); + // A task created from the composer lands either on the personal board or // on a chosen conversation thread. `add` returns the updated board, so we // merge it directly — re-fetching listTurnStates would return a stale @@ -397,7 +417,7 @@ export default function IntelligenceTasksTab() { status: 'todo', objective: draft.objective, notes: draft.notes, - assignedAgent: 'agent_coder', + assignedAgent: 'orchestrator', approvalMode: 'not_required', plan: draft.plan, allowedTools: draft.allowedTools, diff --git a/app/src/components/intelligence/UserTaskComposer.test.tsx b/app/src/components/intelligence/UserTaskComposer.test.tsx index 78d8b5088..5c0b129e5 100644 --- a/app/src/components/intelligence/UserTaskComposer.test.tsx +++ b/app/src/components/intelligence/UserTaskComposer.test.tsx @@ -18,10 +18,11 @@ vi.mock('../../store/hooks', () => ({ vi.mock('../../services/api/todosApi', () => ({ USER_TASKS_THREAD_ID: 'user-tasks', - todosApi: { add: vi.fn() }, + todosApi: { add: vi.fn(), edit: vi.fn() }, })); const mockAdd = vi.mocked(todosApi.add); +const mockEdit = vi.mocked(todosApi.edit); function emptyBoard(threadId: string) { return { threadId, cards: [], updatedAt: '' }; @@ -83,6 +84,57 @@ describe('UserTaskComposer', () => { expect(mockAdd.mock.calls[0][0].threadId).toBe('t-1'); }); + it('assigns the new card to the orchestrator atomically when "assign to agent" is on', async () => { + mockAdd.mockResolvedValueOnce(emptyBoard(USER_TASKS_THREAD_ID)); + render(); + + fireEvent.change(screen.getByPlaceholderText('What needs to be done?'), { + target: { value: 'Ship it' }, + }); + fireEvent.click(screen.getByRole('checkbox')); + fireEvent.click(screen.getByRole('button', { name: 'Create task' })); + + // Single atomic add carries the assignment — no separate edit (no race). + await waitFor(() => expect(mockAdd).toHaveBeenCalledTimes(1)); + expect(mockAdd).toHaveBeenCalledWith( + expect.objectContaining({ + threadId: USER_TASKS_THREAD_ID, + content: 'Ship it', + assignedAgent: 'orchestrator', + approvalMode: 'not_required', + }) + ); + expect(mockEdit).not.toHaveBeenCalled(); + }); + + it('does not assign an agent when the toggle is left off', async () => { + mockAdd.mockResolvedValueOnce(emptyBoard(USER_TASKS_THREAD_ID)); + render(); + + fireEvent.change(screen.getByPlaceholderText('What needs to be done?'), { + target: { value: 'Buy milk' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Create task' })); + + await waitFor(() => expect(mockAdd).toHaveBeenCalledTimes(1)); + expect(mockEdit).not.toHaveBeenCalled(); + }); + + it('disables assign-to-agent when the task is attached to a conversation', () => { + render(); + fireEvent.change(screen.getByPlaceholderText('What needs to be done?'), { + target: { value: 'Book hotel' }, + }); + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).toBeEnabled(); + // Attaching to a thread takes it off the personal board — the poller doesn't + // poll conversation threads, so auto-run is disabled there. + fireEvent.change(screen.getByDisplayValue('Personal (no conversation)'), { + target: { value: 't-1' }, + }); + expect(checkbox).toBeDisabled(); + }); + it('surfaces an error and keeps the modal open on failure', async () => { mockAdd.mockRejectedValueOnce(new Error('boom')); const onClose = vi.fn(); diff --git a/app/src/components/intelligence/UserTaskComposer.tsx b/app/src/components/intelligence/UserTaskComposer.tsx index 53fb25390..b8247205e 100644 --- a/app/src/components/intelligence/UserTaskComposer.tsx +++ b/app/src/components/intelligence/UserTaskComposer.tsx @@ -39,6 +39,11 @@ export function UserTaskComposer({ onCreated, onClose }: UserTaskComposerProps) const [objective, setObjective] = useState(''); const [notes, setNotes] = useState(''); const [attachThreadId, setAttachThreadId] = useState(''); + // When on, the new personal-board card is assigned to the orchestrator so the + // task dispatcher's poller auto-picks and runs it. Off → a plain manual todo + // the poller never touches. Only meaningful on the personal board (the poller + // doesn't poll attached conversation threads), so it's disabled when attaching. + const [assignToAgent, setAssignToAgent] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -53,17 +58,25 @@ export function UserTaskComposer({ onCreated, onClose }: UserTaskComposerProps) const trimmedTitle = title.trim(); if (!trimmedTitle || submitting) return; const threadId = attachThreadId || USER_TASKS_THREAD_ID; + // Auto-pick only works on the personal board (the poller doesn't poll + // attached conversation threads), so ignore the toggle when attaching. + const assign = assignToAgent && !attachThreadId; setSubmitting(true); setError(null); - log('submit threadId=%s status=%s', threadId, status); + log('submit threadId=%s status=%s assign=%s', threadId, status, assign); try { + // Assigning to the orchestrator + waiving the per-card approval gate so + // the dispatcher's poller picks it up and runs it — done atomically in + // the single `add` call (no create-then-edit race / partial failure). const board = await todosApi.add({ threadId, content: trimmedTitle, status, objective: objective.trim() || null, notes: notes.trim() || null, + ...(assign ? { assignedAgent: 'orchestrator', approvalMode: 'not_required' } : {}), }); + onCreated(threadId, board); onClose(); } catch (err) { @@ -168,6 +181,24 @@ export function UserTaskComposer({ onCreated, onClose }: UserTaskComposerProps) /> + + {error && (

{t('intelligence.tasks.composer.createFailed')}: {error} diff --git a/app/src/components/intelligence/__tests__/IntelligenceTasksTab.test.tsx b/app/src/components/intelligence/__tests__/IntelligenceTasksTab.test.tsx index cebae12ac..98c1191f3 100644 --- a/app/src/components/intelligence/__tests__/IntelligenceTasksTab.test.tsx +++ b/app/src/components/intelligence/__tests__/IntelligenceTasksTab.test.tsx @@ -336,7 +336,7 @@ describe('IntelligenceTasksTab', () => { expect.objectContaining({ threadId: 'user-tasks', id: 'agent-task-1', - assignedAgent: 'agent_coder', + assignedAgent: 'orchestrator', approvalMode: 'not_required', }) ); @@ -497,6 +497,35 @@ describe('IntelligenceTasksTab', () => { expect(hoisted.todosRemove).toHaveBeenCalledWith('user-tasks', 'card-0'); }); + test('re-polls the personal + task-source boards on an interval (background runs show live)', async () => { + vi.useFakeTimers(); + try { + hoisted.todosList.mockImplementation((threadId: string) => + Promise.resolve(makeBoard(threadId, [])) + ); + vi.resetModules(); + const Tab = await importTab(); + renderTab(Tab); + + // Flush the mount effect's setTimeout(0) + the initial loadAll fetches. + await vi.advanceTimersByTimeAsync(0); + const userBefore = hoisted.todosList.mock.calls.filter(c => c[0] === 'user-tasks').length; + const sourceBefore = hoisted.todosList.mock.calls.filter(c => c[0] === 'task-sources').length; + expect(userBefore).toBeGreaterThan(0); + expect(sourceBefore).toBeGreaterThan(0); + + // One poll interval later, both boards are re-read (so a background poller + // run's board changes surface without a manual refresh). + await vi.advanceTimersByTimeAsync(4000); + const userAfter = hoisted.todosList.mock.calls.filter(c => c[0] === 'user-tasks').length; + const sourceAfter = hoisted.todosList.mock.calls.filter(c => c[0] === 'task-sources').length; + expect(userAfter).toBeGreaterThan(userBefore); + expect(sourceAfter).toBeGreaterThan(sourceBefore); + } finally { + vi.useRealTimers(); + } + }); + test('opens the composer and applies the created personal board', async () => { vi.resetModules(); const Tab = await importTab(); diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index ff6066929..f6d7515fc 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -2601,6 +2601,9 @@ const messages: TranslationMap = { 'intelligence.tasks.composer.create': 'إنشاء مهمة', 'intelligence.tasks.composer.creating': 'خلق...', 'intelligence.tasks.composer.createFailed': 'لا يمكن خلق المهمة', + 'intelligence.tasks.composer.assignAgentLabel': 'دع وكيلًا يعمل على هذه المهمة تلقائيًا', + 'intelligence.tasks.composer.assignAgentHint': + 'تلتقطها لوحة المهام وتنفّذها نيابةً عنك. اتركها مغلقة للحصول على مهمة شخصية عادية.', 'intelligence.tasks.sourceList.subtitle': 'مهام المصادر التي تنتظر التحول إلى عمل للوكيل.', 'intelligence.tasks.sourceList.empty': 'لا توجد مهام مصادر في الانتظار.', 'intelligence.tasks.sourceList.queued': 'في قائمة الانتظار', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index f730b2131..0a046ea99 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -2650,6 +2650,9 @@ const messages: TranslationMap = { 'intelligence.tasks.composer.create': 'কাজ তৈরি করুন ( T)', 'intelligence.tasks.composer.creating': 'তৈরি করা হচ্ছে...', 'intelligence.tasks.composer.createFailed': 'কাজ তৈরি করতে ব্যর্থ', + 'intelligence.tasks.composer.assignAgentLabel': 'একজন এজেন্টকে স্বয়ংক্রিয়ভাবে এটি করতে দিন', + 'intelligence.tasks.composer.assignAgentHint': + 'টাস্ক বোর্ড এটি তুলে নিয়ে আপনার হয়ে চালাবে। সাধারণ ব্যক্তিগত কাজের জন্য বন্ধ রাখুন।', 'intelligence.tasks.sourceList.subtitle': 'এজেন্টের কাজে রূপান্তরের অপেক্ষায় থাকা উৎস কাজ।', 'intelligence.tasks.sourceList.empty': 'অপেক্ষায় কোনো উৎস কাজ নেই।', 'intelligence.tasks.sourceList.queued': 'সারিবদ্ধ', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index e368cf807..47c825116 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -2716,6 +2716,9 @@ const messages: TranslationMap = { 'intelligence.tasks.composer.create': 'Aufgabe erstellen', 'intelligence.tasks.composer.creating': 'Erstellen…', 'intelligence.tasks.composer.createFailed': 'Die Aufgabe konnte nicht erstellt werden', + 'intelligence.tasks.composer.assignAgentLabel': 'Einen Agenten automatisch daran arbeiten lassen', + 'intelligence.tasks.composer.assignAgentHint': + 'Das Aufgabenboard übernimmt sie und erledigt sie für dich. Für eine einfache persönliche Aufgabe ausgeschaltet lassen.', 'intelligence.tasks.sourceList.subtitle': 'Quellaufgaben, die in Agentenarbeit umgewandelt werden sollen.', 'intelligence.tasks.sourceList.empty': 'Keine Quellaufgaben in der Warteschlange.', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index cf1525081..7a0ac1ad2 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -3036,6 +3036,9 @@ const en: TranslationMap = { 'intelligence.tasks.composer.create': 'Create task', 'intelligence.tasks.composer.creating': 'Creating…', 'intelligence.tasks.composer.createFailed': "Couldn't create the task", + 'intelligence.tasks.composer.assignAgentLabel': 'Let an agent work on this automatically', + 'intelligence.tasks.composer.assignAgentHint': + 'The task board picks it up and runs it for you. Leave off for a plain personal to-do.', 'intelligence.tasks.sourceList.subtitle': 'Source tasks waiting to become agent work.', 'intelligence.tasks.sourceList.empty': 'No source tasks waiting.', 'intelligence.tasks.sourceList.queued': 'Queued', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 3af52424f..64ea9458a 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -2700,6 +2700,10 @@ const messages: TranslationMap = { 'intelligence.tasks.composer.create': 'Crear tarea', 'intelligence.tasks.composer.creating': 'Creando…', 'intelligence.tasks.composer.createFailed': 'No se pudo crear la tarea', + 'intelligence.tasks.composer.assignAgentLabel': + 'Deja que un agente se encargue de esto automáticamente', + 'intelligence.tasks.composer.assignAgentHint': + 'El tablero de tareas la toma y la ejecuta por ti. Déjalo desactivado para una tarea personal simple.', 'intelligence.tasks.sourceList.subtitle': 'Tareas de fuentes esperando convertirse en trabajo del agente.', 'intelligence.tasks.sourceList.empty': 'No hay tareas de fuentes en espera.', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 83f7de6ce..47710d080 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -2709,6 +2709,9 @@ const messages: TranslationMap = { 'intelligence.tasks.composer.create': 'Créer une tâche', 'intelligence.tasks.composer.creating': 'Création…', 'intelligence.tasks.composer.createFailed': 'Impossible de créer la tâche', + 'intelligence.tasks.composer.assignAgentLabel': "Laisser un agent s'en charger automatiquement", + 'intelligence.tasks.composer.assignAgentHint': + "Le tableau des tâches la prend en charge et l'exécute pour vous. Laissez désactivé pour une simple tâche personnelle.", 'intelligence.tasks.sourceList.subtitle': "Tâches de sources en attente de devenir du travail d'agent.", 'intelligence.tasks.sourceList.empty': 'Aucune tâche de source en attente.', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 0f7877d05..3bd4a1963 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -2655,6 +2655,9 @@ const messages: TranslationMap = { 'intelligence.tasks.composer.create': 'कार्य', 'intelligence.tasks.composer.creating': 'बनाना', 'intelligence.tasks.composer.createFailed': 'काम नहीं कर सका', + 'intelligence.tasks.composer.assignAgentLabel': 'किसी एजेंट को इसे स्वचालित रूप से करने दें', + 'intelligence.tasks.composer.assignAgentHint': + 'टास्क बोर्ड इसे उठाकर आपके लिए चला देगा। सामान्य निजी कार्य के लिए इसे बंद रखें।', 'intelligence.tasks.sourceList.subtitle': 'एजेंट कार्य बनने की प्रतीक्षा कर रहे स्रोत कार्य।', 'intelligence.tasks.sourceList.empty': 'कोई स्रोत कार्य प्रतीक्षा में नहीं है।', 'intelligence.tasks.sourceList.queued': 'कतार में', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 33dde55c1..b35e8ffe9 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -2657,6 +2657,9 @@ const messages: TranslationMap = { 'intelligence.tasks.composer.create': 'Buat tugas', 'intelligence.tasks.composer.creating': 'Membuat…', 'intelligence.tasks.composer.createFailed': 'Gagal membuat tugas', + 'intelligence.tasks.composer.assignAgentLabel': 'Biarkan agen mengerjakan ini secara otomatis', + 'intelligence.tasks.composer.assignAgentHint': + 'Papan tugas akan mengambil dan menjalankannya untuk Anda. Biarkan nonaktif untuk tugas pribadi biasa.', 'intelligence.tasks.sourceList.subtitle': 'Tugas sumber yang menunggu menjadi pekerjaan agen.', 'intelligence.tasks.sourceList.empty': 'Tidak ada tugas sumber yang menunggu.', 'intelligence.tasks.sourceList.queued': 'Dalam antrean', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index a3cac5a9b..1806d658d 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -2690,6 +2690,10 @@ const messages: TranslationMap = { 'intelligence.tasks.composer.create': 'Crea attività', 'intelligence.tasks.composer.creating': 'Creando…', 'intelligence.tasks.composer.createFailed': 'Impossibile creare il task', + 'intelligence.tasks.composer.assignAgentLabel': + 'Lascia che un agente se ne occupi automaticamente', + 'intelligence.tasks.composer.assignAgentHint': + 'La bacheca delle attività la prende in carico e la esegue per te. Lascia disattivato per una semplice attività personale.', 'intelligence.tasks.sourceList.subtitle': "Attività dalle fonti in attesa di diventare lavoro dell'agente.", 'intelligence.tasks.sourceList.empty': 'Nessuna attività da fonti in attesa.', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index b6e65e04b..bc3feedd9 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -2630,6 +2630,9 @@ const messages: TranslationMap = { 'intelligence.tasks.composer.create': '작업 만들기', 'intelligence.tasks.composer.creating': '만드는 중…', 'intelligence.tasks.composer.createFailed': '작업을 만들 수 없습니다', + 'intelligence.tasks.composer.assignAgentLabel': '에이전트가 이 작업을 자동으로 처리하도록 하기', + 'intelligence.tasks.composer.assignAgentHint': + '작업 보드가 이를 가져와 대신 실행합니다. 일반 개인 할 일은 꺼 두세요.', 'intelligence.tasks.sourceList.subtitle': '에이전트 작업으로 전환될 소스 작업입니다.', 'intelligence.tasks.sourceList.empty': '대기 중인 소스 작업이 없습니다.', 'intelligence.tasks.sourceList.queued': '대기열에 추가됨', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 6d7e61b37..a21e43a65 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -2687,6 +2687,9 @@ const messages: TranslationMap = { 'intelligence.tasks.composer.create': 'Utwórz zadanie', 'intelligence.tasks.composer.creating': 'Tworzenie…', 'intelligence.tasks.composer.createFailed': 'Nie udało się utworzyć zadania', + 'intelligence.tasks.composer.assignAgentLabel': 'Pozwól agentowi zająć się tym automatycznie', + 'intelligence.tasks.composer.assignAgentHint': + 'Tablica zadań przejmie je i wykona za Ciebie. Wyłącz dla zwykłego zadania osobistego.', 'intelligence.tasks.sourceList.subtitle': 'Zadania ze źródeł czekające na przekształcenie w pracę agenta.', 'intelligence.tasks.sourceList.empty': 'Brak oczekujących zadań ze źródeł.', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 84b4ab252..d53f6c584 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -2698,6 +2698,9 @@ const messages: TranslationMap = { 'intelligence.tasks.composer.create': 'Criar tarefa', 'intelligence.tasks.composer.creating': 'Criando…', 'intelligence.tasks.composer.createFailed': 'Não foi possível criar a tarefa', + 'intelligence.tasks.composer.assignAgentLabel': 'Deixe um agente cuidar disto automaticamente', + 'intelligence.tasks.composer.assignAgentHint': + 'O quadro de tarefas o executa por você. Deixe desativado para uma tarefa pessoal simples.', 'intelligence.tasks.sourceList.subtitle': 'Tarefas de fontes aguardando virar trabalho de agente.', 'intelligence.tasks.sourceList.empty': 'Nenhuma tarefa de fonte aguardando.', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index e5de78ece..0a3c44d35 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -2670,6 +2670,9 @@ const messages: TranslationMap = { 'intelligence.tasks.composer.create': 'Создать задачу', 'intelligence.tasks.composer.creating': 'Создание…', 'intelligence.tasks.composer.createFailed': 'Не удалось создать задачу', + 'intelligence.tasks.composer.assignAgentLabel': 'Поручить агенту выполнить это автоматически', + 'intelligence.tasks.composer.assignAgentHint': + 'Доска задач возьмёт её и выполнит за вас. Оставьте выключенным для обычной личной задачи.', 'intelligence.tasks.sourceList.subtitle': 'Задачи из источников, ожидающие превращения в работу агента.', 'intelligence.tasks.sourceList.empty': 'Нет ожидающих задач из источников.', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index c6d68f59d..b914e8b58 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -2523,6 +2523,9 @@ const messages: TranslationMap = { 'intelligence.tasks.composer.create': '创建任务', 'intelligence.tasks.composer.creating': '正在创建…', 'intelligence.tasks.composer.createFailed': '无法创建任务', + 'intelligence.tasks.composer.assignAgentLabel': '让智能体自动处理此任务', + 'intelligence.tasks.composer.assignAgentHint': + '任务看板会接手并替你执行。普通的个人待办请保持关闭。', 'intelligence.tasks.sourceList.subtitle': '等待转为智能体工作的来源任务。', 'intelligence.tasks.sourceList.empty': '没有等待处理的来源任务。', 'intelligence.tasks.sourceList.queued': '已入队', diff --git a/app/src/services/api/todosApi.ts b/app/src/services/api/todosApi.ts index d396086a1..af981bf36 100644 --- a/app/src/services/api/todosApi.ts +++ b/app/src/services/api/todosApi.ts @@ -49,6 +49,8 @@ export interface AddTodoInput { status?: TaskBoardCardStatus; objective?: string | null; notes?: string | null; + assignedAgent?: string | null; + approvalMode?: TaskApprovalMode | null; } /** Fields accepted when editing a card. Omitted fields are left unchanged. */ @@ -120,6 +122,8 @@ export const todosApi = { status: input.status, objective: input.objective, notes: input.notes, + assignedAgent: input.assignedAgent, + approvalMode: input.approvalMode, }), }); return snapshotToBoard(snap, input.threadId); diff --git a/src/openhuman/agent/task_dispatcher.rs b/src/openhuman/agent/task_dispatcher.rs index e1ee7e492..8df1ea2ec 100644 --- a/src/openhuman/agent/task_dispatcher.rs +++ b/src/openhuman/agent/task_dispatcher.rs @@ -26,9 +26,9 @@ use crate::openhuman::agent::harness::definition::{AgentDefinitionRegistry, Prom use crate::openhuman::agent::harness::session::Agent; use crate::openhuman::agent::harness::subagent_runner::with_autonomous_iter_cap; use crate::openhuman::agent::personality_paths::PersonalityContext; -use crate::openhuman::agent::task_board::{TaskBoardCard, TaskCardStatus}; +use crate::openhuman::agent::task_board::{TaskApprovalMode, TaskBoardCard, TaskCardStatus}; use crate::openhuman::config::Config; -use crate::openhuman::todos::ops::{self, BoardLocation, CardPatch}; +use crate::openhuman::todos::ops::{self, BoardLocation, CardPatch, USER_TASKS_THREAD_ID}; use crate::openhuman::todos::runs::{self, RunLimits, RunOutcome}; /// Max chars of a personality SOUL.md / MEMORY.md or skill guideline block @@ -129,6 +129,33 @@ pub fn build_task_prompt(card: &TaskBoardCard) -> String { lines.join("\n") } +/// Instruction appended to the run prompt so the autonomous turn keeps its own +/// task card current via the `update_task` tool while it works. +/// +/// The card is already `in_progress` (the dispatcher claimed it before +/// spawning the run), addressed by the exact card id + board the run owns +/// (without the explicit `threadId` the tool defaults to the `task-sources` +/// board and would miss a `user-tasks` card). Two things this asks for: +/// 1. *progress* updates (notes/evidence) as the run works, and +/// 2. an explicit `status: blocked` + `blocker` when the run needs a +/// decision/information from the user or cannot proceed — which +/// [`write_back`] now preserves rather than force-completing, so the task +/// pauses for the user instead of being silently marked done. +fn build_progress_instruction(card_id: &str, thread_id: &str) -> String { + format!( + "\n\nThis task is tracked as card `{card_id}` on the `{thread_id}` board. As you work, \ + call the `update_task` tool (id `{card_id}`, threadId `{thread_id}`) to keep the card \ + current — append `notes`/`evidence` as you make progress.\n\nIf you need a decision or \ + information from the user, or you genuinely cannot proceed (missing access, ambiguous \ + requirement, an action that needs the user's confirmation), call `update_task` with \ + `status: blocked` and a `blocker` that states exactly what you need from the user. The \ + task will stay paused in that blocked state until the user responds — do NOT guess, \ + fabricate, or take a risky irreversible action just to avoid blocking. If instead you \ + finish the work, end with a summary of what you did and the evidence; completion is \ + recorded automatically." + ) +} + /// Outcome of a dispatch attempt. #[derive(Debug)] pub enum DispatchOutcome { @@ -161,7 +188,16 @@ pub async fn dispatch_card( // approval before it can run. `Ready` (already approved) bypasses. We // attempt the AwaitingApproval claim first so the gate is also atomic — // two dispatchers racing the same Todo card won't both park it. - if config.autonomy.require_task_plan_approval { + // + // A card explicitly marked `approval_mode = NotRequired` also bypasses the + // gate: it has already cleared human review (e.g. a task approved out of + // the `task-sources` inbox onto the `user-tasks` board, stamped + // `not_required` at approval time). Re-parking it under the global default + // would strand it on a board nobody approves from. Per-card opt-out wins. + if requires_plan_approval( + config.autonomy.require_task_plan_approval, + card.approval_mode.as_ref(), + ) { match ops::claim_card( &location, &card_id, @@ -199,7 +235,14 @@ pub async fn dispatch_card( ) .map_err(|e| format!("[task_dispatcher] claim rejected for {card_id}: {e}"))?; - let prompt = build_task_prompt(&fresh_card); + let mut prompt = build_task_prompt(&fresh_card); + // Tell the run which card it owns so it can post live progress via the + // `update_task` tool (notes/evidence) as it works. The terminal + // `done`/`blocked` transition is still stamped deterministically by + // `write_back` from the run outcome. + if let Some(thread_id) = location.thread_id() { + prompt.push_str(&build_progress_instruction(&card_id, thread_id)); + } let run_id = uuid::Uuid::new_v4().to_string(); @@ -400,48 +443,77 @@ async fn run_autonomous( /// Success → `done` + evidence; failure → `blocked` + blocker reason. An /// external write failure here is logged, never propagated — the run already /// happened. +/// Current persisted status of a card, or `None` if the board can't be read or +/// the card is gone. Used by `write_back` to detect a run that blocked itself. +fn current_card_status(location: &BoardLocation, card_id: &str) -> Option { + ops::list(location) + .ok() + .and_then(|snap| snap.cards.into_iter().find(|c| c.id == card_id)) + .map(|c| c.status) +} + fn write_back( location: &BoardLocation, card_id: &str, run_id: &str, outcome: Result, ) { - let patch = match &outcome { - Ok(output) => { - tracing::info!( - card_id = %card_id, - run_id = %run_id, - output_chars = output.chars().count(), - "[task_dispatcher] run complete → done" - ); - CardPatch { - status: Some(TaskCardStatus::Done), - evidence: Some(vec![truncate_chars(output.trim(), EVIDENCE_MAX_CHARS)]), - ..Default::default() + // Respect a status the run set for itself: if the agent marked the card + // `blocked` via `update_task` (it needs a decision/input from the user, or + // genuinely cannot proceed), leave it blocked — do NOT force-complete it. + // The task then stays paused in that state until the user responds, instead + // of a "clean turn" being silently recorded as done. Otherwise mark done + // with evidence; a run error marks blocked with the error as the blocker. + let agent_self_blocked = + outcome.is_ok() && current_card_status(location, card_id) == Some(TaskCardStatus::Blocked); + + let patch = if agent_self_blocked { + tracing::info!( + card_id = %card_id, + run_id = %run_id, + "[task_dispatcher] run ended with card self-blocked → leaving blocked (awaiting user input), not auto-completing" + ); + None + } else { + match &outcome { + Ok(output) => { + tracing::info!( + card_id = %card_id, + run_id = %run_id, + output_chars = output.chars().count(), + "[task_dispatcher] run complete → done" + ); + Some(CardPatch { + status: Some(TaskCardStatus::Done), + evidence: Some(vec![truncate_chars(output.trim(), EVIDENCE_MAX_CHARS)]), + ..Default::default() + }) } - } - Err(err) => { - tracing::warn!( - card_id = %card_id, - run_id = %run_id, - error = %err, - "[task_dispatcher] run failed → blocked" - ); - CardPatch { - status: Some(TaskCardStatus::Blocked), - blocker: Some(truncate_chars(err, EVIDENCE_MAX_CHARS)), - ..Default::default() + Err(err) => { + tracing::warn!( + card_id = %card_id, + run_id = %run_id, + error = %err, + "[task_dispatcher] run failed → blocked" + ); + Some(CardPatch { + status: Some(TaskCardStatus::Blocked), + blocker: Some(truncate_chars(err, EVIDENCE_MAX_CHARS)), + ..Default::default() + }) } } }; - if let Err(e) = ops::edit(location, card_id, patch) { - tracing::error!( - card_id = %card_id, - run_id = %run_id, - error = %e, - "[task_dispatcher] board write-back failed (run outcome lost from board)" - ); + if let Some(patch) = patch { + if let Err(e) = ops::edit(location, card_id, patch) { + tracing::error!( + card_id = %card_id, + run_id = %run_id, + error = %e, + "[task_dispatcher] board write-back failed (run outcome lost from board)" + ); + } } let (run_outcome, run_error, run_evidence) = match &outcome { @@ -510,9 +582,19 @@ pub fn start_board_poller() { }); } -/// One poller tick: dispatch the highest-urgency `todo` card on the -/// task-sources board, if any and if capacity allows. `pub(crate)` so tests can +/// One poller tick: sweep each executor board and dispatch its highest-urgency +/// dispatchable card, if any and if capacity allows. `pub(crate)` so tests can /// drive a tick without the real interval. +/// +/// Two boards are swept, each independently (own stale-reclaim + single +/// `in_progress` cap): +/// - **`user-tasks`** (the kanban work board) — always swept, but only +/// **agent-assigned** cards are run, so a human's manually-created todo is +/// never auto-executed. This is where tasks approved out of the inbox run. +/// - **`task-sources`** (the proactive inbox) — swept only when ingestion is +/// enabled. With plan-approval required this only ever parks a `todo` at +/// `awaiting_approval`; it runs a card directly only when approval is off. +/// Kept in the sweep so its stale/wedged runs are still reclaimed. pub(crate) async fn poll_once() -> Result<(), String> { // Gate on background-AI capacity (autonomy / power / pause). Dropping the // permit immediately is fine: this is a "may background work start now" @@ -525,21 +607,50 @@ pub(crate) async fn poll_once() -> Result<(), String> { let config = Config::load_or_init() .await .map_err(|e| format!("load config: {e:#}"))?; - if !config.task_sources.enabled { - return Ok(()); + + // (board location, agent_assigned_only). user-tasks first — it's the real + // work board; task-sources is only included for parking + reclaim. + let mut boards: Vec<(BoardLocation, bool)> = vec![( + BoardLocation::Thread { + workspace_dir: config.workspace_dir.clone(), + thread_id: USER_TASKS_THREAD_ID.to_string(), + }, + true, + )]; + if config.task_sources.enabled { + boards.push(( + BoardLocation::Thread { + workspace_dir: config.workspace_dir.clone(), + thread_id: crate::openhuman::task_sources::TASK_SOURCES_THREAD_ID.to_string(), + }, + false, + )); } - let location = BoardLocation::Thread { - workspace_dir: config.workspace_dir.clone(), - thread_id: crate::openhuman::task_sources::TASK_SOURCES_THREAD_ID.to_string(), - }; + for (location, agent_assigned_only) in boards { + if let Err(e) = poll_board(&location, agent_assigned_only).await { + tracing::warn!( + thread_id = ?location.thread_id(), + error = %e, + "[task_dispatcher:poller] board sweep failed (continuing)" + ); + } + } + Ok(()) +} +/// Sweep one board: reclaim stale runs, then (unless one is already running) +/// dispatch its highest-urgency dispatchable card. When `agent_assigned_only` +/// is set, only cards with an `assigned_agent` are eligible — the guard that +/// keeps the poller off a human's manual `user-tasks` cards. +async fn poll_board(location: &BoardLocation, agent_assigned_only: bool) -> Result<(), String> { // Reclaim stale/wedged runs before looking for new work. Reclaimed // cards move back to `todo` (re-dispatchable) so they appear in the // snapshot below and can be picked up in the same tick. - match runs::reclaim_stale(&location, &RunLimits::default()) { + match runs::reclaim_stale(location, &RunLimits::default()) { Ok(result) if result.reclaimed_count > 0 || result.blocked_count > 0 => { tracing::info!( + thread_id = ?location.thread_id(), reclaimed = result.reclaimed_count, blocked = result.blocked_count, "[task_dispatcher:poller] stale runs reclaimed" @@ -547,6 +658,7 @@ pub(crate) async fn poll_once() -> Result<(), String> { } Err(e) => { tracing::warn!( + thread_id = ?location.thread_id(), error = %e, "[task_dispatcher:poller] stale reclaim failed (continuing)" ); @@ -554,7 +666,7 @@ pub(crate) async fn poll_once() -> Result<(), String> { _ => {} } - let snapshot = ops::list(&location)?; + let snapshot = ops::list(location)?; // `enforce_single_in_progress` caps the board at one running card, so if // one is already in progress there's nothing for this tick to claim. @@ -566,26 +678,39 @@ pub(crate) async fn poll_once() -> Result<(), String> { return Ok(()); } - let Some(card) = pick_next_todo(&snapshot.cards) else { + let Some(card) = pick_next_todo(&snapshot.cards, agent_assigned_only) else { return Ok(()); }; tracing::info!( card_id = %card.id, + thread_id = ?location.thread_id(), urgency = card_urgency(&card), - "[task_dispatcher:poller] dispatching highest-urgency todo card" + agent_assigned_only, + "[task_dispatcher:poller] dispatching highest-urgency dispatchable card" ); - dispatch_card(location, card).await.map(|_| ()) + dispatch_card(location.clone(), card).await.map(|_| ()) } /// Highest-urgency dispatchable card (`todo` or approved `ready`; urgency from /// `source_metadata.urgency`, default 0.0; ties broken toward the lower board /// `order`). Returns a clone. `dispatch_card` then either runs a `ready` card /// or parks a `todo` one for approval, per the autonomy setting. -fn pick_next_todo(cards: &[TaskBoardCard]) -> Option { +/// +/// When `agent_assigned_only` is set, cards without an `assigned_agent` are +/// excluded — used on the `user-tasks` board so the poller runs only +/// agent-generated tasks and never picks up a human's manually-created card. +fn pick_next_todo(cards: &[TaskBoardCard], agent_assigned_only: bool) -> Option { cards .iter() .filter(|c| matches!(c.status, TaskCardStatus::Todo | TaskCardStatus::Ready)) + .filter(|c| { + !agent_assigned_only + || c.assigned_agent + .as_deref() + .map(|a| !a.trim().is_empty()) + .unwrap_or(false) + }) .max_by(|a, b| { card_urgency(a) .partial_cmp(&card_urgency(b)) @@ -597,6 +722,18 @@ fn pick_next_todo(cards: &[TaskBoardCard]) -> Option { .cloned() } +/// Whether a card must be parked at `awaiting_approval` before it can run. +/// +/// The global `require_task_plan_approval` setting applies *unless* the card is +/// explicitly marked `approval_mode = NotRequired` — a per-card opt-out for +/// tasks that have already cleared human review (e.g. approved out of the +/// `task-sources` inbox onto `user-tasks`). Per-card opt-out wins over the +/// global default; without this, an already-approved card would be re-parked +/// and stranded. +fn requires_plan_approval(global_required: bool, approval_mode: Option<&TaskApprovalMode>) -> bool { + global_required && approval_mode != Some(&TaskApprovalMode::NotRequired) +} + fn card_urgency(card: &TaskBoardCard) -> f64 { card.source_metadata .as_ref() @@ -728,7 +865,7 @@ mod tests { card_with("c", TaskCardStatus::Todo, Some(0.8), 2), card_with("d", TaskCardStatus::Todo, None, 3), ]; - let picked = pick_next_todo(&cards).expect("a todo card is available"); + let picked = pick_next_todo(&cards, false).expect("a todo card is available"); assert_eq!( picked.id, "c", "highest-urgency todo wins, done card ignored" @@ -741,13 +878,13 @@ mod tests { card_with("late", TaskCardStatus::Todo, Some(0.5), 5), card_with("early", TaskCardStatus::Todo, Some(0.5), 2), ]; - assert_eq!(pick_next_todo(&cards).unwrap().id, "early"); + assert_eq!(pick_next_todo(&cards, false).unwrap().id, "early"); } #[test] fn poller_returns_none_when_no_todo_cards() { let cards = vec![card_with("a", TaskCardStatus::Done, Some(0.9), 0)]; - assert!(pick_next_todo(&cards).is_none()); + assert!(pick_next_todo(&cards, false).is_none()); } #[test] @@ -759,7 +896,7 @@ mod tests { card_with("rej", TaskCardStatus::Rejected, Some(0.95), 1), card_with("ready", TaskCardStatus::Ready, Some(0.5), 2), ]; - assert_eq!(pick_next_todo(&cards).unwrap().id, "ready"); + assert_eq!(pick_next_todo(&cards, false).unwrap().id, "ready"); } #[test] @@ -768,7 +905,65 @@ mod tests { card_with("ready-low", TaskCardStatus::Ready, Some(0.3), 0), card_with("todo-high", TaskCardStatus::Todo, Some(0.9), 1), ]; - assert_eq!(pick_next_todo(&cards).unwrap().id, "todo-high"); + assert_eq!(pick_next_todo(&cards, false).unwrap().id, "todo-high"); + } + + #[test] + fn poller_agent_only_skips_unassigned_cards() { + // On the user-tasks board we run only agent-assigned cards. A human's + // manual todo (no assigned_agent) must be skipped even at high urgency. + let mut human = card_with("human", TaskCardStatus::Todo, Some(0.99), 0); + human.assigned_agent = None; + let mut agent = card_with("agent", TaskCardStatus::Todo, Some(0.20), 1); + agent.assigned_agent = Some("orchestrator".into()); + let cards = vec![human, agent]; + + // Agent-only: the lower-urgency assigned card wins; the human card is invisible. + assert_eq!(pick_next_todo(&cards, true).unwrap().id, "agent"); + // Unfiltered (task-sources behaviour): highest urgency wins regardless. + assert_eq!(pick_next_todo(&cards, false).unwrap().id, "human"); + } + + #[test] + fn poller_agent_only_returns_none_when_all_unassigned() { + let mut a = card_with("a", TaskCardStatus::Todo, Some(0.9), 0); + a.assigned_agent = None; + let mut b = card_with("b", TaskCardStatus::Todo, Some(0.5), 1); + b.assigned_agent = Some(" ".into()); // blank handle is not "assigned" + let cards = vec![a, b]; + assert!(pick_next_todo(&cards, true).is_none()); + } + + #[test] + fn approval_gate_respects_global_and_per_card_optout() { + // Global off → never park. + assert!(!requires_plan_approval(false, None)); + assert!(!requires_plan_approval( + false, + Some(&TaskApprovalMode::Required) + )); + // Global on → park, unless the card opts out via NotRequired. + assert!(requires_plan_approval(true, None)); + assert!(requires_plan_approval( + true, + Some(&TaskApprovalMode::Required) + )); + assert!(!requires_plan_approval( + true, + Some(&TaskApprovalMode::NotRequired) + )); + } + + #[test] + fn progress_instruction_names_card_thread_and_tool() { + let s = build_progress_instruction("task-42", "user-tasks"); + assert!(s.contains("task-42")); + assert!(s.contains("user-tasks")); + assert!(s.contains("update_task")); + // It must instruct the agent to self-block (status: blocked + blocker) + // when it needs the user, so write_back can preserve that state. + assert!(s.contains("status: blocked")); + assert!(s.contains("blocker")); } #[test] @@ -839,6 +1034,58 @@ mod tests { assert!(card.evidence.iter().any(|e| e.contains("opened PR #5"))); } + #[test] + fn write_back_preserves_agent_set_blocked_on_clean_run() { + // The run marked its own card `blocked` (needs user input) via + // update_task, then ended cleanly. write_back must NOT force it to + // `done` — the task stays blocked, with the agent's blocker intact, + // awaiting the user. + let dir = tempfile::tempdir().unwrap(); + let loc = board_loc(dir.path()); + let id = ops::add(&loc, "update alan", CardPatch::default()) + .unwrap() + .cards[0] + .id + .clone(); + ops::update_status(&loc, &id, TaskCardStatus::InProgress).unwrap(); + // Agent self-blocks mid-run, as build_progress_instruction asks it to. + ops::edit( + &loc, + &id, + CardPatch { + status: Some(TaskCardStatus::Blocked), + blocker: Some("Slack isn't connected — confirm how to reach Alan".to_string()), + ..Default::default() + }, + ) + .unwrap(); + + // Run returns Ok (the turn finished) — but the card is self-blocked. + write_back( + &loc, + &id, + "run-2", + Ok("I checked GitHub and memory…".to_string()), + ); + + let card = ops::list(&loc) + .unwrap() + .cards + .into_iter() + .find(|c| c.id == id) + .unwrap(); + assert_eq!( + card.status, + TaskCardStatus::Blocked, + "a clean run over a self-blocked card must stay blocked, not auto-done" + ); + assert_eq!( + card.blocker.as_deref(), + Some("Slack isn't connected — confirm how to reach Alan"), + "the agent's blocker reason is preserved" + ); + } + #[test] fn write_back_marks_blocked_with_reason_on_failure() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/openhuman/agent/tools.rs b/src/openhuman/agent/tools.rs index 8dd117f64..c73d52a38 100644 --- a/src/openhuman/agent/tools.rs +++ b/src/openhuman/agent/tools.rs @@ -6,6 +6,7 @@ pub mod remember_preference; mod run_skill; pub mod save_preference; mod todo; +mod update_task; pub mod workflow_tools; pub use ask_clarification::AskClarificationTool; @@ -16,4 +17,5 @@ pub use remember_preference::RememberPreferenceTool; pub use run_skill::{RunSkillTool, RUN_SKILL_TOOL_NAME}; pub use save_preference::SavePreferenceTool; pub use todo::TodoTool; +pub use update_task::UpdateTaskTool; pub use workflow_tools::{WorkflowLoadTool, WorkflowPhaseTool}; diff --git a/src/openhuman/agent/tools/update_task.rs b/src/openhuman/agent/tools/update_task.rs new file mode 100644 index 000000000..be222c832 --- /dev/null +++ b/src/openhuman/agent/tools/update_task.rs @@ -0,0 +1,226 @@ +//! `update_task` — move or update a specific task card on a thread's board. +//! +//! `todo` only reaches the *current* thread's board. `update_task` addresses a +//! card **by id** on a *target* board — defaulting to the proactive +//! `task-sources` board — so the orchestrator (or an autonomous task run on its +//! own thread) can advance the task it's working: move it to +//! `in_progress`/`blocked`/`done`, or update its objective/notes/evidence/blocker. +//! +//! It is a thin wrapper over [`crate::openhuman::todos::ops::edit`], which +//! applies the status move + field updates atomically, enforces the +//! single-`in_progress` invariant, and emits the board-progress event that the +//! Tasks board UI listens on. + +use crate::openhuman::task_sources::TASK_SOURCES_THREAD_ID; +use crate::openhuman::todos::ops::{self, BoardLocation, CardPatch}; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; +use std::path::PathBuf; + +pub struct UpdateTaskTool; + +impl UpdateTaskTool { + pub fn new() -> Self { + Self + } +} + +impl Default for UpdateTaskTool { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Tool for UpdateTaskTool { + fn name(&self) -> &str { + "update_task" + } + + fn description(&self) -> &str { + "Move or update a specific task card on a task board, addressed by `id`. \ + Use this to advance the task you're working on: set `status` \ + (`todo`/`in_progress`/`blocked`/`done`) to move it between columns, and/or \ + update `objective`, `notes`, `evidence`, `blocker`, `plan`, \ + `acceptanceCriteria`. When you finish, set `status: done` with `evidence`; \ + if you cannot proceed, set `status: blocked` with a `blocker` reason. \ + Targets the proactive `task-sources` board by default — pass `threadId` to \ + target another thread's board. Returns the updated card list + a markdown \ + rendering. At most one card may be `in_progress` at a time." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "id": { "type": "string", "description": "Id of the card to move/update (required)." }, + "status": { + "type": "string", + "enum": ["todo", "in_progress", "blocked", "done"], + "description": "New status — moves the card to that column." + }, + "objective": { "type": "string", "description": "Updated desired outcome for the task." }, + "notes": { "type": "string", "description": "Progress notes / running summary." }, + "blocker": { "type": "string", "description": "Why the task is blocked (set with status=blocked)." }, + "evidence": { + "type": "array", + "description": "Links, output, or files proving the work (set with status=done).", + "items": { "type": "string" } + }, + "plan": { + "type": "array", + "description": "Updated ordered execution steps.", + "items": { "type": "string" } + }, + "acceptanceCriteria": { + "type": "array", + "description": "Updated checklist that must hold before the task is done.", + "items": { "type": "string" } + }, + "threadId": { + "type": "string", + "description": "Board to target; defaults to the `task-sources` board." + } + }, + "required": ["id"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::None + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let Some(id) = optional_string(&args, "id") else { + return Ok(ToolResult::error("missing required field `id`".to_string())); + }; + + let patch = match build_patch(&args) { + Ok(patch) => patch, + Err(err) => return Ok(ToolResult::error(err)), + }; + if patch_is_empty(&patch) { + return Ok(ToolResult::error( + "nothing to update — provide `status` and/or a field \ + (objective/notes/evidence/blocker/plan/acceptanceCriteria)" + .to_string(), + )); + } + + let location = match resolve_location(&args).await { + Ok(location) => location, + Err(err) => return Ok(ToolResult::error(err)), + }; + + Ok(apply(&location, &id, patch)) + } +} + +/// Apply the move/update to the card and render the result. Split out from +/// `execute` so the edit + response shaping is testable without a fork/thread +/// context (which `resolve_location` needs). +fn apply(location: &BoardLocation, id: &str, patch: CardPatch) -> ToolResult { + tracing::info!( + card_id = %id, + thread_id = ?location.thread_id(), + status = ?patch.status, + "[tool][update_task] move/update task card" + ); + match ops::edit(location, id, patch) { + Ok(snap) => { + let payload = json!({ + "threadId": snap.thread_id, + "cards": snap.cards, + "markdown": snap.markdown, + }); + ToolResult::success(payload.to_string()) + } + Err(err) => ToolResult::error(err), + } +} + +/// Resolve the board to act on: the explicit `threadId` arg, else the proactive +/// `task-sources` board. The workspace root comes from the running agent's fork +/// context when present, otherwise from the loaded config. +async fn resolve_location(args: &serde_json::Value) -> Result { + let thread_id = + optional_string(args, "threadId").unwrap_or_else(|| TASK_SOURCES_THREAD_ID.to_string()); + Ok(BoardLocation::Thread { + workspace_dir: workspace_dir().await?, + thread_id, + }) +} + +async fn workspace_dir() -> Result { + if let Some(parent) = crate::openhuman::agent::harness::fork_context::current_parent() { + return Ok(parent.workspace_dir.clone()); + } + crate::openhuman::config::ops::load_config_with_timeout() + .await + .map(|config| config.workspace_dir) + .map_err(|e| format!("update_task: failed to load config for workspace dir: {e}")) +} + +fn build_patch(args: &serde_json::Value) -> Result { + let status = match args.get("status").and_then(|v| v.as_str()) { + Some(s) => Some(ops::parse_status(s)?), + None => None, + }; + Ok(CardPatch { + status, + objective: optional_string(args, "objective"), + plan: optional_string_array(args, "plan")?, + acceptance_criteria: optional_string_array(args, "acceptanceCriteria")?, + evidence: optional_string_array(args, "evidence")?, + notes: optional_string(args, "notes"), + blocker: optional_string(args, "blocker"), + ..Default::default() + }) +} + +fn patch_is_empty(patch: &CardPatch) -> bool { + patch.status.is_none() + && patch.objective.is_none() + && patch.plan.is_none() + && patch.acceptance_criteria.is_none() + && patch.evidence.is_none() + && patch.notes.is_none() + && patch.blocker.is_none() +} + +fn optional_string(args: &serde_json::Value, key: &str) -> Option { + args.get(key) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn optional_string_array( + args: &serde_json::Value, + key: &str, +) -> Result>, String> { + match args.get(key) { + None => Ok(None), + Some(serde_json::Value::Null) => Ok(None), + Some(serde_json::Value::Array(items)) => { + let mut out = Vec::with_capacity(items.len()); + for item in items { + let s = item + .as_str() + .ok_or_else(|| format!("`{key}` must be an array of strings"))? + .trim(); + if !s.is_empty() { + out.push(s.to_string()); + } + } + Ok(Some(out)) + } + Some(_) => Err(format!("`{key}` must be an array of strings")), + } +} + +#[cfg(test)] +#[path = "update_task_tests.rs"] +mod tests; diff --git a/src/openhuman/agent/tools/update_task_tests.rs b/src/openhuman/agent/tools/update_task_tests.rs new file mode 100644 index 000000000..23774fdb1 --- /dev/null +++ b/src/openhuman/agent/tools/update_task_tests.rs @@ -0,0 +1,123 @@ +//! Tests for the `update_task` tool. + +use super::*; +use crate::openhuman::agent::task_board::TaskCardStatus; +use crate::openhuman::tools::traits::Tool; +use serde_json::json; + +// ── build_patch ────────────────────────────────────────────────────────────── + +#[test] +fn build_patch_maps_status_and_all_fields() { + let patch = build_patch(&json!({ + "status": "in_progress", + "objective": "ship it", + "notes": "halfway", + "blocker": "", + "evidence": ["https://x/1", " ", "note"], + "plan": ["a", "b"], + "acceptanceCriteria": ["covered"] + })) + .expect("valid patch"); + assert_eq!(patch.status, Some(TaskCardStatus::InProgress)); + assert_eq!(patch.objective.as_deref(), Some("ship it")); + assert_eq!(patch.notes.as_deref(), Some("halfway")); + // empty/blank array entries are dropped; blanks elsewhere become None via edit. + assert_eq!( + patch.evidence, + Some(vec!["https://x/1".to_string(), "note".to_string()]) + ); + assert_eq!(patch.plan, Some(vec!["a".to_string(), "b".to_string()])); + assert_eq!(patch.acceptance_criteria, Some(vec!["covered".to_string()])); +} + +#[test] +fn build_patch_empty_args_is_empty() { + let patch = build_patch(&json!({})).expect("ok"); + assert!(patch_is_empty(&patch)); +} + +#[test] +fn build_patch_with_only_status_is_not_empty() { + let patch = build_patch(&json!({ "status": "done" })).expect("ok"); + assert!(!patch_is_empty(&patch)); + assert_eq!(patch.status, Some(TaskCardStatus::Done)); +} + +#[test] +fn build_patch_rejects_unknown_status() { + assert!(build_patch(&json!({ "status": "nonsense" })).is_err()); +} + +#[test] +fn build_patch_rejects_non_string_array_item() { + assert!(build_patch(&json!({ "evidence": [1, 2] })).is_err()); +} + +// ── execute() guard rails (no board/workspace needed) ──────────────────────── + +#[tokio::test] +async fn execute_without_id_is_an_error() { + let res = UpdateTaskTool::new() + .execute(json!({ "status": "done" })) + .await + .unwrap(); + assert!(res.is_error, "missing id must be a tool error"); +} + +#[tokio::test] +async fn execute_with_id_but_no_changes_is_an_error() { + // id present but nothing to change → rejected before any board write. + let res = UpdateTaskTool::new() + .execute(json!({ "id": "task-1" })) + .await + .unwrap(); + assert!(res.is_error, "empty update must be a tool error"); +} + +// ── the move + update applied through ops::edit (the tool's real effect) ───── + +#[test] +fn apply_moves_and_updates_a_card_and_returns_success() { + let dir = tempfile::tempdir().unwrap(); + let location = BoardLocation::Thread { + workspace_dir: dir.path().to_path_buf(), + thread_id: TASK_SOURCES_THREAD_ID.to_string(), + }; + let id = ops::add(&location, "Review PR #5", CardPatch::default()) + .unwrap() + .cards[0] + .id + .clone(); + + // Move to done + attach evidence — the exact shape the tool produces. + let patch = build_patch(&json!({ + "status": "done", + "evidence": ["posted review on PR #5"] + })) + .unwrap(); + let res = apply(&location, &id, patch); + assert!(!res.is_error, "successful move/update must not be an error"); + + // The board reflects the move + evidence. + let card = ops::list(&location) + .unwrap() + .cards + .into_iter() + .find(|c| c.id == id) + .unwrap(); + assert_eq!(card.status, TaskCardStatus::Done); + assert!(card.evidence.iter().any(|e| e.contains("posted review"))); +} + +#[test] +fn apply_on_unknown_id_returns_error() { + let dir = tempfile::tempdir().unwrap(); + let location = BoardLocation::Thread { + workspace_dir: dir.path().to_path_buf(), + thread_id: TASK_SOURCES_THREAD_ID.to_string(), + }; + let patch = build_patch(&json!({ "status": "blocked", "blocker": "no channel" })).unwrap(); + let res = apply(&location, "task-does-not-exist", patch); + assert!(res.is_error, "missing card must surface as a tool error"); +} diff --git a/src/openhuman/agent_registry/agents/orchestrator/agent.toml b/src/openhuman/agent_registry/agents/orchestrator/agent.toml index 0e354a913..2ee6e58ae 100644 --- a/src/openhuman/agent_registry/agents/orchestrator/agent.toml +++ b/src/openhuman/agent_registry/agents/orchestrator/agent.toml @@ -148,6 +148,12 @@ named = [ # downstream agents — the orchestrator coordinates, it doesn't # touch files itself. "todowrite", + # `update_task` moves/updates a specific task card by id on a target board + # (defaults to the proactive `task-sources` board) — so the orchestrator can + # advance the task it's working: → in_progress when it starts, → done with + # evidence when finished, or → blocked with a reason when stuck. Complements + # `todowrite`, which only touches the current thread's board. + "update_task", "plan_exit", # Skill chaining: let an in-flight autonomous skill (e.g. # `github-issue-crusher` after the draft PR is open) spawn another diff --git a/src/openhuman/inference/provider/compatible.rs b/src/openhuman/inference/provider/compatible.rs index 395e5d56c..36ae2a545 100644 --- a/src/openhuman/inference/provider/compatible.rs +++ b/src/openhuman/inference/provider/compatible.rs @@ -41,6 +41,19 @@ use compatible_types::{ ResponsesRequest, StreamChunkResponse, StreamingToolCall, ToolCall, }; +/// `frequency_penalty` applied to streaming chat-completions requests. +/// +/// Autoregressive models have a self-reinforcing bias toward repeating spans +/// already in their context; with no penalty a momentary repeat can spiral into +/// the same line emitted until the output-token cap (degenerate decoding). A +/// small positive penalty damps that loop without harming coherence. Carried on +/// the streaming path (where those loops occur — long autonomous turns) and +/// retried without it if a strict provider rejects it; the buffered +/// non-streaming fallback omits it for maximum compatibility. Skipped in +/// serialisation when `None` so providers that don't accept the field are +/// unaffected. +const CHAT_FREQUENCY_PENALTY: f64 = 0.3; + /// A provider that speaks the OpenAI-compatible chat completions API. /// Used by: Venice, Vercel AI Gateway, Cloudflare AI Gateway, Moonshot, /// Synthetic, `OpenCode` Zen, `Z.AI`, `GLM`, `MiniMax`, Bedrock, Qianfan, Groq, Mistral, `xAI`, etc. @@ -929,6 +942,23 @@ impl OpenAiCompatibleProvider { Self::is_native_tool_schema_unsupported(reqwest::StatusCode::BAD_REQUEST, error) } + /// Detect a provider rejecting the `frequency_penalty` sampling field. Some + /// strict OpenAI-compatible backends 400 on unknown params; when this fires + /// the caller retries once with the field omitted (mirrors the no-tools + /// retry). String-based because the streamed transport error surfaces the + /// API error body. + fn err_indicates_frequency_penalty_unsupported(error: &str) -> bool { + let lower = error.to_lowercase(); + lower.contains("frequency_penalty") + && (lower.contains("unsupported") + || lower.contains("unknown") + || lower.contains("unrecognized") + || lower.contains("not supported") + || lower.contains("does not support") + || lower.contains("invalid") + || lower.contains("unexpected")) + } + /// Detect a 404 whose body says the model is completion-only and cannot be /// served from `/v1/chat/completions` (OpenAI: "This is not a chat model /// and thus not supported in the v1/chat/completions endpoint. Did you @@ -1914,6 +1944,7 @@ impl Provider for OpenAiCompatibleProvider { include_usage: true, }), options: self.build_ollama_options(), + frequency_penalty: Some(CHAT_FREQUENCY_PENALTY), }; let stream_dump_seq = reserve_dump_seq(); dump_prompt_if_enabled(&self.name, model, stream_dump_seq, &native_request); @@ -1956,6 +1987,31 @@ impl Provider for OpenAiCompatibleProvider { ); } } + } else if Self::err_indicates_frequency_penalty_unsupported(&err_str) { + // Symmetric to the no-tools retry: a strict provider that + // 400s on `frequency_penalty` should degrade gracefully + // rather than fail the whole chat path. + log::info!( + "[stream] {} rejected frequency_penalty — retrying streaming without it", + self.name, + ); + let retry_request = NativeChatRequest { + frequency_penalty: None, + ..native_request.clone() + }; + match self + .stream_native_chat(credential, &retry_request, tx, stream_dump_seq) + .await + { + Ok(resp) => return Ok(resp), + Err(retry_err) => { + log::warn!( + "[stream] {} retry without frequency_penalty also failed, falling back to non-streaming: {}", + self.name, + retry_err + ); + } + } } else { log::warn!( "[stream] {} streaming chat failed, falling back to non-streaming: {}", @@ -1963,7 +2019,9 @@ impl Provider for OpenAiCompatibleProvider { err ); } - // Fall through to the non-streaming path below. + // Fall through to the non-streaming path below. The + // non-streaming request below omits `frequency_penalty` so a + // provider that rejected it (streaming or not) still succeeds. } } } @@ -1985,6 +2043,12 @@ impl Provider for OpenAiCompatibleProvider { thread_id, stream_options: None, options: self.build_ollama_options(), + // The buffered (non-streaming) path is the fallback / non-streaming + // provider path — omit `frequency_penalty` here for maximum + // compatibility (a provider that rejects it still succeeds). The + // streaming path above carries it (where degenerate repetition loops + // actually occur) and retries without it on rejection. + frequency_penalty: None, }; let dump_seq = reserve_dump_seq(); dump_prompt_if_enabled(&self.name, model, dump_seq, &native_request); diff --git a/src/openhuman/inference/provider/compatible_tests.rs b/src/openhuman/inference/provider/compatible_tests.rs index 591d03993..a1f3de390 100644 --- a/src/openhuman/inference/provider/compatible_tests.rs +++ b/src/openhuman/inference/provider/compatible_tests.rs @@ -66,6 +66,7 @@ fn native_request_emits_thread_id_when_present() { thread_id: Some("thread-abc".to_string()), stream_options: None, options: None, + frequency_penalty: None, }; let json = serde_json::to_value(&req).unwrap(); assert_eq!( @@ -84,6 +85,7 @@ fn native_request_emits_thread_id_when_present() { thread_id: None, stream_options: None, options: None, + frequency_penalty: None, }; let json_no_thread = serde_json::to_value(&req_no_thread).unwrap(); assert!( @@ -92,6 +94,58 @@ fn native_request_emits_thread_id_when_present() { ); } +#[test] +fn native_request_serializes_frequency_penalty_only_when_set() { + let base = super::NativeChatRequest { + model: "kimi".to_string(), + messages: Vec::new(), + temperature: Some(0.7), + stream: Some(false), + tools: None, + tool_choice: None, + thread_id: None, + stream_options: None, + options: None, + frequency_penalty: Some(0.3), + }; + let json = serde_json::to_value(&base).unwrap(); + assert_eq!( + json.get("frequency_penalty") + .and_then(serde_json::Value::as_f64), + Some(0.3), + "a set frequency_penalty must be forwarded to damp repetition loops" + ); + + let none = super::NativeChatRequest { + frequency_penalty: None, + ..base + }; + let json_none = serde_json::to_value(&none).unwrap(); + assert!( + json_none.get("frequency_penalty").is_none(), + "absent frequency_penalty must be omitted so providers that reject it are unaffected" + ); +} + +#[test] +fn detects_frequency_penalty_rejection_for_retry() { + use super::OpenAiCompatibleProvider as P; + // Strict providers that 400 on the field → retry without it. + assert!(P::err_indicates_frequency_penalty_unsupported( + "400 Bad Request: unknown parameter 'frequency_penalty'" + )); + assert!(P::err_indicates_frequency_penalty_unsupported( + "frequency_penalty is not supported by this model" + )); + // Unrelated errors, or the field merely mentioned, must NOT trigger a retry. + assert!(!P::err_indicates_frequency_penalty_unsupported( + "rate limit exceeded" + )); + assert!(!P::err_indicates_frequency_penalty_unsupported( + "applied frequency_penalty 0.3" + )); +} + /// Streaming responses arrive without `usage` unless the request asks /// for `stream_options.include_usage = true` (OpenAI spec). Without it /// the OpenHuman backend's `openhuman.billing` block also never lands, @@ -111,6 +165,7 @@ fn streaming_request_sets_stream_options_include_usage() { include_usage: true, }), options: None, + frequency_penalty: None, }; let json = serde_json::to_value(&req).unwrap(); assert_eq!( @@ -133,6 +188,7 @@ fn non_streaming_request_omits_stream_options() { thread_id: None, stream_options: None, options: None, + frequency_penalty: None, }; let json = serde_json::to_value(&req).unwrap(); assert!( @@ -155,6 +211,7 @@ fn ollama_options_num_ctx_serializes_correctly() { options: Some(super::compatible_types::OllamaOptions { num_ctx: Some(32768), }), + frequency_penalty: None, }; let json = serde_json::to_value(&req).unwrap(); assert_eq!( @@ -176,6 +233,7 @@ fn ollama_options_none_is_omitted() { thread_id: None, stream_options: None, options: None, + frequency_penalty: None, }; let json = serde_json::to_value(&req).unwrap(); assert!( @@ -756,6 +814,7 @@ async fn streaming_chat_config_rejection_propagates_error_without_sentry_report( include_usage: true, }), options: None, + frequency_penalty: None, }; let (delta_tx, _delta_rx) = tokio::sync::mpsc::channel(8); diff --git a/src/openhuman/inference/provider/compatible_types.rs b/src/openhuman/inference/provider/compatible_types.rs index 5a80aa492..b5cd83e03 100644 --- a/src/openhuman/inference/provider/compatible_types.rs +++ b/src/openhuman/inference/provider/compatible_types.rs @@ -187,6 +187,13 @@ pub(crate) struct NativeChatRequest { /// `num_ctx` override. Ignored (skipped) for non-Ollama providers. #[serde(skip_serializing_if = "Option::is_none")] pub(crate) options: Option, + /// OpenAI-compatible `frequency_penalty`. Set to a small positive value on + /// real requests to damp degenerate repetition loops — without it a model + /// that starts repeating a line keeps emitting it until the output-token + /// cap (self-reinforcing decoding). Skipped when `None` so providers that + /// don't accept it are unaffected. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) frequency_penalty: Option, } /// Ollama-specific request options passed in the `options` field. diff --git a/src/openhuman/todos/ops.rs b/src/openhuman/todos/ops.rs index 14a4478fc..a6fc9e65f 100644 --- a/src/openhuman/todos/ops.rs +++ b/src/openhuman/todos/ops.rs @@ -19,6 +19,15 @@ use std::path::PathBuf; use std::sync::{Arc, OnceLock}; use uuid::Uuid; +/// Thread id backing the personal "user tasks" kanban board. +/// +/// Must match the frontend constant in `app/src/services/api/todosApi.ts`. +/// This is the board the kanban UI renders as the user's working lane and that +/// the task dispatcher's board poller executes **agent-assigned** cards on +/// (tasks approved out of the proactive `task-sources` inbox). Manually-created +/// human cards on this board carry no `assigned_agent` and are never auto-run. +pub const USER_TASKS_THREAD_ID: &str = "user-tasks"; + use super::store::{global_scratch_store, ScratchTodoStore}; /// Serialise scratch CRUD so each public op's load → mutate → save diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 68483f9ad..b1ff14009 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -158,6 +158,11 @@ pub fn all_tools_with_runtime( // build-mode pass. The plan→build mode switch itself is a // follow-up; the tool emits a stable marker today. Box::new(TodoTool::new()), + // Move/update a specific task card by id on a target board (defaults to + // the proactive `task-sources` board) — lets the agent advance the task + // it's working (in_progress / done+evidence / blocked+reason) from any + // thread, complementing `todo` which only touches the current thread. + Box::new(UpdateTaskTool::new()), Box::new(PlanExitTool::new()), // Skill chaining: let an in-flight autonomous skill (e.g. // `github-issue-crusher`) kick off another bundled skill_run as a