@xandreed/plugin-tasks 0.0.0-stage → 0.8.0-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +4 -2
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/plugin.adapter.d.ts +27 -0
- package/dist/plugin.adapter.js +17 -0
- package/dist/task-records.entity.d.ts +47 -0
- package/dist/task-records.entity.js +18 -0
- package/dist/task-runner.adapter.d.ts +11 -0
- package/dist/task-runner.adapter.js +11 -0
- package/dist/task.entity.functions.d.ts +38 -0
- package/dist/task.entity.functions.js +46 -0
- package/dist/tasks-capability.adapter.d.ts +2 -0
- package/dist/tasks-capability.adapter.js +38 -0
- package/dist/tasks.adapter.d.ts +13 -0
- package/dist/tasks.adapter.js +151 -0
- package/dist/tasks.adapter.test.d.ts +1 -0
- package/package.json +27 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Xand Reed
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @xandreed/plugin-tasks
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Efferent composable agent framework, 0.8.0-next.0. Bun on Linux.
|
|
4
|
+
|
|
5
|
+
Documentation: https://github.com/xandreeddev/efferent
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { tasksPlugin, TasksPluginLive, default } from "./plugin.adapter.js";
|
|
2
|
+
export { TasksLive } from "./tasks.adapter.js";
|
|
3
|
+
export { InProcessTaskRunnerLive } from "./task-runner.adapter.js";
|
|
4
|
+
export { tasksCapability } from "./tasks-capability.adapter.js";
|
|
5
|
+
export { TASK_CANCELLED, TASK_RESULT, TASK_STARTED, TaskCancelled, TaskOutcome, TaskResult, TaskStarted, TasksConfig, tasksDefaults } from "./task-records.entity.js";
|
|
6
|
+
export { deliveryId, noticeOf } from "./task.entity.functions.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { tasksPlugin, TasksPluginLive, default } from "./plugin.adapter.js";
|
|
2
|
+
export { TasksLive } from "./tasks.adapter.js";
|
|
3
|
+
export { InProcessTaskRunnerLive } from "./task-runner.adapter.js";
|
|
4
|
+
export { tasksCapability } from "./tasks-capability.adapter.js";
|
|
5
|
+
export { TASK_CANCELLED, TASK_RESULT, TASK_STARTED, TaskCancelled, TaskOutcome, TaskResult, TaskStarted, TasksConfig, tasksDefaults } from "./task-records.entity.js";
|
|
6
|
+
export { deliveryId, noticeOf } from "./task.entity.functions.js";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { Capabilities, Sessions, TaskExecutor, TaskRunner, Tasks } from "@xandreed/core";
|
|
3
|
+
/**
|
|
4
|
+
* Background tasks: a task is one turn of a child session, run by the
|
|
5
|
+
* host's TaskRunner and TaskExecutor, its result delivered to the parent's
|
|
6
|
+
* inbox for the turn after the one in flight. With `tools`, the model gets
|
|
7
|
+
* `start_task` and `task_status`.
|
|
8
|
+
*/
|
|
9
|
+
export declare const tasksPlugin: import("@xandreed/core").TypedPlugin<{
|
|
10
|
+
readonly maxRunning: number;
|
|
11
|
+
readonly tools: boolean;
|
|
12
|
+
readonly replyChars: number;
|
|
13
|
+
readonly closingReserveMs: number;
|
|
14
|
+
}, {
|
|
15
|
+
readonly maxRunning: number;
|
|
16
|
+
readonly tools: boolean;
|
|
17
|
+
readonly replyChars: number;
|
|
18
|
+
readonly closingReserveMs: number;
|
|
19
|
+
}, Capabilities | Tasks, never, Sessions | TaskRunner | TaskExecutor>;
|
|
20
|
+
/** Background tasks as a typed layer: provides Tasks (and, with `tools`, their capability); requires Sessions, TaskRunner and TaskExecutor. */
|
|
21
|
+
export declare const TasksPluginLive: (options?: Partial<{
|
|
22
|
+
readonly maxRunning: number;
|
|
23
|
+
readonly tools: boolean;
|
|
24
|
+
readonly replyChars: number;
|
|
25
|
+
readonly closingReserveMs: number;
|
|
26
|
+
}> | undefined) => Layer.Layer<Capabilities | Tasks, import("@xandreed/core").HarnessError, Sessions | TaskRunner | TaskExecutor>;
|
|
27
|
+
export default tasksPlugin;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { Capabilities, definePlugin, Sessions, TaskExecutor, TaskRunner, Tasks } from "@xandreed/core";
|
|
3
|
+
import { tasksCapability } from "./tasks-capability.adapter.js";
|
|
4
|
+
import { TasksConfig, tasksDefaults } from "./task-records.entity.js";
|
|
5
|
+
import { TasksLive } from "./tasks.adapter.js";
|
|
6
|
+
export const tasksPlugin = definePlugin({
|
|
7
|
+
id: "@xandreed/plugin-tasks",
|
|
8
|
+
version: "0.8.0-next.0",
|
|
9
|
+
scope: "runtime",
|
|
10
|
+
config: TasksConfig,
|
|
11
|
+
defaults: tasksDefaults,
|
|
12
|
+
requires: [Sessions, TaskRunner, TaskExecutor],
|
|
13
|
+
provides: [Tasks],
|
|
14
|
+
contributes: [Capabilities],
|
|
15
|
+
layer: (config) => Layer.mergeAll(TasksLive(config), Layer.succeed(Capabilities, config.tools ? [tasksCapability] : []))
|
|
16
|
+
}), TasksPluginLive = tasksPlugin.live;
|
|
17
|
+
export default tasksPlugin;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
export declare const TASK_STARTED = "task.started";
|
|
3
|
+
export declare const TASK_RESULT = "task.result";
|
|
4
|
+
export declare const TASK_CANCELLED = "task.cancelled";
|
|
5
|
+
export declare const TaskStarted: Schema.Struct<{
|
|
6
|
+
readonly taskId: Schema.brand<Schema.String, "ConversationId">;
|
|
7
|
+
readonly instructions: Schema.String;
|
|
8
|
+
readonly mode: Schema.Literals<readonly ["fork", "spawn"]>;
|
|
9
|
+
/** The starter's own facts (e.g. the run that started it). */
|
|
10
|
+
readonly meta: Schema.$Record<Schema.String, Schema.Unknown>;
|
|
11
|
+
}>;
|
|
12
|
+
export type TaskStarted = typeof TaskStarted.Type;
|
|
13
|
+
export declare const TaskOutcome: Schema.Literals<readonly ["completed", "partial", "failed", "interrupted"]>;
|
|
14
|
+
export type TaskOutcome = typeof TaskOutcome.Type;
|
|
15
|
+
export declare const TaskFailure: Schema.Struct<{
|
|
16
|
+
readonly code: Schema.String;
|
|
17
|
+
readonly message: Schema.String;
|
|
18
|
+
}>;
|
|
19
|
+
export declare const TaskResult: Schema.Struct<{
|
|
20
|
+
readonly taskId: Schema.brand<Schema.String, "ConversationId">;
|
|
21
|
+
readonly outcome: Schema.Literals<readonly ["completed", "partial", "failed", "interrupted"]>;
|
|
22
|
+
readonly reply: Schema.OptionFromNullOr<Schema.String>;
|
|
23
|
+
readonly failure: Schema.OptionFromNullOr<Schema.Struct<{
|
|
24
|
+
readonly code: Schema.String;
|
|
25
|
+
readonly message: Schema.String;
|
|
26
|
+
}>>;
|
|
27
|
+
}>;
|
|
28
|
+
export type TaskResult = typeof TaskResult.Type;
|
|
29
|
+
export declare const TaskCancelled: Schema.Struct<{
|
|
30
|
+
readonly taskId: Schema.brand<Schema.String, "ConversationId">;
|
|
31
|
+
}>;
|
|
32
|
+
export declare const TasksConfig: Schema.Struct<{
|
|
33
|
+
/** Tasks a session may have waiting or running at once. */
|
|
34
|
+
readonly maxRunning: Schema.Int;
|
|
35
|
+
/** Offer the model `start_task` and `task_status`. */
|
|
36
|
+
readonly tools: Schema.Boolean;
|
|
37
|
+
/** A longer result reaches the parent cut to this many characters. */
|
|
38
|
+
readonly replyChars: Schema.Int;
|
|
39
|
+
/**
|
|
40
|
+
* Of a runner's finite budget, the time execution leaves for what follows
|
|
41
|
+
* it: the child's result and ending, the delivery and the parent's
|
|
42
|
+
* closing commits (several store round trips). At most half the budget.
|
|
43
|
+
*/
|
|
44
|
+
readonly closingReserveMs: Schema.Int;
|
|
45
|
+
}>;
|
|
46
|
+
export type TasksConfig = typeof TasksConfig.Type;
|
|
47
|
+
export declare const tasksDefaults: TasksConfig;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { ConversationId, JsonObject, TaskMode } from "@xandreed/core";
|
|
3
|
+
export const TASK_STARTED = "task.started", TASK_RESULT = "task.result", TASK_CANCELLED = "task.cancelled", TaskStarted = Schema.Struct({
|
|
4
|
+
taskId: ConversationId,
|
|
5
|
+
instructions: Schema.String,
|
|
6
|
+
mode: TaskMode,
|
|
7
|
+
meta: JsonObject
|
|
8
|
+
}), TaskOutcome = Schema.Literals(["completed", "partial", "failed", "interrupted"]), TaskFailure = Schema.Struct({ code: Schema.String, message: Schema.String }), TaskResult = Schema.Struct({
|
|
9
|
+
taskId: ConversationId,
|
|
10
|
+
outcome: TaskOutcome,
|
|
11
|
+
reply: Schema.OptionFromNullOr(Schema.String),
|
|
12
|
+
failure: Schema.OptionFromNullOr(TaskFailure)
|
|
13
|
+
}), TaskCancelled = Schema.Struct({ taskId: ConversationId }), TasksConfig = Schema.Struct({
|
|
14
|
+
maxRunning: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 20 })),
|
|
15
|
+
tools: Schema.Boolean,
|
|
16
|
+
replyChars: Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 1e5 })),
|
|
17
|
+
closingReserveMs: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 600000 }))
|
|
18
|
+
}), tasksDefaults = { maxRunning: 2, tools: !1, replyChars: 12000, closingReserveMs: 2000 };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { TaskRunner } from "@xandreed/core";
|
|
3
|
+
/**
|
|
4
|
+
* Background work on fibers of this process, each on its own fiber (it
|
|
5
|
+
* keeps none of the starting turn's services), all interrupted when the
|
|
6
|
+
* layer's scope closes: an interrupted task's turn ends as interrupted.
|
|
7
|
+
* With `maxMs`, each piece of work must be done that long after it starts.
|
|
8
|
+
*/
|
|
9
|
+
export declare const InProcessTaskRunnerLive: (options?: {
|
|
10
|
+
readonly maxMs?: number;
|
|
11
|
+
}) => Layer.Layer<TaskRunner>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Clock, Effect, FiberSet, Layer, Option } from "effect";
|
|
2
|
+
import { TaskRunner } from "@xandreed/core";
|
|
3
|
+
export const InProcessTaskRunnerLive = (options) => Layer.effect(TaskRunner, Effect.gen(function* () {
|
|
4
|
+
const fibers = yield* FiberSet.make(), context = yield* Effect.context();
|
|
5
|
+
return TaskRunner.of({
|
|
6
|
+
run: (work) => Effect.gen(function* () {
|
|
7
|
+
const now = yield* Clock.currentTimeMillis, deadline = Option.map(Option.fromNullishOr(options?.maxMs), (ms) => now + ms), fiber = yield* Effect.sync(() => Effect.runForkWith(context)(work(deadline)));
|
|
8
|
+
yield* FiberSet.add(fibers, fiber);
|
|
9
|
+
})
|
|
10
|
+
});
|
|
11
|
+
}));
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Exit, Option } from "effect";
|
|
2
|
+
import { HarnessError } from "@xandreed/core";
|
|
3
|
+
import type { SessionLogEvent, SessionView, TaskStatus, TaskView, TurnOutcome } from "@xandreed/core";
|
|
4
|
+
import type { TaskResult as Result, TaskStarted as Started } from "./task-records.entity.js";
|
|
5
|
+
/** The inbox id a task's result is delivered under: once per task. */
|
|
6
|
+
export declare const deliveryId: (taskId: string) => string;
|
|
7
|
+
/** Done and worth telling the parent about (a cancelled task tells nothing). */
|
|
8
|
+
export declare const isFinished: (status: TaskStatus) => boolean;
|
|
9
|
+
/** Waiting to run, or running. */
|
|
10
|
+
export declare const isActive: (status: TaskStatus) => boolean;
|
|
11
|
+
/** The tasks a parent started, in order; a record that does not decode is skipped. */
|
|
12
|
+
export declare const startedOf: (events: ReadonlyArray<SessionLogEvent>) => ReadonlyArray<Started>;
|
|
13
|
+
/** The inbox ids a session has had delivered. */
|
|
14
|
+
export declare const deliveredOf: (events: ReadonlyArray<SessionLogEvent>) => ReadonlySet<string>;
|
|
15
|
+
export interface TaskEnd {
|
|
16
|
+
readonly status: TaskStatus;
|
|
17
|
+
readonly reply: Option.Option<string>;
|
|
18
|
+
readonly failure: Option.Option<{
|
|
19
|
+
readonly code: string;
|
|
20
|
+
readonly message: string;
|
|
21
|
+
}>;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Where a task stands, from its child: the first of its result and its
|
|
25
|
+
* cancellation ends it; otherwise it runs while its turn is held, was
|
|
26
|
+
* interrupted if a turn began and closed without a result, and waits if
|
|
27
|
+
* none began.
|
|
28
|
+
*/
|
|
29
|
+
export declare const endOf: (view: SessionView, records: ReadonlyArray<SessionLogEvent>) => TaskEnd;
|
|
30
|
+
/** A child that no longer exists: its parent's removal took it. */
|
|
31
|
+
export declare const goneEnd: TaskEnd;
|
|
32
|
+
/** How the child's turn went, as its result records it: a turn past its deadline is interrupted. */
|
|
33
|
+
export declare const resultOf: (taskId: Result["taskId"], exit: Exit.Exit<Option.Option<TurnOutcome>, HarnessError>) => Result;
|
|
34
|
+
/**
|
|
35
|
+
* What the parent's inbox turn shows the model: a trusted frame around the
|
|
36
|
+
* task's own words, which may not close the frame early.
|
|
37
|
+
*/
|
|
38
|
+
export declare const noticeOf: (task: TaskView, replyChars: number) => string;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Cause, Exit, Option, Schema } from "effect";
|
|
2
|
+
import { HarnessError } from "@xandreed/core";
|
|
3
|
+
import { TASK_CANCELLED, TASK_RESULT, TASK_STARTED, TaskResult, TaskStarted } from "./task-records.entity.js";
|
|
4
|
+
const decodeStarted = Schema.decodeUnknownOption(TaskStarted), decodeResult = Schema.decodeUnknownOption(TaskResult);
|
|
5
|
+
export const deliveryId = (taskId) => `task:${taskId}`;
|
|
6
|
+
const FINISHED = ["completed", "partial", "failed", "interrupted"];
|
|
7
|
+
export const isFinished = (status) => FINISHED.includes(status), isActive = (status) => status === "pending" || status === "running", startedOf = (events) => events.filter((event) => event.kind === TASK_STARTED).flatMap((event) => Option.toArray(decodeStarted(event.data))), deliveredOf = (events) => new Set(events.filter((event) => event.kind === "inbox.queued").map((event) => String(event.data.id)));
|
|
8
|
+
const began = (view) => view.turns > Option.match(view.header.parent, { onNone: () => 0, onSome: (lineage) => lineage.turnAtFork });
|
|
9
|
+
export const endOf = (view, records) => {
|
|
10
|
+
const first = records.find((event) => event.kind === TASK_RESULT || event.kind === TASK_CANCELLED);
|
|
11
|
+
if (first?.kind === TASK_CANCELLED)
|
|
12
|
+
return { status: "cancelled", reply: Option.none(), failure: Option.none() };
|
|
13
|
+
const result = first === void 0 ? Option.none() : decodeResult(first.data);
|
|
14
|
+
if (Option.isSome(result))
|
|
15
|
+
return { status: result.value.outcome, reply: result.value.reply, failure: result.value.failure };
|
|
16
|
+
if (Option.isSome(view.open))
|
|
17
|
+
return { status: "running", reply: Option.none(), failure: Option.none() };
|
|
18
|
+
return began(view) ? { status: "interrupted", reply: Option.none(), failure: Option.some({ code: "task.lost", message: "the task's turn stopped without a result" }) } : { status: "pending", reply: Option.none(), failure: Option.none() };
|
|
19
|
+
}, goneEnd = { status: "cancelled", reply: Option.none(), failure: Option.some({ code: "task.removed", message: "the task's session was removed" }) }, resultOf = (taskId, exit) => Exit.match(exit, {
|
|
20
|
+
onSuccess: (outcome) => Option.match(outcome, {
|
|
21
|
+
onNone: () => ({ taskId, outcome: "interrupted", reply: Option.none(), failure: Option.some({ code: "task.deadline", message: "the task ran out of time" }) }),
|
|
22
|
+
onSome: (done) => ({
|
|
23
|
+
taskId,
|
|
24
|
+
outcome: done.outcome,
|
|
25
|
+
reply: done.reply,
|
|
26
|
+
failure: done.outcome === "failed" ? Option.some({ code: "task.turn", message: "the task's turn failed" }) : Option.none()
|
|
27
|
+
})
|
|
28
|
+
}),
|
|
29
|
+
onFailure: (cause) => {
|
|
30
|
+
const error = Cause.squash(cause);
|
|
31
|
+
return {
|
|
32
|
+
taskId,
|
|
33
|
+
outcome: "failed",
|
|
34
|
+
reply: Option.none(),
|
|
35
|
+
failure: Option.some(error instanceof HarnessError ? { code: error.code, message: error.message } : { code: "task.turn", message: "the task's turn failed" })
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}), noticeOf = (task, replyChars) => {
|
|
39
|
+
const output = Option.match(task.reply, {
|
|
40
|
+
onNone: () => Option.match(task.failure, { onNone: () => "(no reply)", onSome: (failure) => `(no reply: ${failure.message})` }),
|
|
41
|
+
onSome: (reply) => reply.length > replyChars ? `${reply.slice(0, replyChars)}
|
|
42
|
+
(cut at ${replyChars} characters)` : reply
|
|
43
|
+
});
|
|
44
|
+
return [`[Background task ${task.taskId} ${task.status}]`, "<task-output>", output.replaceAll("</task-output>", "</task_output>"), "</task-output>"].join(`
|
|
45
|
+
`);
|
|
46
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Tool } from "effect/ai";
|
|
2
|
+
import { Effect, Option, Schema } from "effect";
|
|
3
|
+
import { defineCapability, defineSkill, defineTool, Failure, RunContext, TaskMode, Tasks, TaskStatus } from "@xandreed/core";
|
|
4
|
+
const StartTask = Tool.make("start_task", {
|
|
5
|
+
description: "Start a background task: another run of you works on the instructions while this conversation goes on, and its result comes back here when it finishes, for you to answer then. Use it for long work the user need not wait for, and say that you started it. mode fork: the task sees this conversation so far; spawn: it sees only the instructions.",
|
|
6
|
+
parameters: Schema.Struct({ instructions: Schema.String, mode: TaskMode }),
|
|
7
|
+
success: Schema.Struct({ taskId: Schema.String, status: TaskStatus }),
|
|
8
|
+
failure: Failure,
|
|
9
|
+
failureMode: "return"
|
|
10
|
+
}), TaskStatusTool = Tool.make("task_status", {
|
|
11
|
+
description: "Check a background task this conversation started: whether it is still running, and its result once it has one.",
|
|
12
|
+
parameters: Schema.Struct({ taskId: Schema.String }),
|
|
13
|
+
success: Schema.Struct({ taskId: Schema.String, status: TaskStatus, reply: Schema.NullOr(Schema.String) }),
|
|
14
|
+
failure: Failure,
|
|
15
|
+
failureMode: "return"
|
|
16
|
+
}), unavailable = (error) => ({ error: "TasksUnavailable", message: `Background tasks are unavailable right now (${error._tag})` });
|
|
17
|
+
export const tasksCapability = defineCapability({
|
|
18
|
+
id: "@xandreed/plugin-tasks/tools",
|
|
19
|
+
version: "1",
|
|
20
|
+
tools: [
|
|
21
|
+
defineTool({
|
|
22
|
+
tool: StartTask,
|
|
23
|
+
handler: ({ instructions, mode }) => Effect.gen(function* () {
|
|
24
|
+
const run = yield* RunContext, task = yield* (yield* Tasks).start(run.session, { instructions, mode, meta: { runId: run.runId } }).pipe(Effect.mapError((error) => error._tag === "TaskRefused" ? { error: "TaskRefused", message: error.message } : unavailable(error)));
|
|
25
|
+
return { taskId: task.taskId, status: task.status };
|
|
26
|
+
})
|
|
27
|
+
}),
|
|
28
|
+
defineTool({
|
|
29
|
+
tool: TaskStatusTool,
|
|
30
|
+
handler: ({ taskId }) => Effect.gen(function* () {
|
|
31
|
+
const run = yield* RunContext, task = yield* (yield* Tasks).status(run.session, taskId).pipe(Effect.mapError((error) => error._tag === "TaskMissing" ? { error: "UnknownTask", message: `No task ${taskId} was started in this conversation` } : unavailable(error)));
|
|
32
|
+
return { taskId: task.taskId, status: task.status, reply: Option.getOrNull(task.reply) };
|
|
33
|
+
}),
|
|
34
|
+
annotations: { readOnly: !0 }
|
|
35
|
+
})
|
|
36
|
+
],
|
|
37
|
+
skills: [defineSkill({ id: "tasks.background", summary: "Run long work in the background and check on it.", tools: ["start_task", "task_status"], always: !0 })]
|
|
38
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { Sessions, TaskExecutor, TaskRunner, Tasks } from "@xandreed/core";
|
|
3
|
+
import type { TasksConfig } from "./task-records.entity.js";
|
|
4
|
+
/**
|
|
5
|
+
* Background tasks over Sessions. A task is one turn of a child session
|
|
6
|
+
* whose id is the task's. Everything that decides reads the log (the
|
|
7
|
+
* parent's `task.started`, the child's result or cancellation, the parent's
|
|
8
|
+
* inbox), so any instance, or a later request, can finish what another
|
|
9
|
+
* left: run a task that never ran, deliver a result that was never
|
|
10
|
+
* delivered. Delivery is once per task (the inbox id), and whoever delivers
|
|
11
|
+
* drains the parent, so it reacts after the answer in flight.
|
|
12
|
+
*/
|
|
13
|
+
export declare const TasksLive: (config: TasksConfig) => Layer.Layer<Tasks, never, Sessions | TaskRunner | TaskExecutor>;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { Clock, Effect, Layer, Option, Ref, Schema } from "effect";
|
|
2
|
+
import {
|
|
3
|
+
ConversationId,
|
|
4
|
+
HarnessError,
|
|
5
|
+
MemoryKindOf,
|
|
6
|
+
SessionLogError,
|
|
7
|
+
Sessions,
|
|
8
|
+
TaskExecutor,
|
|
9
|
+
TaskMissing,
|
|
10
|
+
TaskRefused,
|
|
11
|
+
TaskRunner,
|
|
12
|
+
Tasks,
|
|
13
|
+
UserMessage
|
|
14
|
+
} from "@xandreed/core";
|
|
15
|
+
import { deliveredOf, deliveryId, endOf, goneEnd, isActive, isFinished, noticeOf, resultOf, startedOf } from "./task.entity.functions.js";
|
|
16
|
+
import { TASK_CANCELLED, TASK_RESULT, TASK_STARTED, TaskResult, TaskStarted } from "./task-records.entity.js";
|
|
17
|
+
const OUTCOME_KINDS = [TASK_RESULT, TASK_CANCELLED], encodeFailure = (error) => new SessionLogError({ code: "tasks.encode", message: error.message }), startedJson = (started) => Schema.encodeEffect(TaskStarted)(started).pipe(Effect.map((data) => data), Effect.mapError(encodeFailure)), resultJson = (result) => Schema.encodeEffect(TaskResult)(result).pipe(Effect.map((data) => data), Effect.mapError(encodeFailure)), reservedAsStorage = (error) => Effect.fail(new SessionLogError({ code: "tasks.kind", message: `${error.kind} is reserved` }));
|
|
18
|
+
export const TasksLive = (config) => Layer.effect(Tasks, Effect.gen(function* () {
|
|
19
|
+
const sessions = yield* Sessions, runner = yield* TaskRunner, executor = yield* TaskExecutor, childOf = (parent, task) => ({ id: task, owner: parent.owner }), childState = (child) => Effect.all([sessions.get(child), sessions.read(child, { kinds: OUTCOME_KINDS })]).pipe(Effect.map(Option.some), Effect.catchTag("SessionMissing", () => Effect.succeed(Option.none()))), viewOf = (parent, started, delivered) => childState(childOf(parent, started.taskId)).pipe(Effect.map((state) => ({
|
|
20
|
+
taskId: started.taskId,
|
|
21
|
+
parent: parent.id,
|
|
22
|
+
child: started.taskId,
|
|
23
|
+
instructions: started.instructions,
|
|
24
|
+
mode: started.mode,
|
|
25
|
+
...Option.match(state, { onNone: () => goneEnd, onSome: ([view, records]) => endOf(view, records) }),
|
|
26
|
+
delivered: delivered.has(deliveryId(started.taskId))
|
|
27
|
+
}))), parentRecords = (parent) => sessions.read(parent, { kinds: [TASK_STARTED, "inbox.queued"] }), listOf = (parent) => parentRecords(parent).pipe(Effect.flatMap((events) => Effect.forEach(startedOf(events), (started) => viewOf(parent, started, deliveredOf(events))))), taskOf = (parent, taskId) => Effect.gen(function* () {
|
|
28
|
+
const events = yield* parentRecords(parent), started = startedOf(events).find((record) => record.taskId === taskId);
|
|
29
|
+
return started === void 0 ? yield* Effect.fail(new TaskMissing({ session: parent.id, taskId })) : yield* viewOf(parent, started, deliveredOf(events));
|
|
30
|
+
}), recordEnd = (child, result) => sessions.transact(child, () => Effect.gen(function* () {
|
|
31
|
+
return { drafts: (yield* sessions.read(child, { kinds: OUTCOME_KINDS })).length > 0 ? [] : [{ kind: TASK_RESULT, data: yield* resultJson(result) }], result: void 0 };
|
|
32
|
+
})).pipe(Effect.catchTag("ReservedKind", reservedAsStorage), Effect.asVoid), bounded = (body, writer, deadline) => Effect.gen(function* () {
|
|
33
|
+
const now = yield* Clock.currentTimeMillis, timed = Option.match(deadline, {
|
|
34
|
+
onNone: () => body.pipe(Effect.map(Option.some)),
|
|
35
|
+
onSome: (at) => body.pipe(Effect.timeoutOption(Math.max(0, at - now)))
|
|
36
|
+
});
|
|
37
|
+
return yield* Effect.raceFirst(timed, writer.closed.pipe(Effect.flatMap((closed) => Effect.fail(new HarnessError({ code: "turn.closed", message: `turn ${closed.turn} was ${closed.reason}` })))));
|
|
38
|
+
}), runChild = (parent, task, deadline) => {
|
|
39
|
+
const child = childOf(parent, task.child);
|
|
40
|
+
return Effect.scoped(Effect.gen(function* () {
|
|
41
|
+
const writer = yield* sessions.begin(child, {
|
|
42
|
+
_tag: "User",
|
|
43
|
+
userMessage: new UserMessage({ text: task.instructions }),
|
|
44
|
+
runId: yield* Effect.sync(() => crypto.randomUUID()),
|
|
45
|
+
key: deliveryId(task.taskId),
|
|
46
|
+
command: { task: task.taskId }
|
|
47
|
+
});
|
|
48
|
+
if ((yield* sessions.read(child, { kinds: [TASK_CANCELLED] })).length > 0)
|
|
49
|
+
return yield* writer.end({ reason: "cancelled", failure: Option.none() });
|
|
50
|
+
const exit = yield* Effect.exit(bounded(executor.turn(writer, { ...task, status: "running" }), writer, deadline)), result = resultOf(task.taskId, exit), data = yield* resultJson(result);
|
|
51
|
+
yield* writer.transact((foreign) => Effect.succeed({
|
|
52
|
+
drafts: foreign.some((event) => event.kind === TASK_CANCELLED) ? [] : [{ kind: TASK_RESULT, data }],
|
|
53
|
+
result: void 0
|
|
54
|
+
}));
|
|
55
|
+
return yield* writer.end({ reason: result.outcome, failure: result.failure });
|
|
56
|
+
})).pipe(Effect.catchTags({
|
|
57
|
+
SessionBusy: () => Effect.void,
|
|
58
|
+
TurnDuplicate: () => Effect.void,
|
|
59
|
+
KeyConflict: () => Effect.void,
|
|
60
|
+
NothingPending: () => Effect.void,
|
|
61
|
+
TurnRefused: (refused) => recordEnd(child, {
|
|
62
|
+
taskId: task.taskId,
|
|
63
|
+
outcome: "failed",
|
|
64
|
+
reply: Option.none(),
|
|
65
|
+
failure: Option.some({ code: "task.refused", message: refused.message })
|
|
66
|
+
}),
|
|
67
|
+
HarnessError: (error) => Effect.logInfo(`background task ${task.taskId} stopped: ${error.message}`)
|
|
68
|
+
}), Effect.asVoid);
|
|
69
|
+
}, deliverNotice = (parent, task) => sessions.deliver(parent, {
|
|
70
|
+
id: deliveryId(task.taskId),
|
|
71
|
+
source: { kind: "task", taskId: task.taskId, child: task.child, status: task.status },
|
|
72
|
+
content: noticeOf(task, config.replyChars)
|
|
73
|
+
}).pipe(Effect.catchTag("InboxFull", (full) => Effect.logWarning(`the inbox of ${parent.id} is full; task ${task.taskId} is delivered later`).pipe(Effect.as({ delivered: !1, pending: full.pending })))), noting = (writer, answered) => {
|
|
74
|
+
const note = (drafts) => drafts.some((draft) => draft.kind === MemoryKindOf.TurnEnded) ? Ref.set(answered, !0) : Effect.void;
|
|
75
|
+
return {
|
|
76
|
+
...writer,
|
|
77
|
+
append: (drafts) => note(drafts).pipe(Effect.andThen(writer.append(drafts))),
|
|
78
|
+
transact: (decide) => writer.transact((foreign) => decide(foreign).pipe(Effect.tap((decision) => note(decision.drafts))))
|
|
79
|
+
};
|
|
80
|
+
}, reaction = (writer, deadline) => Effect.gen(function* () {
|
|
81
|
+
const now = yield* Clock.currentTimeMillis;
|
|
82
|
+
if (Option.isNone(deadline))
|
|
83
|
+
return yield* executor.react(writer);
|
|
84
|
+
const cut = { code: "task.deadline", message: "the runner's deadline ended the inbox reaction" };
|
|
85
|
+
if (deadline.value <= now)
|
|
86
|
+
return yield* writer.end({ reason: "interrupted", failure: Option.some({ ...cut, message: "the runner's deadline passed before the inbox reaction began" }) });
|
|
87
|
+
const answered = yield* Ref.make(!1), reacted = yield* executor.react(noting(writer, answered)).pipe(Effect.timeoutOption(deadline.value - now));
|
|
88
|
+
if (Option.isSome(reacted))
|
|
89
|
+
return;
|
|
90
|
+
yield* writer.end({ reason: (yield* Ref.get(answered)) ? "partial" : "interrupted", failure: Option.some(cut) });
|
|
91
|
+
}), settle = (parent, taskId, deadline) => Effect.gen(function* () {
|
|
92
|
+
const task = yield* taskOf(parent, taskId);
|
|
93
|
+
if (!isFinished(task.status) || task.delivered)
|
|
94
|
+
return;
|
|
95
|
+
const delivered = yield* deliverNotice(parent, task), now = yield* Clock.currentTimeMillis, remaining = Option.map(deadline, (at) => at - now);
|
|
96
|
+
if (delivered.pending === 0 || Option.isSome(remaining) && remaining.value <= 0)
|
|
97
|
+
return;
|
|
98
|
+
yield* sessions.drain(parent, (writer) => reaction(writer, deadline), Option.isSome(deadline) ? { maxTurns: 1 } : {});
|
|
99
|
+
}), work = (parent, taskId) => (deadline) => Effect.gen(function* () {
|
|
100
|
+
const started = yield* Clock.currentTimeMillis, executeBy = Option.map(deadline, (at) => at - Math.min(config.closingReserveMs, Math.floor(Math.max(0, at - started) / 2))), execution = Effect.gen(function* () {
|
|
101
|
+
const task = yield* taskOf(parent, taskId);
|
|
102
|
+
if (task.status === "pending")
|
|
103
|
+
yield* runChild(parent, task, executeBy);
|
|
104
|
+
yield* settle(parent, taskId, executeBy);
|
|
105
|
+
});
|
|
106
|
+
yield* Option.match(deadline, {
|
|
107
|
+
onNone: () => execution,
|
|
108
|
+
onSome: (at) => execution.pipe(Effect.timeoutOption(Math.max(0, at - started)), Effect.flatMap(Option.match({
|
|
109
|
+
onSome: () => Effect.void,
|
|
110
|
+
onNone: () => Clock.currentTimeMillis.pipe(Effect.flatMap((now) => Effect.logWarning(`background task ${taskId} of ${parent.id} was cut by the runner's deadline after ${now - started} ms`)))
|
|
111
|
+
})))
|
|
112
|
+
});
|
|
113
|
+
}).pipe(Effect.catchCause((cause) => Effect.logWarning(`background task ${taskId} of ${parent.id} stopped`, cause)), Effect.withSpan("task.run", { attributes: { "task.id": taskId, "session.id": parent.id } })), start = (parent, input) => Effect.gen(function* () {
|
|
114
|
+
const view = yield* sessions.get(parent);
|
|
115
|
+
if (Option.isSome(view.header.parent))
|
|
116
|
+
return yield* Effect.fail(new TaskRefused({ session: parent.id, reason: "depth", message: "a background task cannot start another task" }));
|
|
117
|
+
const taskId = input.taskId ?? ConversationId.make(yield* Effect.sync(() => crypto.randomUUID()));
|
|
118
|
+
if ((yield* sessions.transact(parent, () => Effect.gen(function* () {
|
|
119
|
+
const tasks = yield* listOf(parent);
|
|
120
|
+
if (tasks.some((task) => task.taskId === taskId))
|
|
121
|
+
return { drafts: [], result: !1 };
|
|
122
|
+
const active = tasks.filter((task) => isActive(task.status)).length;
|
|
123
|
+
if (active >= config.maxRunning)
|
|
124
|
+
return yield* Effect.fail(new TaskRefused({
|
|
125
|
+
session: parent.id,
|
|
126
|
+
reason: "limit",
|
|
127
|
+
message: `${active} background tasks are already running; wait for one to finish`
|
|
128
|
+
}));
|
|
129
|
+
yield* sessions.fork(parent, { id: taskId, origin: "task", meta: { taskId, mode: input.mode }, inherit: input.mode === "fork" }).pipe(Effect.catchTag("SessionExists", () => Effect.void));
|
|
130
|
+
const data = yield* startedJson({ taskId, instructions: input.instructions, mode: input.mode, meta: input.meta ?? {} });
|
|
131
|
+
return { drafts: [{ kind: TASK_STARTED, data }], result: !0 };
|
|
132
|
+
})).pipe(Effect.catchTag("ReservedKind", reservedAsStorage))).result)
|
|
133
|
+
yield* runner.run(work(parent, taskId));
|
|
134
|
+
return yield* taskOf(parent, taskId).pipe(Effect.catchTag("TaskMissing", () => Effect.fail(new SessionLogError({ code: "tasks.lost", message: `task ${taskId} was started but is not in ${parent.id}` }))));
|
|
135
|
+
}), cancel = (parent, taskId) => Effect.gen(function* () {
|
|
136
|
+
const task = yield* taskOf(parent, taskId);
|
|
137
|
+
if (!isActive(task.status))
|
|
138
|
+
return !1;
|
|
139
|
+
const child = childOf(parent, task.child), decided = yield* sessions.transact(child, (view) => sessions.read(child, { kinds: OUTCOME_KINDS }).pipe(Effect.map((records) => isActive(endOf(view, records).status) ? { drafts: [{ kind: TASK_CANCELLED, data: { taskId } }], result: { cancelled: !0, open: Option.map(view.open, (open) => open.turn) } } : { drafts: [], result: { cancelled: !1, open: Option.none() } }))).pipe(Effect.catchTag("ReservedKind", reservedAsStorage));
|
|
140
|
+
if (!decided.result.cancelled)
|
|
141
|
+
return !1;
|
|
142
|
+
yield* Option.match(decided.result.open, { onNone: () => Effect.void, onSome: (turn) => sessions.cancel(child, turn).pipe(Effect.asVoid) });
|
|
143
|
+
yield* sessions.transact(parent, () => Effect.succeed({ drafts: [{ kind: TASK_CANCELLED, data: { taskId } }], result: void 0 })).pipe(Effect.catchTag("ReservedKind", reservedAsStorage));
|
|
144
|
+
return !0;
|
|
145
|
+
}), reconcile = (parent) => Effect.gen(function* () {
|
|
146
|
+
const tasks = yield* listOf(parent), delivered = yield* Effect.forEach(tasks.filter((task) => isFinished(task.status) && !task.delivered), (task) => deliverNotice(parent, task)), waiting = tasks.filter((task) => task.status === "pending");
|
|
147
|
+
yield* Effect.forEach(waiting, (task) => runner.run(work(parent, task.taskId)), { discard: !0 });
|
|
148
|
+
return { delivered: delivered.filter((done) => done.delivered).length, started: waiting.length };
|
|
149
|
+
});
|
|
150
|
+
return Tasks.of({ start, status: taskOf, list: listOf, cancel, reconcile });
|
|
151
|
+
}));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xandreed/plugin-tasks",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.8.0-next.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"default": "./dist/index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@xandreed/core": "0.8.0-next.0",
|
|
14
|
+
"effect": "4.0.0-rc.118"
|
|
15
|
+
},
|
|
16
|
+
"private": false,
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"skills",
|
|
21
|
+
"profiles",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"bun": ">=1.3.0"
|
|
27
|
+
},
|
|
28
|
+
"license": "MIT"
|
|
29
|
+
}
|