la-machina-engine 0.7.1 → 0.7.3

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/README.md CHANGED
@@ -694,7 +694,9 @@ Shape:
694
694
 
695
695
  ### Webhooks
696
696
 
697
- Pass a `webhook` object to `start()` / `resumeAsync()` and the engine will POST the final `EngineResponse` to your URL on the configured events.
697
+ Async runs deliver status changes to a URL you own. Pass a `webhook`
698
+ object to `start()` / `resumeAsync()` and the engine POSTs the final
699
+ `EngineResponse` whenever the run reaches a terminal or pause state.
698
700
 
699
701
  ```ts
700
702
  await engine.start({
@@ -705,46 +707,285 @@ await engine.start({
705
707
  url: 'https://your-app.com/hooks/la-machina',
706
708
  secret: 'shared-hmac-secret', // optional — enables X-LaMachina-Signature
707
709
  events: ['paused', 'done', 'failed'], // default: all three
708
- headers: { 'X-Tenant': 'acme' }, // optional — passed through
710
+ headers: { 'X-Tenant': 'acme' }, // optional — passed through per request
709
711
  },
710
712
  })
711
713
  ```
712
714
 
713
- **Request headers:**
715
+ #### Events — what fires and when
714
716
 
715
- | Header | Value |
716
- |---|---|
717
- | `Content-Type` | `application/json` |
718
- | `X-LaMachina-Event` | `status.paused` \| `status.done` \| `status.failed` |
719
- | `X-LaMachina-RunId` | Run ID from your `start()` call |
720
- | `X-LaMachina-Delivery` | Unique UUID per delivery attempt |
721
- | `X-LaMachina-Timestamp` | Unix ms (used in HMAC input) |
722
- | `X-LaMachina-Signature` | `sha256=<hex>` — HMAC over `${timestamp}.${body}` (only if `secret` set) |
717
+ Three events, mapped 1:1 from `EngineResponse.status`:
718
+
719
+ | Event | Fires when | `data` field | `meta.pauseReason` |
720
+ |---|---|---|---|
721
+ | `done` | Model reached `end_turn` cleanly | Output string (or parsed JSON in structured-output mode) | |
722
+ | `paused` | Gate callback returned `{ allow: false }`, OR run needs runner handoff | `null` | `gate_required` \| `handoff_to_runner` |
723
+ | `failed` | Anything threw (API 5xx after retries, max turns, timeout, cancel, runner unreachable) | `null` | — (`errors[0]` has the cause) |
724
+
725
+ `queued`, `running`, and `not_found` **never fire webhooks** — they're
726
+ only observable via `getStatus()` polling. Webhooks are terminal /
727
+ pausal only.
728
+
729
+ #### When webhooks do vs don't fire
730
+
731
+ | API call | Webhooks? | Why |
732
+ |---|---|---|
733
+ | `engine.start({webhook})` | ✓ fires on terminal/pause | |
734
+ | `engine.resumeAsync({webhook})` | ✓ fires on terminal/pause | |
735
+ | `engine.run()` | **never** | Caller already has the response in hand |
736
+ | `engine.resume()` | **never** | Same — synchronous, caller holds the result |
737
+ | `engine.cancelRun(runId)` | in-flight run aborts and fires `failed` | Cancellation is a normal failure path |
738
+
739
+ Webhooks are for the async surface exclusively. Anything running
740
+ synchronously returns its response directly.
741
+
742
+ #### Request shape
743
+
744
+ `POST {webhook.url}` with body = `JSON.stringify(EngineResponse)` and:
745
+
746
+ | Header | Value | Notes |
747
+ |---|---|---|
748
+ | `Content-Type` | `application/json` | |
749
+ | `X-LaMachina-Event` | `status.done` \| `status.paused` \| `status.failed` | Event-type routing on the receiver |
750
+ | `X-LaMachina-RunId` | Run ID from your `start()` call | Correlate with client-side state |
751
+ | `X-LaMachina-Delivery` | Fresh UUID per attempt | **Use this for idempotency** — same delivery ID = retry of same logical event |
752
+ | `X-LaMachina-Timestamp` | Unix ms | Covered by the HMAC — lets receivers reject replays |
753
+ | `X-LaMachina-Signature` | `sha256=<hex>` | Only when `secret` is set; see "Verifying the signature" below |
754
+ | _(user headers)_ | whatever you passed in `webhook.headers` | Merged last, cannot override engine headers |
755
+
756
+ Request timeout is 30 s by default. The engine aborts slower receivers
757
+ and treats them as a retryable network failure.
758
+
759
+ #### Payload — one schema for every event
760
+
761
+ The body is always an `EngineResponse` (the same shape `engine.run()`
762
+ returns). The event type determines which fields are meaningful:
763
+
764
+ **`done` payload:**
765
+
766
+ ```jsonc
767
+ {
768
+ "runId": "run_abc",
769
+ "status": "done",
770
+ "data": "The analysis is complete. Revenue grew 15% YoY.",
771
+ "meta": {
772
+ "nodeId": "analyze",
773
+ "turns": 5,
774
+ "tokensUsed": { "input": 12500, "output": 3200, "cacheReadInput": 8000 },
775
+ "durationMs": 8500,
776
+ "output": "The analysis is complete. Revenue grew 15% YoY.",
777
+ "transcript": { "path": "projects/run_abc/nodes/analyze", "lastShardIndex": 2 }
778
+ },
779
+ "errors": [],
780
+ "timestamp": 1712966400000
781
+ }
782
+ ```
783
+
784
+ **`paused` payload:**
785
+
786
+ ```jsonc
787
+ {
788
+ "runId": "run_abc",
789
+ "status": "paused",
790
+ "data": null,
791
+ "meta": {
792
+ "nodeId": "publish",
793
+ "pauseReason": "gate_required",
794
+ "turns": 3,
795
+ "tokensUsed": { "input": 8200, "output": 1900 },
796
+ "pendingToolCall": {
797
+ "toolName": "Publish",
798
+ "toolUseId": "toolu_01abc",
799
+ "input": { "post": { "title": "...", "body": "..." } }
800
+ },
801
+ "transcript": { "path": "projects/run_abc/nodes/publish", "lastShardIndex": 1 }
802
+ },
803
+ "errors": [],
804
+ "timestamp": 1712966400000
805
+ }
806
+ ```
807
+
808
+ Use `pendingToolCall.input` to render an approval UI, then call
809
+ `engine.resumeAsync({ runId, gateAnswer: 'approve', webhook: {...} })`
810
+ to continue. A separate `done` (or `failed`) webhook will fire for the
811
+ resumed run.
812
+
813
+ **`failed` payload:**
723
814
 
724
- **Retry schedule** (exponential-ish):
815
+ ```jsonc
816
+ {
817
+ "runId": "run_abc",
818
+ "status": "failed",
819
+ "data": null,
820
+ "meta": {
821
+ "nodeId": "n1",
822
+ "cancelled": true // present when the failure was engine.cancelRun()
823
+ },
824
+ "errors": [
825
+ { "code": "CANCELLED", "message": "Run was cancelled by client" }
826
+ // Other codes: RUN_FAILED, RESUME_FAILED, ERR_RUNNER_UNREACHABLE, ERR_MAX_TURNS, ORPHANED, …
827
+ ],
828
+ "timestamp": 1712966400000
829
+ }
830
+ ```
831
+
832
+ The `errors[]` array holds `{code, message}` pairs — use `errors[0].code`
833
+ for programmatic routing, `message` for display.
834
+
835
+ #### Verifying the signature
836
+
837
+ When `webhook.secret` is set, the engine signs
838
+ `${X-LaMachina-Timestamp}.${body}` with HMAC-SHA256 and sets
839
+ `X-LaMachina-Signature: sha256=<hex>`. Verify in Node:
840
+
841
+ ```ts
842
+ import { createHmac, timingSafeEqual } from 'node:crypto'
843
+
844
+ function verifyLaMachinaWebhook(req: Request, rawBody: string, secret: string): boolean {
845
+ const ts = req.headers.get('x-lamachina-timestamp')
846
+ const sig = req.headers.get('x-lamachina-signature')
847
+ if (!ts || !sig) return false
848
+
849
+ // Reject replays older than 5 minutes
850
+ if (Math.abs(Date.now() - Number(ts)) > 5 * 60_000) return false
851
+
852
+ const expected =
853
+ 'sha256=' +
854
+ createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex')
855
+
856
+ // Constant-time comparison
857
+ const a = Buffer.from(sig)
858
+ const b = Buffer.from(expected)
859
+ return a.length === b.length && timingSafeEqual(a, b)
860
+ }
861
+ ```
862
+
863
+ On Cloudflare Workers (Web Crypto, no `node:crypto`):
864
+
865
+ ```ts
866
+ async function verifyLaMachinaWebhook(req: Request, rawBody: string, secret: string) {
867
+ const ts = req.headers.get('x-lamachina-timestamp')
868
+ const sig = req.headers.get('x-lamachina-signature')
869
+ if (!ts || !sig) return false
870
+ if (Math.abs(Date.now() - Number(ts)) > 5 * 60_000) return false
871
+
872
+ const key = await crypto.subtle.importKey(
873
+ 'raw',
874
+ new TextEncoder().encode(secret),
875
+ { name: 'HMAC', hash: 'SHA-256' },
876
+ false,
877
+ ['sign'],
878
+ )
879
+ const buf = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${ts}.${rawBody}`))
880
+ const expected =
881
+ 'sha256=' +
882
+ Array.from(new Uint8Array(buf))
883
+ .map((b) => b.toString(16).padStart(2, '0'))
884
+ .join('')
885
+ return expected === sig
886
+ }
887
+ ```
888
+
889
+ **Always verify against the raw bytes** you read from the request.
890
+ Re-serializing the parsed JSON will produce different bytes and the
891
+ signature won't match.
892
+
893
+ #### Idempotency — receivers MUST handle duplicates
894
+
895
+ `X-LaMachina-Delivery` is unique per attempt, but retries of the same
896
+ logical event may send the same payload to your endpoint multiple
897
+ times (network flaps, receiver returns 5xx, etc.). De-duplicate on:
898
+
899
+ - `X-LaMachina-Delivery` — reject second delivery with the same ID
900
+ - OR `runId + status + timestamp` — simpler, event-level dedup
901
+
902
+ Pattern: insert the delivery ID into a short-TTL cache (Redis, R2, DB
903
+ unique constraint); on collision return 200 without reprocessing.
904
+
905
+ #### Retry schedule
906
+
907
+ Fixed schedule per delivery attempt:
725
908
 
726
909
  ```
727
910
  attempt 1: immediate
728
- attempt 2: +10s
729
- attempt 3: +60s
730
- attempt 4: +5min
731
- attempt 5: +30min
732
- then give up
911
+ attempt 2: +10 s (after the previous attempt's failure)
912
+ attempt 3: +60 s
913
+ attempt 4: +5 min
914
+ attempt 5: +30 min
915
+ give up
733
916
  ```
734
917
 
735
918
  Retry decisions:
736
919
 
737
- | HTTP | Retry? |
920
+ | Receiver response | Retry? |
738
921
  |---|---|
739
- | 2xx | No (delivered) |
922
+ | 2xx | No delivered |
740
923
  | 408 Request Timeout | Yes |
741
924
  | 429 Rate Limited | Yes |
742
- | 5xx | Yes |
743
- | 410 Gone | **No** (permanent resource removed) |
744
- | Other 4xx | No (client bug don't retry) |
925
+ | 5xx (500–599) | Yes |
926
+ | **410 Gone** | **No — give up immediately** (resource intentionally removed) |
927
+ | Other 4xx (400/401/403/404/…) | No payload/auth bug; retrying won't help |
745
928
  | Network error / timeout | Yes |
746
929
 
747
- Every attempt is appended to `state.webhook.deliveries[]` for audit.
930
+ Every attempt — success or failure — is appended to
931
+ `state.webhook.deliveries[]` in `state.json`, including the HTTP status,
932
+ error message, delivery ID, timestamps, and attempt number. Inspect
933
+ via `engine.getStatus(runId)` or read `state.json` directly from R2.
934
+
935
+ #### Manual replay
936
+
937
+ If the receiver was down and the engine has already given up (5
938
+ attempts exhausted, or 4xx stopped retries), replay any past delivery:
939
+
940
+ ```ts
941
+ const status = await engine.getStatus(runId)
942
+ const missed = status.meta.webhook?.deliveries.find((d) => d.status === 'failed')
943
+ if (missed) {
944
+ await engine.retryWebhook(runId, missed.id)
945
+ }
946
+ ```
947
+
948
+ `retryWebhook` fires a fresh POST with a **new** delivery ID (so
949
+ receivers that already processed the original ID won't reject it as a
950
+ dup — this is a deliberate re-issuance, not a network retry) and
951
+ continues the retry schedule from attempt 1.
952
+
953
+ #### Correlated pause → resume
954
+
955
+ When a run emits `paused`, the client typically gathers a decision and
956
+ calls `resumeAsync({runId, gateAnswer, webhook})`. The resumed run
957
+ will emit **another** webhook on completion — usually `done`, sometimes
958
+ `paused` again if the model hits a second gate, or `failed` if resume
959
+ fails. Receivers should track `runId` state across events:
960
+
961
+ | State sequence | Meaning |
962
+ |---|---|
963
+ | `paused` → `done` | Happy-path HITL — approved and completed |
964
+ | `paused` → `paused` → `done` | Multi-step approval — each gate wake fires its own event |
965
+ | `paused` → `failed` (`CANCELLED`) | User rejected at the gate and cancelled the run |
966
+ | `paused` → (no follow-up) | Orphaned — caller never called `resumeAsync` |
967
+
968
+ Use `runId` as your correlation key across all events for a run.
969
+
970
+ #### What's NOT a webhook event (deliberate omissions)
971
+
972
+ These are intentionally out of scope:
973
+
974
+ - **Per-turn progress** — too chatty. Poll `getStatus(runId)` for
975
+ live turn / token / activity updates (the heartbeat writes
976
+ `state.json` every ~500 ms when activity changes).
977
+ - **Per-tool dispatch** — that's what `preToolCall` / `postToolCall`
978
+ hooks are for (in-process, synchronous).
979
+ - **Subagent lifecycle** — the parent's terminal/pause state is what
980
+ fires; child runs are opaque to external receivers.
981
+ - **Resume started / resume completed** — `resumeAsync()` returns
982
+ immediately with `{runId, nodeId, status: 'running'}`; the next
983
+ webhook you'll see is the resumed run's terminal state.
984
+
985
+ If you need finer-grained updates, use `getStatus()` polling — it
986
+ reads the heartbeat-updated `state.json` and gives you
987
+ `turns / tokensUsed / currentActivity / lastTool` in real time
988
+ without any webhook-driven traffic.
748
989
 
749
990
  ### Node.js example — sync HITL and async HITL together
750
991
 
package/dist/index.cjs CHANGED
@@ -1974,6 +1974,10 @@ function toAISdkTools(tools) {
1974
1974
  }
1975
1975
 
1976
1976
  // src/model/aiSdkAdapter.ts
1977
+ var import_anthropic = require("@ai-sdk/anthropic");
1978
+ var import_openai = require("@ai-sdk/openai");
1979
+ var import_google = require("@ai-sdk/google");
1980
+ var import_openai_compatible = require("@ai-sdk/openai-compatible");
1977
1981
  var AISdkAdapter = class {
1978
1982
  options;
1979
1983
  model = null;
@@ -2034,23 +2038,18 @@ var AISdkAdapter = class {
2034
2038
  async getModel() {
2035
2039
  if (this.model !== null) return this.model;
2036
2040
  const { provider, modelId, apiKey, baseURL } = this.options;
2037
- let mod;
2038
2041
  switch (provider) {
2039
2042
  case "anthropic":
2040
- mod = await importOrThrow("@ai-sdk/anthropic", provider);
2041
- this.model = mod.createAnthropic({ apiKey })(modelId);
2043
+ this.model = (0, import_anthropic.createAnthropic)({ apiKey })(modelId);
2042
2044
  break;
2043
2045
  case "openai":
2044
- mod = await importOrThrow("@ai-sdk/openai", provider);
2045
- this.model = mod.createOpenAI({ apiKey, ...baseURL ? { baseURL } : {} })(modelId);
2046
+ this.model = (0, import_openai.createOpenAI)({ apiKey, ...baseURL ? { baseURL } : {} })(modelId);
2046
2047
  break;
2047
2048
  case "google":
2048
- mod = await importOrThrow("@ai-sdk/google", provider);
2049
- this.model = mod.createGoogleGenerativeAI({ apiKey })(modelId);
2049
+ this.model = (0, import_google.createGoogleGenerativeAI)({ apiKey })(modelId);
2050
2050
  break;
2051
2051
  case "openai-compatible":
2052
- mod = await importOrThrow("@ai-sdk/openai-compatible", provider);
2053
- this.model = mod.createOpenAICompatible({ name: "custom", apiKey, baseURL: baseURL ?? "" })(
2052
+ this.model = (0, import_openai_compatible.createOpenAICompatible)({ name: "custom", apiKey, baseURL: baseURL ?? "" })(
2054
2053
  modelId
2055
2054
  );
2056
2055
  break;
@@ -2072,13 +2071,6 @@ function mapFinishReason(reason) {
2072
2071
  return "end_turn";
2073
2072
  }
2074
2073
  }
2075
- async function importOrThrow(pkg, provider) {
2076
- try {
2077
- return await import(pkg);
2078
- } catch {
2079
- throw new Error(`Provider "${provider}" requires "${pkg}". Install: npm i ${pkg}`);
2080
- }
2081
- }
2082
2074
 
2083
2075
  // src/model/factory.ts
2084
2076
  function createModelAdapter(config, options = {}) {
@@ -7874,6 +7866,9 @@ async function collectSkills(storage, skillsDir) {
7874
7866
  // src/engine/jsonOutput.ts
7875
7867
  init_cjs_shims();
7876
7868
  var import_zod_to_json_schema2 = require("zod-to-json-schema");
7869
+ function isZodSchema(s) {
7870
+ return s !== null && typeof s === "object" && "_def" in s && typeof s.safeParse === "function";
7871
+ }
7877
7872
  function buildSchemaPrompt(schema) {
7878
7873
  const lines = [
7879
7874
  "# Output Format",
@@ -7883,11 +7878,18 @@ function buildSchemaPrompt(schema) {
7883
7878
  "Do NOT wrap in ```json ... ```. Just raw JSON."
7884
7879
  ];
7885
7880
  if (schema) {
7886
- const jsonSchema2 = (0, import_zod_to_json_schema2.zodToJsonSchema)(schema, {
7887
- target: "jsonSchema7",
7888
- $refStrategy: "none"
7889
- });
7890
- const { $schema: _, ...clean } = jsonSchema2;
7881
+ let clean;
7882
+ if (isZodSchema(schema)) {
7883
+ const jsonSchema2 = (0, import_zod_to_json_schema2.zodToJsonSchema)(schema, {
7884
+ target: "jsonSchema7",
7885
+ $refStrategy: "none"
7886
+ });
7887
+ const { $schema: _z, ...rest } = jsonSchema2;
7888
+ clean = rest;
7889
+ } else {
7890
+ const { $schema: _j, ...rest } = schema;
7891
+ clean = rest;
7892
+ }
7891
7893
  lines.push("", "The JSON MUST conform to this schema:", JSON.stringify(clean, null, 2));
7892
7894
  } else {
7893
7895
  lines.push("", "Return a JSON object with the relevant data.");
@@ -7923,6 +7925,9 @@ function tryParseJSON2(text2) {
7923
7925
  return { ok: false, error: "No valid JSON found in response" };
7924
7926
  }
7925
7927
  function validateOutput(value, schema) {
7928
+ if (!isZodSchema(schema)) {
7929
+ return { ok: true, data: value };
7930
+ }
7926
7931
  const result = schema.safeParse(value);
7927
7932
  if (result.success) {
7928
7933
  return { ok: true, data: result.data };