mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-22 18:45:19 +02:00
feat: background tasks
Adds Background Tasks — recurring background agents the user can set up to either keep a digest current (daily email summary, top HN stories, weather brief) or perform a recurring action (draft a reply, post to Slack, call an API). Each task is a persistent set of instructions plus optional triggers (schedule, time-of-day window, or matching incoming Gmail / calendar event). The agent reads the verbs in the instructions on every run and picks the right mode automatically. User-facing surfaces: - New "Background tasks" entry in the sidebar, with a table listing every task, its schedule, last run, and an active toggle. - A detail page per task with a max-width reader showing the task's current output and a control sidebar for editing instructions, triggers, and reviewing run history. - "New task" can open in a free-form box where the user describes what they want and Copilot sets it up end-to-end, or in a structured form for manual setup. - "Edit with Copilot" hand-off from the detail view, pre-seeded with the task's context. Under the hood: - The event pipeline that previously powered live-notes is now a generic consumer registry. Live-notes and background tasks both subscribe; incoming events are routed to candidates from both concurrently. - Schedule helpers and the agent-message trigger block are factored out of live-notes into shared modules. Both features use the same building blocks now. - Copilot's proactive routing is reframed: anything recurring (cadence words, watch / monitor verbs, action verbs, event-conditional asks) now flows to background tasks. Live-notes load only on explicit mention. - A small reliability fix for the run-creation fallback chain: an empty-string model/provider passed by an LLM tool call now correctly falls through to the default instead of being persisted as a real value. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
13fa80c687
commit
b01af12148
45 changed files with 4025 additions and 594 deletions
111
apps/x/packages/shared/src/background-task.ts
Normal file
111
apps/x/packages/shared/src/background-task.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import z from 'zod';
|
||||
import { TriggersSchema, type Triggers, type TriggerWindow } from './live-note.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Background tasks
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// A bg-task is a persistent agent set up to fire on a schedule and/or in
|
||||
// response to incoming events. Each task owns a folder under
|
||||
// `$WorkDir/bg-tasks/<slug>/`:
|
||||
//
|
||||
// bg-tasks/<slug>/
|
||||
// ├── task.yaml # plain YAML: BackgroundTask shape
|
||||
// ├── index.md # agent-owned body — the user-visible artifact
|
||||
// └── runs/ # one <runId>.jsonl per run (written by agent runtime)
|
||||
//
|
||||
// The agent picks between OUTPUT mode (rewrite index.md with the latest state)
|
||||
// and ACTION mode (perform a side-effect, append a journal entry) based on
|
||||
// the verbs in `instructions` each run. No mode field on the task.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Re-export triggers so callers don't need a second import.
|
||||
export { TriggersSchema, type Triggers, type TriggerWindow };
|
||||
|
||||
export type BackgroundTask = {
|
||||
name: string;
|
||||
instructions: string;
|
||||
active: boolean;
|
||||
triggers?: Triggers;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
createdAt: string;
|
||||
// Runtime-managed — never hand-write. Mirrors live-note's flat-field
|
||||
// pattern: `lastAttemptAt` is bumped at every run start (backoff anchor),
|
||||
// `lastRunAt` / `lastRunSummary` only on successful completion, `lastRunError`
|
||||
// only on failure (cleared on next success). This keeps the "last good run"
|
||||
// visible even while a new run is in-flight or failing.
|
||||
lastAttemptAt?: string;
|
||||
lastRunId?: string;
|
||||
lastRunAt?: string;
|
||||
lastRunSummary?: string;
|
||||
lastRunError?: string;
|
||||
};
|
||||
|
||||
export type BackgroundTaskSummary = {
|
||||
slug: string;
|
||||
name: string;
|
||||
instructions: string;
|
||||
active: boolean;
|
||||
triggers?: Triggers;
|
||||
createdAt: string;
|
||||
lastAttemptAt?: string;
|
||||
lastRunId?: string;
|
||||
lastRunAt?: string;
|
||||
lastRunSummary?: string;
|
||||
lastRunError?: string;
|
||||
};
|
||||
|
||||
export const BackgroundTaskSchema = z.object({
|
||||
name: z.string().min(1).describe('User-facing display name.'),
|
||||
instructions: z.string().min(1).describe('A persistent instruction in the user\'s words — what should this task keep doing? E.g. "Summarize my unread emails every morning into a brief digest." The agent re-reads instructions on every run and decides whether to rewrite index.md (OUTPUT mode) or perform a side-effect and journal it (ACTION mode) based on the verbs.'),
|
||||
active: z.boolean().default(true).describe('Set false to pause without deleting.'),
|
||||
triggers: TriggersSchema.optional().describe('When the agent fires. Omit for manual-only.'),
|
||||
model: z.string().optional().describe('ADVANCED — leave unset. Per-task model override.'),
|
||||
provider: z.string().optional().describe('ADVANCED — leave unset. Per-task provider name override.'),
|
||||
createdAt: z.string().describe('ISO timestamp set once at create-time.'),
|
||||
lastAttemptAt: z.string().optional().describe('Runtime-managed — never write this yourself. Bumped at the start of every agent run; used by the scheduler for backoff so failures do not retry-storm.'),
|
||||
lastRunId: z.string().optional().describe('Runtime-managed — never write this yourself. The id of the most recent run (success or failure); used by the bg-task:stop handler.'),
|
||||
lastRunAt: z.string().optional().describe('Runtime-managed — never write this yourself. Bumped only when an agent run *succeeds*; used as the cycle anchor for cron / window triggers and as the freshness timestamp shown in the UI.'),
|
||||
lastRunSummary: z.string().optional().describe('Runtime-managed — never write this yourself. Set on success; not overwritten on failure so the user keeps seeing the last good summary.'),
|
||||
lastRunError: z.string().optional().describe('Runtime-managed — never write this yourself. Set on a failed run; cleared on the next successful run.'),
|
||||
});
|
||||
|
||||
export const BackgroundTaskSummarySchema = z.object({
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
instructions: z.string(),
|
||||
active: z.boolean(),
|
||||
triggers: TriggersSchema.optional(),
|
||||
createdAt: z.string(),
|
||||
lastAttemptAt: z.string().optional(),
|
||||
lastRunId: z.string().optional(),
|
||||
lastRunAt: z.string().optional(),
|
||||
lastRunSummary: z.string().optional(),
|
||||
lastRunError: z.string().optional(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bus events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const BackgroundTaskTrigger = z.enum(['manual', 'cron', 'window', 'event']);
|
||||
export type BackgroundTaskTriggerType = z.infer<typeof BackgroundTaskTrigger>;
|
||||
|
||||
export const BackgroundTaskAgentStartEvent = z.object({
|
||||
type: z.literal('background_task_agent_start'),
|
||||
slug: z.string(),
|
||||
trigger: BackgroundTaskTrigger,
|
||||
runId: z.string(),
|
||||
});
|
||||
|
||||
export const BackgroundTaskAgentCompleteEvent = z.object({
|
||||
type: z.literal('background_task_agent_complete'),
|
||||
slug: z.string(),
|
||||
runId: z.string(),
|
||||
error: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
});
|
||||
|
||||
export const BackgroundTaskAgentEvent = z.union([BackgroundTaskAgentStartEvent, BackgroundTaskAgentCompleteEvent]);
|
||||
export type BackgroundTaskAgentEventType = z.infer<typeof BackgroundTaskAgentEvent>;
|
||||
76
apps/x/packages/shared/src/events.ts
Normal file
76
apps/x/packages/shared/src/events.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rowboat events — the shared queue feeding the live-note + bg-task consumers.
|
||||
//
|
||||
// Producers (gmail/calendar sync) write JSON files to `$WorkDir/events/pending/`
|
||||
// using IDs from the monotonically increasing ID generator. The processor in
|
||||
// `packages/core/src/events/processor.ts` polls the directory, fans out Pass-1
|
||||
// routing across registered consumers in parallel, fires each consumer's
|
||||
// candidates sequentially, then enriches the event and moves it to `done/`.
|
||||
//
|
||||
// Schema is additive-on-optional so old events written by previous versions
|
||||
// parse cleanly. The legacy `KnowledgeEventSchema` is re-exported as an alias
|
||||
// from `./live-note.ts` for one release.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ConsumerResultSchema = z.object({
|
||||
candidateIds: z.array(z.string()),
|
||||
runIds: z.array(z.string()),
|
||||
errors: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export type ConsumerResult = z.infer<typeof ConsumerResultSchema>;
|
||||
|
||||
export const RowboatEventSchema = z.object({
|
||||
id: z.string().describe('Monotonically increasing ID; also the filename in events/pending/'),
|
||||
source: z.string().describe('Producer of the event (e.g. "gmail", "calendar")'),
|
||||
type: z.string().describe('Event type (e.g. "email.synced")'),
|
||||
createdAt: z.string().describe('ISO timestamp when the event was produced'),
|
||||
payload: z.string().describe('Human-readable event body, usually markdown'),
|
||||
|
||||
/**
|
||||
* If set, the consumer-named here short-circuits Pass-1 and targets the
|
||||
* named id directly (used for re-runs from the UI). The producer is
|
||||
* unchanged from the legacy `targetFilePath` behavior but generalized.
|
||||
*/
|
||||
target: z.object({
|
||||
consumer: z.string(),
|
||||
id: z.string(),
|
||||
}).optional(),
|
||||
|
||||
/** Legacy field — preserved on read for backwards compat with events
|
||||
* written by the pre-rename code. Equivalent to
|
||||
* `target: { consumer: 'live-note', id: <value> }`. */
|
||||
targetFilePath: z.string().optional(),
|
||||
|
||||
// ----------------- Enriched on move from pending/ to done/ -----------
|
||||
|
||||
processedAt: z.string().optional(),
|
||||
|
||||
/** Per-consumer outcome map. */
|
||||
consumers: z.record(z.string(), ConsumerResultSchema).optional(),
|
||||
|
||||
/** Legacy field — preserved on read for backwards compat with events
|
||||
* enriched by the pre-rename code. */
|
||||
candidateFilePaths: z.array(z.string()).optional(),
|
||||
|
||||
/** Legacy field — preserved on read for backwards compat with events
|
||||
* enriched by the pre-rename code. */
|
||||
runIds: z.array(z.string()).optional(),
|
||||
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
export type RowboatEvent = z.infer<typeof RowboatEventSchema>;
|
||||
|
||||
/**
|
||||
* Pass-1 routing output. The `ids` strings are consumer-defined:
|
||||
* - live-note → workspace-relative paths
|
||||
* - bg-task → task slugs
|
||||
*/
|
||||
export const Pass1OutputSchema = z.object({
|
||||
ids: z.array(z.string()).describe('Identifiers of candidates whose intent and event-match criteria suggest the event might be relevant. The consumer\'s agent does Pass 2 on the event payload before acting.'),
|
||||
});
|
||||
|
||||
export type Pass1Output = z.infer<typeof Pass1OutputSchema>;
|
||||
|
|
@ -10,6 +10,8 @@ export * as serviceEvents from './service-events.js'
|
|||
export * as inlineTask from './inline-task.js';
|
||||
export * as blocks from './blocks.js';
|
||||
export * as liveNote from './live-note.js';
|
||||
export * as events from './events.js';
|
||||
export * as backgroundTask from './background-task.js';
|
||||
export * as promptBlock from './prompt-block.js';
|
||||
export * as frontmatter from './frontmatter.js';
|
||||
export * as bases from './bases.js';
|
||||
|
|
|
|||
|
|
@ -7,6 +7,12 @@ import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
|
|||
import { AgentScheduleState } from './agent-schedule-state.js';
|
||||
import { ServiceEvent } from './service-events.js';
|
||||
import { LiveNoteAgentEvent, LiveNoteSchema } from './live-note.js';
|
||||
import {
|
||||
BackgroundTaskAgentEvent,
|
||||
BackgroundTaskSchema,
|
||||
BackgroundTaskSummarySchema,
|
||||
TriggersSchema,
|
||||
} from './background-task.js';
|
||||
import { UserMessageContent } from './message.js';
|
||||
import { RowboatApiConfig } from './rowboat-account.js';
|
||||
import { ZListToolkitsResponse } from './composio.js';
|
||||
|
|
@ -218,6 +224,10 @@ const ipcSchemas = {
|
|||
req: LiveNoteAgentEvent,
|
||||
res: z.null(),
|
||||
},
|
||||
'bg-task-agent:events': {
|
||||
req: BackgroundTaskAgentEvent,
|
||||
res: z.null(),
|
||||
},
|
||||
'models:list': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
|
|
@ -691,6 +701,95 @@ const ipcSchemas = {
|
|||
})),
|
||||
}),
|
||||
},
|
||||
// Background-task channels
|
||||
'bg-task:run': {
|
||||
req: z.object({
|
||||
slug: z.string(),
|
||||
context: z.string().optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
runId: z.string().nullable().optional(),
|
||||
summary: z.string().nullable().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'bg-task:get': {
|
||||
req: z.object({
|
||||
slug: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
task: BackgroundTaskSchema.nullable().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'bg-task:patch': {
|
||||
req: z.object({
|
||||
slug: z.string(),
|
||||
partial: BackgroundTaskSchema.partial(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
task: BackgroundTaskSchema.nullable().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'bg-task:create': {
|
||||
req: z.object({
|
||||
name: z.string(),
|
||||
instructions: z.string(),
|
||||
triggers: TriggersSchema.optional(),
|
||||
model: z.string().optional(),
|
||||
provider: z.string().optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
slug: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'bg-task:delete': {
|
||||
req: z.object({
|
||||
slug: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'bg-task:stop': {
|
||||
req: z.object({
|
||||
slug: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'bg-task:list': {
|
||||
req: z.object({
|
||||
offset: z.number().int().nonnegative().optional(),
|
||||
limit: z.number().int().positive().optional(),
|
||||
sort: z.enum(['createdAt:desc', 'createdAt:asc', 'name:asc']).optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
items: z.array(BackgroundTaskSummarySchema),
|
||||
total: z.number().int().nonnegative(),
|
||||
}),
|
||||
},
|
||||
// Returns the runIds recorded in `bg-tasks/<slug>/runs.log` (newest first).
|
||||
// The renderer turns each id into a full Run via the existing `runs:fetch`
|
||||
// channel — bg-task transcripts now live at the global $WorkDir/runs/.
|
||||
'bg-task:listRunIds': {
|
||||
req: z.object({
|
||||
slug: z.string(),
|
||||
limit: z.number().int().positive().optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
runIds: z.array(z.string()),
|
||||
}),
|
||||
},
|
||||
// Embedded browser (WebContentsView) channels
|
||||
'browser:setBounds': {
|
||||
req: z.object({
|
||||
|
|
|
|||
|
|
@ -85,27 +85,12 @@ export const LiveNoteSchema = z.object({
|
|||
// Knowledge events (live-note event-driven pipeline)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const KnowledgeEventSchema = z.object({
|
||||
id: z.string().describe('Monotonically increasing ID; also the filename in events/pending/'),
|
||||
source: z.string().describe('Producer of the event (e.g. "gmail", "calendar")'),
|
||||
type: z.string().describe('Event type (e.g. "email.synced")'),
|
||||
createdAt: z.string().describe('ISO timestamp when the event was produced'),
|
||||
payload: z.string().describe('Human-readable event body, usually markdown'),
|
||||
targetFilePath: z.string().optional().describe('If set, skip routing and target this note directly (used for re-runs)'),
|
||||
// Enriched on move from pending/ to done/
|
||||
processedAt: z.string().optional(),
|
||||
candidateFilePaths: z.array(z.string()).optional(),
|
||||
runIds: z.array(z.string()).optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
export type KnowledgeEvent = z.infer<typeof KnowledgeEventSchema>;
|
||||
|
||||
export const Pass1OutputSchema = z.object({
|
||||
filePaths: z.array(z.string()).describe('Note file paths whose objective and event-match criteria suggest the event might be relevant. The agent does Pass 2 on the event payload before editing.'),
|
||||
});
|
||||
|
||||
export type Pass1Output = z.infer<typeof Pass1OutputSchema>;
|
||||
// Legacy aliases — `KnowledgeEventSchema` / `Pass1OutputSchema` now live in
|
||||
// `./events.ts` as `RowboatEventSchema` / `Pass1OutputSchema`. These re-exports
|
||||
// keep older import paths working for one release; remove after nothing imports
|
||||
// them from here.
|
||||
export { RowboatEventSchema as KnowledgeEventSchema, Pass1OutputSchema } from './events.js';
|
||||
export type { RowboatEvent as KnowledgeEvent, Pass1Output } from './events.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bus events
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export const StartEvent = BaseRunEvent.extend({
|
|||
useCase: z.enum([
|
||||
"copilot_chat",
|
||||
"live_note_agent",
|
||||
"background_task_agent",
|
||||
"meeting_note",
|
||||
"knowledge_sync",
|
||||
]).optional(),
|
||||
|
|
@ -138,6 +139,7 @@ export const AskHumanResponsePayload = AskHumanResponseEvent.pick({
|
|||
export const UseCase = z.enum([
|
||||
"copilot_chat",
|
||||
"live_note_agent",
|
||||
"background_task_agent",
|
||||
"meeting_note",
|
||||
"knowledge_sync",
|
||||
]);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue