@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 +7 -5
- package/README.md +64 -3
- package/package.json +11 -11
- package/src/agent-job.ts +41 -2
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
|
|
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
|
|
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
|
|
261
|
-
REQUIRED; both reads of `target.job()` are LAZY.
|
|
262
|
-
|
|
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
|
|
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
|
|
539
|
-
whose second run changes nothing. Otherwise a replayed
|
|
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.
|
|
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.
|
|
36
|
-
"@ultimat3/cache": "25.
|
|
37
|
-
"@ultimat3/core": "25.
|
|
38
|
-
"@ultimat3/db": "25.
|
|
39
|
-
"@ultimat3/http": "25.
|
|
40
|
-
"@ultimat3/jobs": "25.
|
|
41
|
-
"@ultimat3/money": "25.
|
|
42
|
-
"@ultimat3/policy": "25.
|
|
43
|
-
"@ultimat3/schema": "25.
|
|
44
|
-
"@ultimat3/time": "25.
|
|
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 }) =>
|
|
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
|
|