mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(tasks): autonomously execute approved/assigned task-board work (poller + update_task + repetition fix) (#3326)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
1a85d242e2
commit
f36c66b5b5
@@ -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,
|
||||
|
||||
@@ -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(<UserTaskComposer onCreated={vi.fn()} onClose={vi.fn()} />);
|
||||
|
||||
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(<UserTaskComposer onCreated={vi.fn()} onClose={vi.fn()} />);
|
||||
|
||||
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(<UserTaskComposer onCreated={vi.fn()} onClose={vi.fn()} />);
|
||||
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();
|
||||
|
||||
@@ -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<string | null>(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)
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={assignToAgent && !attachThreadId}
|
||||
disabled={attachThreadId !== ''}
|
||||
onChange={e => setAssignToAgent(e.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 flex-none rounded border-stone-300 text-ocean-600 focus:ring-ocean-500 disabled:opacity-50 dark:border-neutral-600 dark:bg-neutral-950"
|
||||
/>
|
||||
<span className="text-xs text-stone-600 dark:text-neutral-300">
|
||||
<span className="font-semibold text-stone-700 dark:text-neutral-200">
|
||||
{t('intelligence.tasks.composer.assignAgentLabel')}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-stone-500 dark:text-neutral-400">
|
||||
{t('intelligence.tasks.composer.assignAgentHint')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-md border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
|
||||
{t('intelligence.tasks.composer.createFailed')}: {error}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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': 'في قائمة الانتظار',
|
||||
|
||||
@@ -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': 'সারিবদ্ধ',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': 'कतार में',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': '대기열에 추가됨',
|
||||
|
||||
@@ -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ł.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': 'Нет ожидающих задач из источников.',
|
||||
|
||||
@@ -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': '已入队',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<TaskCardStatus> {
|
||||
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<String, String>,
|
||||
) {
|
||||
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<TaskBoardCard> {
|
||||
///
|
||||
/// 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<TaskBoardCard> {
|
||||
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<TaskBoardCard> {
|
||||
.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();
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<ToolResult> {
|
||||
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<BoardLocation, String> {
|
||||
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<PathBuf, String> {
|
||||
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<CardPatch, String> {
|
||||
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<String> {
|
||||
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<Option<Vec<String>>, 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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<OllamaOptions>,
|
||||
/// 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<f64>,
|
||||
}
|
||||
|
||||
/// Ollama-specific request options passed in the `options` field.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user