@fastagent-sh/fastagent 0.26.0 → 0.26.1

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.
@@ -239,11 +239,11 @@ export const PI_DEFAULT_MODELS = {
239
239
  azure: "gpt-5.4",
240
240
  "openai-codex": "gpt-6.1-sol",
241
241
  radius: "balanced",
242
- nvidia: "nvidia/nemotron-3-ultra-550b-a55b",
242
+ nvidia: "nvidia/nemotron-3.5-super-vl-120b-a12b",
243
243
  deepseek: "deepseek-v4-pro",
244
244
  google: "gemini-3.1-pro-preview",
245
245
  "google-vertex": "gemini-3.1-pro-preview",
246
- "github-copilot": "gpt-5.4",
246
+ "github-copilot": "gpt-6.1-sol",
247
247
  openrouter: "moonshotai/kimi-k2.6",
248
248
  "vercel-ai-gateway": "zai/glm-5.1",
249
249
  xai: "grok-4.7",
@@ -269,10 +269,10 @@ export const PI_DEFAULT_MODELS = {
269
269
  "qwen-token-plan": "qwen3.7-max",
270
270
  "qwen-token-plan-cn": "qwen3.7-max",
271
271
  "qwen-token-plan-individual": "qwen3.8-max",
272
- xiaomi: "mimo-v2.5-pro",
273
- "xiaomi-token-plan-cn": "mimo-v2.5-pro",
274
- "xiaomi-token-plan-ams": "mimo-v2.5-pro",
275
- "xiaomi-token-plan-sgp": "mimo-v2.5-pro",
272
+ xiaomi: "mimo-v2.6-pro",
273
+ "xiaomi-token-plan-cn": "mimo-v2.6-pro",
274
+ "xiaomi-token-plan-ams": "mimo-v2.6-pro",
275
+ "xiaomi-token-plan-sgp": "mimo-v2.6-pro",
276
276
  };
277
277
  /** One minimal request with the key under test. */
278
278
  async function verifyApiKey(models, providerId, verifyWith, notify, signal) {
@@ -22,6 +22,7 @@ import { admitSettings } from "./defaults.js";
22
22
  import { USER_ENTRY, imageAt, isPublished, parseImageRef } from "./entries.js";
23
23
  import { sessionEvents } from "./events.js";
24
24
  import { storageIds } from "./ids.js";
25
+ import { giveUpOnParked } from "./parked.js";
25
26
  import { isInput, queuedInputs, requestIdFor, usageOfState } from "./messages.js";
26
27
  import { durableReads } from "./reads.js";
27
28
  import { releaseWaitingQueues } from "./release.js";
@@ -42,6 +43,7 @@ export async function openDurableAgent(options) {
42
43
  try {
43
44
  const ids = await storageIds(harness, context);
44
45
  const sessions = await Effect.runPromise(Scope.provide(trackSessions(harness, options.storage, context), lifetime));
46
+ await giveUpOnParked(harness, context);
45
47
  // Before `resume()`, which runs an earlier process's work. An abort enables the scheduler too, but commits first, so
46
48
  // the aborted work does not run (measured).
47
49
  const unsettled = (await harness.inspect(context)).submissions.filter(isInput);
@@ -11,6 +11,8 @@ const StorageDoc = defineDoc({
11
11
  version: 1,
12
12
  scope: "session",
13
13
  initial: () => ({ incarnation: "" }),
14
+ // Written once, when the storage is first opened: no deltas pile up to bound.
15
+ checkpointWhen: () => false,
14
16
  });
15
17
  export async function storageIds(harness, context) {
16
18
  const incarnation = await harness.commit(async (tx) => {
@@ -0,0 +1,18 @@
1
+ /**
2
+ * What becomes of a task pi-durable's crash-loop guard parked (`settings.restartLimit`: cut off by a crash that many
3
+ * times in a row, with no progress between). Left parked, the message it was answering stays `running` for ever with
4
+ * nothing to say why (SPEC.md §5.4 ends a message that cannot be answered `failed`). Parked is a state the task keeps,
5
+ * which `inspect()` shows as blocked for `restart_limit`: it is read from there, and not from the one `task_parked`
6
+ * report an open makes, so a process that dies half way, or a step that fails, is finished by the next open.
7
+ */
8
+ import type { Context } from "@earendil-works/chord/context";
9
+ import { type Harness } from "@earendil-works/pi-durable";
10
+ /**
11
+ * Settle the messages each parked task was answering `unanswered` for {@link RESTART_LIMIT_REASON}, and abort the task,
12
+ * which frees its session. The settlement comes first: an abort alone settles them `aborted`, which reads as a
13
+ * cancellation nobody asked for, and the first settlement of a submission stands (measured). Both steps can be run
14
+ * again over their own result: the run stays until the abort's handler ends it, so a task left half way is found
15
+ * parked, and finished, by the next open. A parked task that is not its conversation's run (a tool's, a compaction's)
16
+ * is only aborted: the run goes on without it.
17
+ */
18
+ export declare function giveUpOnParked(harness: Harness, context: Context): Promise<void>;
@@ -0,0 +1,27 @@
1
+ import { LiveDoc } from "@earendil-works/pi-durable";
2
+ import { log } from "../../log.js";
3
+ const RESTART_LIMIT_REASON = "restart_limit";
4
+ const DETAIL = "The process was cut off repeatedly while answering this message, so it was given up on";
5
+ /**
6
+ * Settle the messages each parked task was answering `unanswered` for {@link RESTART_LIMIT_REASON}, and abort the task,
7
+ * which frees its session. The settlement comes first: an abort alone settles them `aborted`, which reads as a
8
+ * cancellation nobody asked for, and the first settlement of a submission stands (measured). Both steps can be run
9
+ * again over their own result: the run stays until the abort's handler ends it, so a task left half way is found
10
+ * parked, and finished, by the next open. A parked task that is not its conversation's run (a tool's, a compaction's)
11
+ * is only aborted: the run goes on without it.
12
+ */
13
+ export async function giveUpOnParked(harness, context) {
14
+ const parked = (await harness.inspect(context)).tasks.filter(({ state }) => state.kind === "blocked" && state.reason === RESTART_LIMIT_REASON);
15
+ for (const { record } of parked) {
16
+ await harness.commit(async (tx) => {
17
+ const run = (await tx.doc(LiveDoc, record.conversationId))?.run;
18
+ if (run?.taskId !== record.id)
19
+ return;
20
+ for (const id of run.inputs) {
21
+ tx.settleSubmission(id, { status: "unanswered", reason: RESTART_LIMIT_REASON, detail: DETAIL });
22
+ }
23
+ }, context);
24
+ await harness.abortTask(record.id, context);
25
+ log.warn(`[fastagent] gave up on ${record.kind} task ${record.id} of conversation ${record.conversationId}: it was parked`);
26
+ }
27
+ }
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Temporary: pi-durable 1.3.0 leaves a conversation's queued messages waiting after a run that did not answer (a model
2
+ * Temporary: pi-durable leaves a conversation's queued messages waiting after a run that did not answer (a model
3
3
  * failure, an abort, a message sent while an aborted run was still ending) until another submission places them, while
4
4
  * the Agent API takes them whatever the message before them ended with (SPEC.md §5.3). A write of FastAgent's own
5
- * kind places them. Remove once pi-durable ships `afterFailedRun` and it covers aborts too.
5
+ * kind places them. pi-durable 1.4.0's `settings.afterFailedRun` covers a model failure (`model_error`, `no_model`)
6
+ * and not an abort: remove this once it does.
6
7
  */
7
8
  import type { Context } from "@earendil-works/chord/context";
8
9
  import { type Harness } from "@earendil-works/pi-durable";
@@ -33,8 +33,10 @@ export async function openLockedStorage(path, locking, holders) {
33
33
  return await SqliteStorage.open(adapter);
34
34
  }
35
35
  catch (error) {
36
- // The connection only: the adapter's close checkpoints first, which a refused opener cannot do.
37
- database.close();
36
+ // The connection only: the adapter's close checkpoints first, which a refused opener cannot do. Already closed
37
+ // when the storage refused the database itself (a schema it does not know), and closing it again would hide why.
38
+ if (database.isOpen)
39
+ database.close();
38
40
  if (error.errcode !== SQLITE_BUSY)
39
41
  throw error;
40
42
  const who = holders === undefined ? "" : `: ${holders} is running`;
@@ -464,6 +464,9 @@ declare const AgentHttpApi_base: HttpApi.HttpApi<"fastagent", HttpApiGroup.HttpA
464
464
  readonly event: Schema.String;
465
465
  readonly data: Schema.String;
466
466
  }>, never, never>, Schema.Never, {
467
+ readonly type: "message";
468
+ readonly message: Message;
469
+ } | {
467
470
  readonly type: "entry";
468
471
  readonly entry: Entry;
469
472
  } | {
@@ -499,9 +502,6 @@ declare const AgentHttpApi_base: HttpApi.HttpApi<"fastagent", HttpApiGroup.HttpA
499
502
  readonly retryable: boolean;
500
503
  readonly details?: Schema.Json | undefined;
501
504
  };
502
- } | {
503
- readonly type: "message";
504
- readonly message: Message;
505
505
  } | {
506
506
  readonly type: string;
507
507
  }> | Schema.toCodecJson<typeof Message>, Schema.toCodecJson<Schema.decodeTo<Schema.declare<AgentError, AgentError>, Schema.Struct<{
@@ -18,7 +18,9 @@ export declare function cronError(cron: string, tz: string | undefined): string
18
18
  * A function of the expression alone, so a file's verdict never flips with the time it is read. Two consecutive
19
19
  * instants under 10 minutes apart are on one day or across one midnight, and the time-of-day fields repeat every day,
20
20
  * so two days of the time-of-day form (the day fields made `*`, in UTC) hold every such gap. That is conservative:
21
- * `0,55 0,23 * * 1` is refused for the 23:55 → 00:00 pair, though Tuesday never fires. A DST change only lengthens a
22
- * gap (croner fires a repeated hour once). A year field is refused, since what repeats every year does not need one.
21
+ * `0,55 0,23 * * 1` is refused for the 23:55 → 00:00 pair, though Tuesday never fires. A DST change can bring two
22
+ * instants closer once a year, which this does not refuse: an expression with several times runs both passes of the
23
+ * hour the autumn change repeats (`0,55 1 * * *` at 01:55 and, 5 minutes on, the second 01:00), and a schedule whose
24
+ * session is busy skips a fire anyway. A year field is refused, since what repeats every year does not need one.
23
25
  */
24
26
  export declare function recurringCronError(cron: string, tz: string | undefined): string | undefined;
@@ -45,8 +45,10 @@ const verdicts = new Map();
45
45
  * A function of the expression alone, so a file's verdict never flips with the time it is read. Two consecutive
46
46
  * instants under 10 minutes apart are on one day or across one midnight, and the time-of-day fields repeat every day,
47
47
  * so two days of the time-of-day form (the day fields made `*`, in UTC) hold every such gap. That is conservative:
48
- * `0,55 0,23 * * 1` is refused for the 23:55 → 00:00 pair, though Tuesday never fires. A DST change only lengthens a
49
- * gap (croner fires a repeated hour once). A year field is refused, since what repeats every year does not need one.
48
+ * `0,55 0,23 * * 1` is refused for the 23:55 → 00:00 pair, though Tuesday never fires. A DST change can bring two
49
+ * instants closer once a year, which this does not refuse: an expression with several times runs both passes of the
50
+ * hour the autumn change repeats (`0,55 1 * * *` at 01:55 and, 5 minutes on, the second 01:00), and a schedule whose
51
+ * session is busy skips a fire anyway. A year field is refused, since what repeats every year does not need one.
50
52
  */
51
53
  export function recurringCronError(cron, tz) {
52
54
  const key = `${cron}\u0000${tz ?? ""}`;
@@ -6,9 +6,13 @@
6
6
  */
7
7
  /**
8
8
  * Translate a 5-field cron into EventBridge Scheduler's `cron(m h dom mon dow *)`, or say why it can't be. The two
9
- * fire on the same instants in the same zone, with one exception at a DST change: a local time the spring change
10
- * removes (02:30 in most of the US on that day) is skipped by EventBridge and run an hour later by croner, so such a
11
- * schedule runs once more a year on a resident host. A time the autumn change repeats runs once in both.
9
+ * fire on the same instants in the same zone, with two exceptions at a DST change, both on a resident host (croner):
10
+ * a local time the spring change removes (02:30 in most of the US on that day) is skipped by EventBridge and run by
11
+ * croner at the first valid time after the gap, and a schedule with several minutes or hours (every 15 minutes) runs
12
+ * both passes of the hour the autumn change repeats. EventBridge's documentation says a schedule "runs only once" when
13
+ * the clock goes back, which is read here as once per time: once for each of the four times of an every-15-minutes
14
+ * schedule. That reading is not tested against a live schedule. A single time in the repeated hour runs once in both.
15
+ * `?` is `*`, as croner reads it and as EventBridge means it ("any").
12
16
  */
13
17
  export declare function toEventBridgeCron(cron: string): {
14
18
  expression: string;
@@ -56,23 +56,24 @@ function mapDowField(dow) {
56
56
  }
57
57
  /**
58
58
  * Translate a 5-field cron into EventBridge Scheduler's `cron(m h dom mon dow *)`, or say why it can't be. The two
59
- * fire on the same instants in the same zone, with one exception at a DST change: a local time the spring change
60
- * removes (02:30 in most of the US on that day) is skipped by EventBridge and run an hour later by croner, so such a
61
- * schedule runs once more a year on a resident host. A time the autumn change repeats runs once in both.
59
+ * fire on the same instants in the same zone, with two exceptions at a DST change, both on a resident host (croner):
60
+ * a local time the spring change removes (02:30 in most of the US on that day) is skipped by EventBridge and run by
61
+ * croner at the first valid time after the gap, and a schedule with several minutes or hours (every 15 minutes) runs
62
+ * both passes of the hour the autumn change repeats. EventBridge's documentation says a schedule "runs only once" when
63
+ * the clock goes back, which is read here as once per time: once for each of the four times of an every-15-minutes
64
+ * schedule. That reading is not tested against a live schedule. A single time in the repeated hour runs once in both.
65
+ * `?` is `*`, as croner reads it and as EventBridge means it ("any").
62
66
  */
63
67
  export function toEventBridgeCron(cron) {
64
68
  const fields = cron.trim().split(/\s+/);
65
69
  if (fields.length !== 5) {
66
70
  return { error: `EventBridge supports 5-field cron only (got ${fields.length} fields)` };
67
71
  }
68
- const [min, hour, dom, mon, dow] = fields;
72
+ // EventBridge takes `?` in the day fields only, and exactly one of them; croner reads it as `*` anywhere.
73
+ const [min, hour, dom, mon, dow] = fields.map((field) => (field === "?" ? "*" : field));
69
74
  if (/[L#]/i.test(dow) || /[L#]/i.test(dom)) {
70
75
  return { error: "L/# day forms don't translate to EventBridge numbering — set this schedule up manually" };
71
76
  }
72
- // `?` FIRST, and not as a synonym for `*`.
73
- if (dom === "?" || dow === "?") {
74
- return { expression: `cron(${min} ${hour} * ${mon} ? *)` };
75
- }
76
77
  if (dom !== "*" && dow !== "*") {
77
78
  return {
78
79
  error: "restricting BOTH day-of-month and day-of-week (cron OR semantics) is not expressible in EventBridge",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastagent-sh/fastagent",
3
- "version": "0.26.0",
3
+ "version": "0.26.1",
4
4
  "description": "Vibe first. Then FastAgent: turn a local agent directory into a live service in your app, on Telegram, Slack, Feishu, or behind any channel.",
5
5
  "keywords": [
6
6
  "agent",
@@ -89,33 +89,33 @@
89
89
  },
90
90
  "dependencies": {
91
91
  "@clack/prompts": "^1.6.0",
92
- "@earendil-works/chord": "1.3.0",
93
- "@earendil-works/pi-ai": "1.3.0",
94
- "@earendil-works/pi-durable": "1.3.0",
92
+ "@earendil-works/chord": "1.4.0",
93
+ "@earendil-works/pi-ai": "1.4.0",
94
+ "@earendil-works/pi-durable": "1.4.0",
95
95
  "@effect/platform-bun": "^4.0.7",
96
96
  "@effect/platform-node": "^4.0.7",
97
- "@larksuiteoapi/node-sdk": "^1.71.1",
97
+ "@larksuiteoapi/node-sdk": "^1.76.0",
98
98
  "@smithy/signature-v4": "^5.7.4",
99
- "chokidar": "^5.0.0",
99
+ "chokidar": "^6.0.0",
100
100
  "commander": "^15.0.0",
101
- "croner": "^10.0.1",
101
+ "croner": "^11.0.0",
102
102
  "effect": "^4.0.7",
103
103
  "giget": "^3.3.0",
104
- "ignore": "^7.0.12",
104
+ "ignore": "^7.0.14",
105
105
  "proper-lockfile": "^4.1.2",
106
106
  "undici": "^8.6.0",
107
- "yaml": "^2.9.0"
107
+ "yaml": "^2.9.1"
108
108
  },
109
109
  "devDependencies": {
110
- "@biomejs/biome": "^2.5.15",
111
- "@earendil-works/pi-coding-agent": "1.3.0",
112
- "@hono/node-server": "^2.1.4",
110
+ "@biomejs/biome": "^2.6.4",
111
+ "@earendil-works/pi-coding-agent": "1.4.0",
112
+ "@hono/node-server": "^2.1.5",
113
113
  "@types/express": "^5.0.6",
114
114
  "@types/proper-lockfile": "^4.1.4",
115
- "express": "^5.2.1",
116
- "fastify": "^5.12.5",
117
- "knip": "^6.41.0",
115
+ "express": "^5.3.0",
116
+ "fastify": "^5.12.7",
117
+ "knip": "^6.44.0",
118
118
  "typescript": "^7.0.2",
119
- "vitest": "^5.0.3"
119
+ "vitest": "^5.0.5"
120
120
  }
121
121
  }