@ultimat3/ai 25.0.0 → 25.1.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/CLAUDE.md CHANGED
@@ -73,9 +73,9 @@ exposure, the whole input schema (an idempotent action's reserved `idempotencyKe
73
73
  | `llm-cache.ts` | the semantic cache half of `llm()`: what a declaration may partition on, and the store it reaches |
74
74
  | `llm-fixture.ts` | the harness `llm.test.ts` and `llm-cache.test.ts` share. Not shipped (`!src/**/*-fixture.ts`) |
75
75
  | `bounds-fixture.ts` | the one `refusal`/`asyncRefusal` every numeric-bound suite here asserts through. Not shipped, same rule |
76
- | `agent-bounds.test.ts` | the agent loop's ceilings, split out because `agent.test.ts` is at the 500-line ceiling |
76
+ | `agent-bounds.test.ts` | the agent loop's ceilings (split from `agent.test.ts`, at its line ceiling) |
77
77
  | `runtime.ts` | the ambient gateway / embedder / semantic caches an `llm()` reaches |
78
- | `fix-line.ts` / `fix-line.evals.ts` / `fix-line.v1.baseline.json` / `fix-line.eval.test.ts` | the package's own dogfood eval — the first framework-level `*.eval.test.ts`, proving the `defineEval`/baseline convention actually fails a build |
78
+ | `fix-line-fixture.ts` / `fix-line.evals-fixture.ts` / `fix-line.v1.baseline.json` / `fix-line.eval.test.ts` | the package's dogfood eval — proof the baseline convention fails a build |
79
79
 
80
80
  ## Invariants — bounds and budget
81
81
 
@@ -257,9 +257,11 @@ exposure, the whole input schema (an idempotent action's reserved `idempotencyKe
257
257
  unwinds with `X_ABORTED`.
258
258
  - **`describeAgents()` / `registeredModels()` are offered, not published** — their only legal consumer
259
259
  is `@ultimat3/cli`. The facts are a THUNK; an unnamed agent has no row.
260
- - **`agentJob()` composes `job()`** (never an imitation handle). `name`, `tenant`, `retry` are
261
- REQUIRED; both reads of `target.job()` are LAZY. **Every tool an `agentJob()`'d agent may call must
262
- be idempotent** — stated in `AgentJobOptions.idempotencyKey`'s doc and the README, not enforceable.
260
+ - **`agentJob()` composes `job()`** (never an imitation). `name`, `tenant`, `retry` are
261
+ REQUIRED; both reads of `target.job()` are LAZY. No result is kept: wrap an ACTION that runs the
262
+ agent and writes, target bound from its input — never a model-aimed write tool; every write
263
+ idempotent (unenforceable). **`actor`** re-resolves who it acts FOR each attempt (a served worker:
264
+ ANONYMOUS) via `impersonate`; off-tenant is TERMINAL `X_JOB_TENANT_MISMATCH`.
263
265
 
264
266
  ## Invariants — evals, retrieval, fix lines
265
267
 
package/README.md CHANGED
@@ -516,6 +516,63 @@ export const summariseBacklog = agentJob(summarisePost, {
516
516
  });
517
517
  ```
518
518
 
519
+ An agent whose policy reads a member — every real app's — names who it runs for. And an agent
520
+ whose result must be KEPT is queued through an action that runs it and makes the write itself.
521
+
522
+ **Never hand the model a write tool whose target it chooses.** The model's input is text someone
523
+ else wrote — a draft, a ticket, a page — and "now record this for record X" is an instruction a
524
+ model may follow, so a write tool is a write that text can aim. The action binds the target from
525
+ its OWN input, and the agent stays read-only. The reference app's `keepDraftReview` →
526
+ `reviewDraftLater` (`examples/dummy/apps/web/app/posts/actions.ts`, tested on the production
527
+ worker in `review.job.test.ts`) is the worked call:
528
+
529
+ ```ts
530
+ import { action, t } from '@ultimat3/action';
531
+ import { type Actor, userActor } from '@ultimat3/core';
532
+ import { agent, agentJob, definePrompt } from '@ultimat3/ai';
533
+ import { can } from '@ultimat3/policy';
534
+
535
+ // The app's own reads and its one write, scoped by the job's tenant.
536
+ declare function memberById(id: string): Promise<{ id: string; orgId: string; role: string }>;
537
+ declare function saveReview(postId: string, review: { verdict: string }): Promise<{ verdict: string }>;
538
+
539
+ // READ-ONLY: no tool here writes, so nothing in the post it reads can aim a write.
540
+ const reviewDraft = agent({
541
+ input: t.object({ postId: t.uuid, orgId: t.uuid }),
542
+ output: t.object({ verdict: t.string }),
543
+ prompt: definePrompt<{ postId: string }>({
544
+ id: 'review-draft',
545
+ version: '1.0.0',
546
+ template: 'Review post {{postId}}.',
547
+ }),
548
+ vars: ({ input }) => ({ postId: input.postId }),
549
+ tools: [],
550
+ policy: can('post:read'),
551
+ });
552
+
553
+ // The write, bound to the post id THIS action was given — an upsert, so a replayed run is one row.
554
+ const keepReview = action({
555
+ input: t.object({ postId: t.uuid, orgId: t.uuid, memberId: t.uuid }),
556
+ output: t.object({ verdict: t.string }),
557
+ policy: can('post:publish'),
558
+ async handle({ input, ctx }) {
559
+ const review = await reviewDraft.as(ctx.actor, { postId: input.postId, orgId: input.orgId });
560
+ return saveReview(input.postId, review);
561
+ },
562
+ });
563
+
564
+ export const reviewLater = agentJob(keepReview, {
565
+ name: 'posts.review',
566
+ tenant: (input) => input.orgId,
567
+ retry: { attempts: 3, backoff: 'exponential' },
568
+ // Re-read on every attempt: a member who left, or lost the role, is refused rather than trusted.
569
+ actor: async ({ input }): Promise<Actor> => {
570
+ const member = await memberById(input.memberId);
571
+ return userActor({ id: member.id, orgId: member.orgId, roles: [member.role] });
572
+ },
573
+ });
574
+ ```
575
+
519
576
  It composes `job()` rather than imitating a handle, so `.enqueue()`, the outbox, the worker's
520
577
  cancellation, `x jobs show` and its manifest row all arrive for free. Pair it with `backfill()` for
521
578
  the sweep and `hive()` for the fan-out inside one page.
@@ -526,7 +583,9 @@ the sweep and `hive()` for the fan-out inside one page.
526
583
  | `tenant` and `retry` are required, no default | `jobs` states it: every candidate default for `tenant` is a cross-tenant read waiting for the first job that takes an org id in its input. `tenant: 'none'` is the explicit statement that it touches no scoped table |
527
584
  | the action projection is read **lazily** | `agentJob()` runs at module scope beside the `agent()` it wraps, and names are stamped by `registerAction` at boot — reading `.job()` eagerly makes that ordinary file `X_ACTION_UNREGISTERED` |
528
585
  | one execution path, and it is the action's | `run` is `invoke(agent, input, { surface: 'job', ctx })`, so the agent's policy, input parse, budget scope and span all apply — and the `ctx` is the worker's, so an attempt timing out aborts the agent's turn loop |
529
- | the actor is the worker context's, never the model's | the job body runs with system authority and the org comes from the job's declared `tenant`; nothing a model emits can reach either |
586
+ | the actor is the worker context's, never the model's | the org comes from the job's declared `tenant`; nothing a model emits can reach the identity. In a served app the worker's actor is the **anonymous** one, so a member policy refuses it `X_UNAUTHENTICATED` — declare `actor` |
587
+ | `actor({ input, ctx })` resolves who the run acts FOR, on every attempt | the jobs rule — a job acting for a user takes the user's id in its input and re-authorises it in the body — for a body that is the agent's. Load the member under the job's tenant; the swap is core's `impersonate`, so the worker stays on the record as `onBehalfOf`. An actor in another org than `tenant` is `X_JOB_TENANT_MISMATCH` before the agent starts — terminal, so it dead-letters on attempt 1 |
588
+ | a queued run's return value is **not stored** | `x_jobs` keeps no result column. What the agent produces is kept by the ACTION `agentJob` wraps — it runs the agent, then writes with the ids in its own input — never by a write tool whose target the model picks. That write has to be idempotent (below) |
530
589
 
531
590
  ### The at-least-once trap, said plainly
532
591
 
@@ -535,8 +594,10 @@ one row. One row that a worker claims, half-runs and loses the lease on is claim
535
594
  agent runs a second time from the top** — as does every page a `backfill()` replays, since its
536
595
  `handle` is at-least-once by construction.
537
596
 
538
- So every tool the agent may call has to be idempotent: an `upsertAll`, an `updateWhere`, a statement
539
- whose second run changes nothing. Otherwise a replayed attempt issues a second refund.
597
+ So every write a run makes has to be idempotent — the wrapping action's, and any tool's: an
598
+ `upsertAll`, an `updateWhere`, a statement whose second run changes nothing. Otherwise a replayed
599
+ attempt issues a second refund. And a write the run needs belongs in the wrapping action, bound to
600
+ its input, rather than in a tool: idempotent is not the same as aimed where you meant.
540
601
 
541
602
  An action declaring `idempotent: true` offers the model the same optional `idempotencyKey`
542
603
  argument `@ultimat3/mcp` advertises, and the key reaches `invoke`: a model retrying an ambiguous
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/ai",
3
- "version": "25.0.0",
3
+ "version": "25.1.0",
4
4
  "description": "LLM gateway, versioned prompts, evals as tests, embeddings, hybrid vector search, RAG",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,15 +32,15 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/action": "25.0.0",
36
- "@ultimat3/cache": "25.0.0",
37
- "@ultimat3/core": "25.0.0",
38
- "@ultimat3/db": "25.0.0",
39
- "@ultimat3/http": "25.0.0",
40
- "@ultimat3/jobs": "25.0.0",
41
- "@ultimat3/money": "25.0.0",
42
- "@ultimat3/policy": "25.0.0",
43
- "@ultimat3/schema": "25.0.0",
44
- "@ultimat3/time": "25.0.0"
35
+ "@ultimat3/action": "25.1.0",
36
+ "@ultimat3/cache": "25.1.0",
37
+ "@ultimat3/core": "25.1.0",
38
+ "@ultimat3/db": "25.1.0",
39
+ "@ultimat3/http": "25.1.0",
40
+ "@ultimat3/jobs": "25.1.0",
41
+ "@ultimat3/money": "25.1.0",
42
+ "@ultimat3/policy": "25.1.0",
43
+ "@ultimat3/schema": "25.1.0",
44
+ "@ultimat3/time": "25.1.0"
45
45
  }
46
46
  }
package/src/agent-job.ts CHANGED
@@ -13,8 +13,10 @@
13
13
  */
14
14
 
15
15
  import type { Action, ActionJobHandle } from '@ultimat3/action';
16
+ import type { Actor, Ctx } from '@ultimat3/core';
17
+ import { impersonate, useContext } from '@ultimat3/core';
16
18
  import type { JobHandle, JobTenant, RetryPolicy } from '@ultimat3/jobs';
17
- import { job } from '@ultimat3/jobs';
19
+ import { JobTenantMismatchError, job } from '@ultimat3/jobs';
18
20
  import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
19
21
 
20
22
  export interface AgentJobOptions<I> {
@@ -51,6 +53,19 @@ export interface AgentJobOptions<I> {
51
53
  * indistinguishable here, and a rule refusing both would be a wrong refusal. See the README.
52
54
  */
53
55
  idempotencyKey?(input: I): string;
56
+ /**
57
+ * Who the run acts FOR, resolved from its own input on EVERY attempt — the jobs rule "a job
58
+ * acting for a user takes the user's id in its input and re-authorises it in the body", for a
59
+ * body that is the agent's. Without it the agent, its policy and every tool run as the WORKER,
60
+ * which in a served app is the anonymous actor: a member rule refuses it `X_UNAUTHENTICATED`.
61
+ *
62
+ * Load the member under the job's tenant (the ambient context already carries it) and build the
63
+ * same actor a request would: a member who left, or lost the role, is refused on the next
64
+ * attempt rather than trusted from the enqueue. The swap is core's `impersonate`, so the worker
65
+ * stays on the record as `onBehalfOf`. An actor in another org than `tenant` declared is refused
66
+ * before the agent starts — `tenant` is the org this run acts under, and nothing moves it.
67
+ */
68
+ actor?(args: { readonly input: I; readonly ctx: Ctx }): Promise<Actor> | Actor;
54
69
  }
55
70
 
56
71
  /**
@@ -82,7 +97,31 @@ export function agentJob<TInput extends StandardSchemaV1, TOutput extends Standa
82
97
  // input, { surface: 'job', ctx })`, so the agent's policy, its input parse, its budget scope
83
98
  // and its span all apply — and `ctx` is the WORKER's, so `ctx.signal` aborting at the attempt
84
99
  // timeout reaches the agent's turn loop.
85
- run: ({ input, ctx }) => bridge().invoke(asInput<TInput>(input), ctx),
100
+ run: async ({ input, ctx }) => {
101
+ if (options.actor === undefined) return bridge().invoke(asInput<TInput>(input), ctx);
102
+ const actor = await options.actor({ input, ctx });
103
+ assertSameTenant(options.name, actor, ctx.actor.orgId);
104
+ return impersonate(actor, `agentJob ${options.name} acts for the actor its input names`, () =>
105
+ // The CHILD context: the resolved actor, the worker's signal, deadline and services.
106
+ bridge().invoke(asInput<TInput>(input), useContext()),
107
+ );
108
+ },
109
+ });
110
+ }
111
+
112
+ /**
113
+ * The resolver's actor stays inside the org the job declared. Coded and TERMINAL — jobs' own
114
+ * `X_JOB_TENANT_MISMATCH`, the refusal a webhook delivery naming the wrong org gets — because the
115
+ * same payload resolves the same foreign actor on every attempt: an unclassified `assert` spent the
116
+ * whole retry policy re-proving it.
117
+ */
118
+ function assertSameTenant(name: string, actor: Actor, tenant: string | undefined): void {
119
+ const orgId = actor.orgId ?? undefined;
120
+ if (orgId === tenant) return;
121
+ throw new JobTenantMismatchError({
122
+ job: name,
123
+ reason: `resolved an actor in org ${orgId ?? '(none)'} through actor(), but the run is under ${tenant === undefined ? "tenant 'none'" : `org ${tenant}`}, so it would act outside the org it declared`,
124
+ fix: `actor: ({ input, ctx }) => memberActorIn(ctx.actor.orgId, input) — in agentJob("${name}"), load the member under the job's own tenant, so a member of another org is not found rather than returned`,
86
125
  });
87
126
  }
88
127