octaflow 0.28.0 → 0.29.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.
- package/dist/ai/index.d.ts +6 -5
- package/dist/ai/index.js +50 -6
- package/dist/ai/providers/index.d.ts +1 -1
- package/dist/ai/providers/index.js +8 -1
- package/dist/ai/registry.d.ts +1 -1
- package/dist/ai/registry.js +1 -2
- package/dist/{provider-registry-BRwjC6_3.js → provider-registry-B91lgOmu.js} +48 -12
- package/dist/{provider-registry-BvXBqJDR.d.ts → provider-registry-C_mOfdHH.d.ts} +1 -1
- package/package.json +17 -16
- package/skills/add-a-decision/SKILL.md +200 -0
- package/skills/add-an-ai-proposal-workflow/SKILL.md +228 -0
package/dist/ai/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { A as TypedStep, H as RetryPolicy, M as TypedWorkflow, f as Decider, j as TypedStepContext, vt as FlowErrorShape, yt as Result } from "../index-B8SvtiYn.js";
|
|
2
2
|
import { n as Logger } from "../logger-BCus3AOa.js";
|
|
3
3
|
import { i as BuildStepContextArgs, o as WorkflowHooks } from "../hooks-BV-Vany9.js";
|
|
4
|
-
import { C as isSubprocessorResidency, S as embeddingDimensionsFor, _ as EmbeddingProviderMeta, a as AiProcessingRegion, b as OpenAiCompatibleProviderId, c as AiProviderModelOption, d as AiVendorResidency, f as DEFAULT_AI_PROVIDER, g as EmbeddingProviderId, h as EMBEDDING_PROVIDER_IDS, i as AiEndpointResidency, l as AiResidencyError, m as EMBEDDING_PROVIDERS, n as AI_PROVIDER_IDS, o as AiProviderId, p as DEFAULT_EMBEDDING_PROVIDER, r as AiEndpointRegion, s as AiProviderMeta, t as AI_PROVIDERS, u as AiResidencyResult, v as OPENAI_COMPATIBLE_PROVIDER, w as resolveEndpointResidency, x as OpenAiCompatibleProviderMeta, y as OPENAI_COMPATIBLE_PROVIDER_ID } from "../provider-registry-
|
|
4
|
+
import { C as isSubprocessorResidency, S as embeddingDimensionsFor, _ as EmbeddingProviderMeta, a as AiProcessingRegion, b as OpenAiCompatibleProviderId, c as AiProviderModelOption, d as AiVendorResidency, f as DEFAULT_AI_PROVIDER, g as EmbeddingProviderId, h as EMBEDDING_PROVIDER_IDS, i as AiEndpointResidency, l as AiResidencyError, m as EMBEDDING_PROVIDERS, n as AI_PROVIDER_IDS, o as AiProviderId, p as DEFAULT_EMBEDDING_PROVIDER, r as AiEndpointRegion, s as AiProviderMeta, t as AI_PROVIDERS, u as AiResidencyResult, v as OPENAI_COMPATIBLE_PROVIDER, w as resolveEndpointResidency, x as OpenAiCompatibleProviderMeta, y as OPENAI_COMPATIBLE_PROVIDER_ID } from "../provider-registry-C_mOfdHH.js";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { EmbeddingModelV4, LanguageModelV4, SharedV4ProviderMetadata } from "@ai-sdk/provider";
|
|
7
7
|
//#region src/ai/instrumented-model.d.ts
|
|
@@ -73,8 +73,8 @@ export interface TokenUsage {
|
|
|
73
73
|
cacheWriteTokens: number;
|
|
74
74
|
}
|
|
75
75
|
/**
|
|
76
|
-
* Default pricing, USD per million tokens — Anthropic as of 2026-
|
|
77
|
-
* the OpenAI embeddings as of 2026-08. A best-effort estimate, not a billing
|
|
76
|
+
* Default pricing, USD per million tokens — Anthropic as of 2026-10, Mistral and
|
|
77
|
+
* the OpenAI embeddings as of 2026-08, the OpenAI language models as of 2026-10. A best-effort estimate, not a billing
|
|
78
78
|
* record: a BYOK caller can name any model id, and an unknown id prices at the
|
|
79
79
|
* priciest row (safe overcounting — see {@link createCostEstimator}). Retired ids
|
|
80
80
|
* are kept rather than deleted: dropping one silently reprices every historical
|
|
@@ -507,7 +507,8 @@ export declare function createRefusalAwareModel(model: LanguageModelV4): Languag
|
|
|
507
507
|
/**
|
|
508
508
|
* Per-model API capability switches.
|
|
509
509
|
*
|
|
510
|
-
* Claude 4.6+ models (Opus 4.6/4.7/4.8/5, Sonnet 4.6/5,
|
|
510
|
+
* Claude 4.6+ models (Opus 4.6/4.7/4.8/5/5.5, Sonnet 4.6/5/5.5, Haiku 5.5,
|
|
511
|
+
* Fable/Mythos 5) use
|
|
511
512
|
* adaptive thinking; on 4.7+ the legacy `thinking: { type: 'enabled',
|
|
512
513
|
* budget_tokens }` shape and non-default sampling params (`temperature`,
|
|
513
514
|
* `top_p`, `top_k`) are rejected with a 400. Older models (Haiku 4.5,
|
|
@@ -519,7 +520,7 @@ export declare function createRefusalAwareModel(model: LanguageModelV4): Languag
|
|
|
519
520
|
* functions are that branch, written once. `providerId` is a plain string:
|
|
520
521
|
* only `'anthropic'` has provider-specific shapes today.
|
|
521
522
|
*/
|
|
522
|
-
/** True for Claude 4.6+ / 5-family models: adaptive thinking, no budgetTokens, no sampling params. */
|
|
523
|
+
/** True for Claude 4.6+ / 5-family models (`claude-opus-5` covers `claude-opus-5-5`, and so on): adaptive thinking, no budgetTokens, no sampling params. */
|
|
523
524
|
export declare function supportsAdaptiveThinking(modelId: string): boolean;
|
|
524
525
|
export interface AnthropicThinkingSettings {
|
|
525
526
|
temperature?: number;
|
package/dist/ai/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { D as ok, E as err, T as noopLogger, _ as defineStep, m as buildWorkflow } from "../core-C9h3-KFr.js";
|
|
2
2
|
import { a as readRefusalDetails, i as isAiRefusalError, n as REFUSAL_FINISH_REASON, r as createRefusalAwareModel, t as AiRefusalError } from "../refusal-BsL2HPDb.js";
|
|
3
|
-
import { a as EMBEDDING_PROVIDERS, c as OPENAI_COMPATIBLE_PROVIDER_ID, d as resolveEndpointResidency, i as DEFAULT_EMBEDDING_PROVIDER, l as embeddingDimensionsFor, n as AI_PROVIDER_IDS, o as EMBEDDING_PROVIDER_IDS, r as DEFAULT_AI_PROVIDER, s as OPENAI_COMPATIBLE_PROVIDER, t as AI_PROVIDERS, u as isSubprocessorResidency } from "../provider-registry-
|
|
3
|
+
import { a as EMBEDDING_PROVIDERS, c as OPENAI_COMPATIBLE_PROVIDER_ID, d as resolveEndpointResidency, i as DEFAULT_EMBEDDING_PROVIDER, l as embeddingDimensionsFor, n as AI_PROVIDER_IDS, o as EMBEDDING_PROVIDER_IDS, r as DEFAULT_AI_PROVIDER, s as OPENAI_COMPATIBLE_PROVIDER, t as AI_PROVIDERS, u as isSubprocessorResidency } from "../provider-registry-B91lgOmu.js";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { Output, generateText, wrapEmbeddingModel, wrapLanguageModel } from "ai";
|
|
6
6
|
//#region src/ai/instrumented-model.ts
|
|
@@ -125,8 +125,8 @@ function createInstrumentedEmbeddingModel(model, accumulator) {
|
|
|
125
125
|
//#endregion
|
|
126
126
|
//#region src/ai/cost.ts
|
|
127
127
|
/**
|
|
128
|
-
* Default pricing, USD per million tokens — Anthropic as of 2026-
|
|
129
|
-
* the OpenAI embeddings as of 2026-08. A best-effort estimate, not a billing
|
|
128
|
+
* Default pricing, USD per million tokens — Anthropic as of 2026-10, Mistral and
|
|
129
|
+
* the OpenAI embeddings as of 2026-08, the OpenAI language models as of 2026-10. A best-effort estimate, not a billing
|
|
130
130
|
* record: a BYOK caller can name any model id, and an unknown id prices at the
|
|
131
131
|
* priciest row (safe overcounting — see {@link createCostEstimator}). Retired ids
|
|
132
132
|
* are kept rather than deleted: dropping one silently reprices every historical
|
|
@@ -170,6 +170,12 @@ const DEFAULT_MODEL_PRICING = {
|
|
|
170
170
|
cacheReadPerMillion: .25,
|
|
171
171
|
cacheWritePerMillion: 12.5
|
|
172
172
|
},
|
|
173
|
+
"claude-opus-5-5": {
|
|
174
|
+
inputPerMillion: 4,
|
|
175
|
+
outputPerMillion: 20,
|
|
176
|
+
cacheReadPerMillion: .2,
|
|
177
|
+
cacheWritePerMillion: 5
|
|
178
|
+
},
|
|
173
179
|
"claude-opus-5": {
|
|
174
180
|
inputPerMillion: 5,
|
|
175
181
|
outputPerMillion: 25,
|
|
@@ -182,6 +188,18 @@ const DEFAULT_MODEL_PRICING = {
|
|
|
182
188
|
cacheReadPerMillion: .2,
|
|
183
189
|
cacheWritePerMillion: 2.5
|
|
184
190
|
},
|
|
191
|
+
"claude-sonnet-5-5": {
|
|
192
|
+
inputPerMillion: 2,
|
|
193
|
+
outputPerMillion: 10,
|
|
194
|
+
cacheReadPerMillion: .2,
|
|
195
|
+
cacheWritePerMillion: 2.5
|
|
196
|
+
},
|
|
197
|
+
"claude-haiku-5-5": {
|
|
198
|
+
inputPerMillion: .1,
|
|
199
|
+
outputPerMillion: .5,
|
|
200
|
+
cacheReadPerMillion: .01,
|
|
201
|
+
cacheWritePerMillion: .125
|
|
202
|
+
},
|
|
185
203
|
"claude-opus-4-8": {
|
|
186
204
|
inputPerMillion: 5,
|
|
187
205
|
outputPerMillion: 25,
|
|
@@ -265,6 +283,30 @@ const DEFAULT_MODEL_PRICING = {
|
|
|
265
283
|
outputPerMillion: 0,
|
|
266
284
|
cacheReadPerMillion: 0,
|
|
267
285
|
cacheWritePerMillion: 0
|
|
286
|
+
},
|
|
287
|
+
"gpt-6-astra": {
|
|
288
|
+
inputPerMillion: 10,
|
|
289
|
+
outputPerMillion: 50,
|
|
290
|
+
cacheReadPerMillion: 1,
|
|
291
|
+
cacheWritePerMillion: 12.5
|
|
292
|
+
},
|
|
293
|
+
"gpt-6.1-sol": {
|
|
294
|
+
inputPerMillion: 2,
|
|
295
|
+
outputPerMillion: 10,
|
|
296
|
+
cacheReadPerMillion: .1,
|
|
297
|
+
cacheWritePerMillion: 2.5
|
|
298
|
+
},
|
|
299
|
+
"gpt-6-sol": {
|
|
300
|
+
inputPerMillion: 2,
|
|
301
|
+
outputPerMillion: 10,
|
|
302
|
+
cacheReadPerMillion: .2,
|
|
303
|
+
cacheWritePerMillion: 2.5
|
|
304
|
+
},
|
|
305
|
+
"gpt-6-luna": {
|
|
306
|
+
inputPerMillion: .1,
|
|
307
|
+
outputPerMillion: .5,
|
|
308
|
+
cacheReadPerMillion: .01,
|
|
309
|
+
cacheWritePerMillion: .125
|
|
268
310
|
}
|
|
269
311
|
};
|
|
270
312
|
/**
|
|
@@ -627,7 +669,8 @@ function createAiUsageAggregationService(deps) {
|
|
|
627
669
|
/**
|
|
628
670
|
* Per-model API capability switches.
|
|
629
671
|
*
|
|
630
|
-
* Claude 4.6+ models (Opus 4.6/4.7/4.8/5, Sonnet 4.6/5,
|
|
672
|
+
* Claude 4.6+ models (Opus 4.6/4.7/4.8/5/5.5, Sonnet 4.6/5/5.5, Haiku 5.5,
|
|
673
|
+
* Fable/Mythos 5) use
|
|
631
674
|
* adaptive thinking; on 4.7+ the legacy `thinking: { type: 'enabled',
|
|
632
675
|
* budget_tokens }` shape and non-default sampling params (`temperature`,
|
|
633
676
|
* `top_p`, `top_k`) are rejected with a 400. Older models (Haiku 4.5,
|
|
@@ -647,9 +690,10 @@ const ADAPTIVE_THINKING_MODEL_PREFIXES = [
|
|
|
647
690
|
"claude-opus-4-7",
|
|
648
691
|
"claude-opus-4-6",
|
|
649
692
|
"claude-sonnet-5",
|
|
650
|
-
"claude-sonnet-4-6"
|
|
693
|
+
"claude-sonnet-4-6",
|
|
694
|
+
"claude-haiku-5"
|
|
651
695
|
];
|
|
652
|
-
/** True for Claude 4.6+ / 5-family models: adaptive thinking, no budgetTokens, no sampling params. */
|
|
696
|
+
/** True for Claude 4.6+ / 5-family models (`claude-opus-5` covers `claude-opus-5-5`, and so on): adaptive thinking, no budgetTokens, no sampling params. */
|
|
653
697
|
function supportsAdaptiveThinking(modelId) {
|
|
654
698
|
return ADAPTIVE_THINKING_MODEL_PREFIXES.some((prefix) => modelId.startsWith(prefix));
|
|
655
699
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { b as OpenAiCompatibleProviderId, g as EmbeddingProviderId, i as AiEndpointResidency, o as AiProviderId } from "../../provider-registry-
|
|
1
|
+
import { b as OpenAiCompatibleProviderId, g as EmbeddingProviderId, i as AiEndpointResidency, o as AiProviderId } from "../../provider-registry-C_mOfdHH.js";
|
|
2
2
|
import { EmbeddingModelV4, LanguageModelV4 } from "@ai-sdk/provider";
|
|
3
3
|
//#region src/ai/providers/index.d.ts
|
|
4
4
|
export type LanguageModelFactory = (modelId: string) => LanguageModelV4;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { r as createRefusalAwareModel } from "../../refusal-BsL2HPDb.js";
|
|
2
|
-
import { a as EMBEDDING_PROVIDERS, c as OPENAI_COMPATIBLE_PROVIDER_ID, d as resolveEndpointResidency, t as AI_PROVIDERS } from "../../provider-registry-
|
|
2
|
+
import { a as EMBEDDING_PROVIDERS, c as OPENAI_COMPATIBLE_PROVIDER_ID, d as resolveEndpointResidency, t as AI_PROVIDERS } from "../../provider-registry-B91lgOmu.js";
|
|
3
3
|
import { wrapLanguageModel } from "ai";
|
|
4
4
|
//#region src/ai/providers/index.ts
|
|
5
5
|
const LLM_LOADERS = {
|
|
@@ -16,6 +16,13 @@ const LLM_LOADERS = {
|
|
|
16
16
|
apiKey,
|
|
17
17
|
baseURL
|
|
18
18
|
});
|
|
19
|
+
},
|
|
20
|
+
openai: async (apiKey, baseURL) => {
|
|
21
|
+
const { createOpenAI } = await import("@ai-sdk/openai");
|
|
22
|
+
return createOpenAI({
|
|
23
|
+
apiKey,
|
|
24
|
+
baseURL
|
|
25
|
+
});
|
|
19
26
|
}
|
|
20
27
|
};
|
|
21
28
|
const EMBEDDING_LOADERS = {
|
package/dist/ai/registry.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { C as isSubprocessorResidency, S as embeddingDimensionsFor, _ as EmbeddingProviderMeta, a as AiProcessingRegion, b as OpenAiCompatibleProviderId, c as AiProviderModelOption, d as AiVendorResidency, f as DEFAULT_AI_PROVIDER, g as EmbeddingProviderId, h as EMBEDDING_PROVIDER_IDS, i as AiEndpointResidency, l as AiResidencyError, m as EMBEDDING_PROVIDERS, n as AI_PROVIDER_IDS, o as AiProviderId, p as DEFAULT_EMBEDDING_PROVIDER, r as AiEndpointRegion, s as AiProviderMeta, t as AI_PROVIDERS, u as AiResidencyResult, v as OPENAI_COMPATIBLE_PROVIDER, w as resolveEndpointResidency, x as OpenAiCompatibleProviderMeta, y as OPENAI_COMPATIBLE_PROVIDER_ID } from "../provider-registry-
|
|
1
|
+
import { C as isSubprocessorResidency, S as embeddingDimensionsFor, _ as EmbeddingProviderMeta, a as AiProcessingRegion, b as OpenAiCompatibleProviderId, c as AiProviderModelOption, d as AiVendorResidency, f as DEFAULT_AI_PROVIDER, g as EmbeddingProviderId, h as EMBEDDING_PROVIDER_IDS, i as AiEndpointResidency, l as AiResidencyError, m as EMBEDDING_PROVIDERS, n as AI_PROVIDER_IDS, o as AiProviderId, p as DEFAULT_EMBEDDING_PROVIDER, r as AiEndpointRegion, s as AiProviderMeta, t as AI_PROVIDERS, u as AiResidencyResult, v as OPENAI_COMPATIBLE_PROVIDER, w as resolveEndpointResidency, x as OpenAiCompatibleProviderMeta, y as OPENAI_COMPATIBLE_PROVIDER_ID } from "../provider-registry-C_mOfdHH.js";
|
|
2
2
|
export { AI_PROVIDERS, AI_PROVIDER_IDS, AiEndpointRegion, AiEndpointResidency, AiProcessingRegion, AiProviderId, AiProviderMeta, AiProviderModelOption, AiResidencyError, AiResidencyResult, AiVendorResidency, DEFAULT_AI_PROVIDER, DEFAULT_EMBEDDING_PROVIDER, EMBEDDING_PROVIDERS, EMBEDDING_PROVIDER_IDS, EmbeddingProviderId, EmbeddingProviderMeta, OPENAI_COMPATIBLE_PROVIDER, OPENAI_COMPATIBLE_PROVIDER_ID, OpenAiCompatibleProviderId, OpenAiCompatibleProviderMeta, embeddingDimensionsFor, isSubprocessorResidency, resolveEndpointResidency };
|
package/dist/ai/registry.js
CHANGED
|
@@ -1,2 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export { AI_PROVIDERS, AI_PROVIDER_IDS, DEFAULT_AI_PROVIDER, DEFAULT_EMBEDDING_PROVIDER, EMBEDDING_PROVIDERS, EMBEDDING_PROVIDER_IDS, OPENAI_COMPATIBLE_PROVIDER, OPENAI_COMPATIBLE_PROVIDER_ID, embeddingDimensionsFor, isSubprocessorResidency, resolveEndpointResidency };
|
|
1
|
+
export { t as AI_PROVIDERS, n as AI_PROVIDER_IDS, r as DEFAULT_AI_PROVIDER, i as DEFAULT_EMBEDDING_PROVIDER, a as EMBEDDING_PROVIDERS, o as EMBEDDING_PROVIDER_IDS, s as OPENAI_COMPATIBLE_PROVIDER, c as OPENAI_COMPATIBLE_PROVIDER_ID, l as embeddingDimensionsFor, u as isSubprocessorResidency, d as resolveEndpointResidency } from "../provider-registry-B91lgOmu.js";
|
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
* Adding a provider: an id here with its metadata, a factory in
|
|
10
10
|
* `providers/`, and pricing rows in `cost.ts`.
|
|
11
11
|
*/
|
|
12
|
-
const AI_PROVIDER_IDS = [
|
|
12
|
+
const AI_PROVIDER_IDS = [
|
|
13
|
+
"anthropic",
|
|
14
|
+
"mistral",
|
|
15
|
+
"openai"
|
|
16
|
+
];
|
|
13
17
|
const EMBEDDING_PROVIDER_IDS = ["openai", "mistral"];
|
|
14
18
|
/**
|
|
15
19
|
* What an unset provider resolves to: the EU-established vendor, so a deployment
|
|
@@ -29,27 +33,31 @@ const AI_PROVIDERS = {
|
|
|
29
33
|
legalName: "Anthropic PBC",
|
|
30
34
|
country: "US"
|
|
31
35
|
},
|
|
32
|
-
defaultModel: "claude-sonnet-5",
|
|
33
|
-
classifierModel: "claude-haiku-
|
|
34
|
-
testModel: "claude-haiku-
|
|
36
|
+
defaultModel: "claude-sonnet-5-5",
|
|
37
|
+
classifierModel: "claude-haiku-5-5",
|
|
38
|
+
testModel: "claude-haiku-5-5",
|
|
35
39
|
keyPrefix: "sk-ant-",
|
|
36
40
|
consoleKeyUrl: "https://console.anthropic.com/settings/keys",
|
|
37
41
|
modelOptions: [
|
|
38
42
|
{
|
|
39
|
-
label: "Claude Opus 5",
|
|
40
|
-
value: "claude-opus-5"
|
|
43
|
+
label: "Claude Opus 5.5",
|
|
44
|
+
value: "claude-opus-5-5"
|
|
41
45
|
},
|
|
42
46
|
{
|
|
43
|
-
label: "Claude
|
|
44
|
-
value: "claude-
|
|
47
|
+
label: "Claude Sonnet 5.5",
|
|
48
|
+
value: "claude-sonnet-5-5"
|
|
45
49
|
},
|
|
46
50
|
{
|
|
47
|
-
label: "Claude
|
|
48
|
-
value: "claude-
|
|
51
|
+
label: "Claude Haiku 5.5",
|
|
52
|
+
value: "claude-haiku-5-5"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
label: "Claude Opus 5",
|
|
56
|
+
value: "claude-opus-5"
|
|
49
57
|
},
|
|
50
58
|
{
|
|
51
|
-
label: "Claude
|
|
52
|
-
value: "claude-
|
|
59
|
+
label: "Claude Sonnet 5",
|
|
60
|
+
value: "claude-sonnet-5"
|
|
53
61
|
}
|
|
54
62
|
]
|
|
55
63
|
},
|
|
@@ -80,6 +88,34 @@ const AI_PROVIDERS = {
|
|
|
80
88
|
value: "mistral-small-latest"
|
|
81
89
|
}
|
|
82
90
|
]
|
|
91
|
+
},
|
|
92
|
+
openai: {
|
|
93
|
+
id: "openai",
|
|
94
|
+
label: "OpenAI",
|
|
95
|
+
residency: {
|
|
96
|
+
region: "third-country",
|
|
97
|
+
legalName: "OpenAI, L.L.C.",
|
|
98
|
+
country: "US"
|
|
99
|
+
},
|
|
100
|
+
defaultModel: "gpt-6.1-sol",
|
|
101
|
+
classifierModel: "gpt-6-luna",
|
|
102
|
+
testModel: "gpt-6-luna",
|
|
103
|
+
keyPrefix: "sk-",
|
|
104
|
+
consoleKeyUrl: "https://platform.openai.com/api-keys",
|
|
105
|
+
modelOptions: [
|
|
106
|
+
{
|
|
107
|
+
label: "GPT-6 Astra",
|
|
108
|
+
value: "gpt-6-astra"
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
label: "GPT-6.1 Sol",
|
|
112
|
+
value: "gpt-6.1-sol"
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
label: "GPT-6 Luna",
|
|
116
|
+
value: "gpt-6-luna"
|
|
117
|
+
}
|
|
118
|
+
]
|
|
83
119
|
}
|
|
84
120
|
};
|
|
85
121
|
const EMBEDDING_PROVIDERS = {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Adding a provider: an id here with its metadata, a factory in
|
|
10
10
|
* `providers/`, and pricing rows in `cost.ts`.
|
|
11
11
|
*/
|
|
12
|
-
declare const AI_PROVIDER_IDS: readonly ['anthropic', 'mistral'];
|
|
12
|
+
declare const AI_PROVIDER_IDS: readonly ['anthropic', 'mistral', 'openai'];
|
|
13
13
|
type AiProviderId = (typeof AI_PROVIDER_IDS)[number];
|
|
14
14
|
declare const EMBEDDING_PROVIDER_IDS: readonly ['openai', 'mistral'];
|
|
15
15
|
type EmbeddingProviderId = (typeof EMBEDDING_PROVIDER_IDS)[number];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "octaflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.1",
|
|
4
4
|
"description": "Durable DAG workflow engine: Zod-typed steps over Postgres + pg-boss, with an optional AI add-on (token/cost/quota). Layered single package.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -47,7 +47,8 @@
|
|
|
47
47
|
"files": [
|
|
48
48
|
"dist",
|
|
49
49
|
"README.md",
|
|
50
|
-
"LICENSE"
|
|
50
|
+
"LICENSE",
|
|
51
|
+
"skills"
|
|
51
52
|
],
|
|
52
53
|
"publishConfig": {
|
|
53
54
|
"access": "public"
|
|
@@ -116,23 +117,23 @@
|
|
|
116
117
|
}
|
|
117
118
|
},
|
|
118
119
|
"devDependencies": {
|
|
119
|
-
"@ai-sdk/anthropic": "^4.0.
|
|
120
|
-
"@ai-sdk/mistral": "^4.0.
|
|
121
|
-
"@ai-sdk/openai": "^4.0.
|
|
122
|
-
"@ai-sdk/openai-compatible": "^3.0.
|
|
123
|
-
"@ai-sdk/provider": "^4.0.
|
|
124
|
-
"@testcontainers/postgresql": "^12.2.
|
|
125
|
-
"@types/node": "^26.6.
|
|
120
|
+
"@ai-sdk/anthropic": "^4.0.79",
|
|
121
|
+
"@ai-sdk/mistral": "^4.0.63",
|
|
122
|
+
"@ai-sdk/openai": "^4.0.93",
|
|
123
|
+
"@ai-sdk/openai-compatible": "^3.0.68",
|
|
124
|
+
"@ai-sdk/provider": "^4.0.27",
|
|
125
|
+
"@testcontainers/postgresql": "^12.2.1",
|
|
126
|
+
"@types/node": "^26.6.5",
|
|
126
127
|
"@types/pg": "^8.23.1",
|
|
127
|
-
"ai": "^7.0.
|
|
128
|
-
"drizzle-orm": "^0.45.
|
|
128
|
+
"ai": "^7.0.139",
|
|
129
|
+
"drizzle-orm": "^0.45.4",
|
|
129
130
|
"pg": "^8.23.1",
|
|
130
|
-
"pg-boss": "^12.
|
|
131
|
-
"testcontainers": "^12.2.
|
|
132
|
-
"tsdown": "^0.23.
|
|
131
|
+
"pg-boss": "^12.37.1",
|
|
132
|
+
"testcontainers": "^12.2.1",
|
|
133
|
+
"tsdown": "^0.23.1",
|
|
133
134
|
"typescript": "^7.0.2",
|
|
134
|
-
"vitest": "^5.0.
|
|
135
|
-
"zod": "^4.6.
|
|
135
|
+
"vitest": "^5.0.4",
|
|
136
|
+
"zod": "^4.6.7"
|
|
136
137
|
},
|
|
137
138
|
"scripts": {
|
|
138
139
|
"build": "tsdown",
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: add-a-decision
|
|
3
|
+
description: Add an octaflow decision step — a decider (a model, a rules function, your own) answers fixed questions with fixed options and a confidence; a confident answer is applied on its own as an `autopilot` ledger entry, an unsure one waits for a person in the decision card, and every applied decision can be reverted. Use to classify, route, tag, prioritise or score a record; when the model writes content a person must always see, use add-an-ai-proposal-workflow.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Add a decision
|
|
7
|
+
|
|
8
|
+
> Paths under `apps/` and `packages/` are in the platform repository,
|
|
9
|
+
> <https://github.com/octabits-io/octabits>. In your own app, read a package's README at
|
|
10
|
+
> `node_modules/@octabits-io/<name>/README.md`.
|
|
11
|
+
|
|
12
|
+
A decision asks questions whose answers you can list — *which team*, *how urgent* — and gets
|
|
13
|
+
one option per question with a confidence. The step guarantees the shape: an answer outside the
|
|
14
|
+
options fails the step without a retry and never reaches a dependent. Below a threshold a
|
|
15
|
+
question goes to a person; above it the answer stands, and the ledger says that nobody looked.
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
decide ── review (only when a question is below the threshold) ── outcome ── apply
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Shown end to end in `apps/starter/src/server/triage.ts` (Next.js, Drizzle, pg-boss) with its
|
|
22
|
+
page in `apps/starter/src/components/triage-panel.tsx`. The engine's own example is
|
|
23
|
+
`packages/octaflow/examples/17-decisions.ts`.
|
|
24
|
+
|
|
25
|
+
**Which pattern?**
|
|
26
|
+
|
|
27
|
+
| The model… | Pattern |
|
|
28
|
+
|---|---|
|
|
29
|
+
| chooses among options you can list, and a confident choice may stand | a decision (this skill) |
|
|
30
|
+
| writes content — a text, field values, new rows — a person always reviews | `add-an-ai-proposal-workflow` (beside this skill) |
|
|
31
|
+
|
|
32
|
+
An effect a person must always see first is a decision with `threshold: 1`. An effect that
|
|
33
|
+
cannot be undone (a payment, a message to a third party) never runs on autopilot: keep the
|
|
34
|
+
threshold at 1 and record the reversibility as `irreversible`.
|
|
35
|
+
|
|
36
|
+
## Step 1 — the three steps
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { z } from 'zod';
|
|
40
|
+
import { defineDecisionStep, defineDecisionReviewStep, defineDecisionOutcomeStep, functionDecider } from 'octaflow';
|
|
41
|
+
import { createModelDecider } from 'octaflow/ai';
|
|
42
|
+
|
|
43
|
+
const input = z.object({ dealId: z.string() });
|
|
44
|
+
|
|
45
|
+
const route = defineDecisionStep({
|
|
46
|
+
type: 'deal-routing.decide',
|
|
47
|
+
workflowInputSchema: input,
|
|
48
|
+
questions: { // inline, so the options stay literal types
|
|
49
|
+
team: ['sales', 'solutions', 'partners'],
|
|
50
|
+
urgency: ['low', 'normal', 'high'],
|
|
51
|
+
},
|
|
52
|
+
input: async (ctx) => readRoutingInput(ctx.context.host, ctx.workflowInput.dealId),
|
|
53
|
+
instructions: 'team: who should own this deal. urgency: how soon they should act.',
|
|
54
|
+
decider: createModelDecider<Host>(), // ctx.context.model, behind your disclosure gate
|
|
55
|
+
threshold: 0.8, // or per question: { team: 0.8 } — urgency then never asks
|
|
56
|
+
});
|
|
57
|
+
const review = defineDecisionReviewStep({ type: 'deal-routing.review', decision: { route } });
|
|
58
|
+
const outcome = defineDecisionOutcomeStep({ type: 'deal-routing.outcome', decision: { route }, review: { review } });
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- **`input` is data, not a prompt** — the smallest data that answers the questions. Leave out
|
|
62
|
+
names and contact details; the decision input leaves the process.
|
|
63
|
+
- **The decider.** `createModelDecider()` asks `ctx.context.model` (or the `model` you pass) with
|
|
64
|
+
structured output; wrap that model in the disclosure gate (`@octabits-io/disclosure/ai-sdk`).
|
|
65
|
+
Rules you can state outright are `functionDecider('rules', fn)`: confidence 1, `calibrated:
|
|
66
|
+
true`, never asks. Any object with `id`, `calibrated` and `decide()` is a `Decider`.
|
|
67
|
+
- **The threshold is a policy, not a probability.** A language model's stated confidence is
|
|
68
|
+
`calibrated: false`, and the step records that on every output. Start high; lower it only when
|
|
69
|
+
reviewers rarely change the decider's answer.
|
|
70
|
+
- **The review** suspends only when `output.review` is non-empty, and has no deadline by default.
|
|
71
|
+
`timeoutMs` with `onTimeout: 'accept'` keeps the decider's answers when it expires — that is
|
|
72
|
+
autopilot by another name, so prefer the default.
|
|
73
|
+
- **The outcome** merges both with `resolveDecision`: a person's choice wins where they made one.
|
|
74
|
+
Its `decidedBy` says, per question, `'decider'` or `'person'`.
|
|
75
|
+
|
|
76
|
+
## Step 2 — the apply step
|
|
77
|
+
|
|
78
|
+
One more step after the outcome writes the settled choices. It is the host's code, because only
|
|
79
|
+
the host knows what an answer means in its schema:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { buildWorkflow, defineStep, nonRetryableError } from 'octaflow';
|
|
83
|
+
|
|
84
|
+
const apply = defineStep<Input, { entryId: number | null }, Ctx, { route: typeof route; outcome: typeof outcome }>({
|
|
85
|
+
type: 'deal-routing.apply',
|
|
86
|
+
workflowInputSchema: input,
|
|
87
|
+
outputSchema: z.object({ entryId: z.number().nullable() }),
|
|
88
|
+
dependencies: { route, outcome },
|
|
89
|
+
handler: async (ctx) => {
|
|
90
|
+
const applied = await applyRouting(ctx.context.host, ctx.workflowId, ctx.deps.route, ctx.deps.outcome);
|
|
91
|
+
if (!applied.ok) throw nonRetryableError(applied.error.message); // a refusal is not fixed by a retry
|
|
92
|
+
return applied.value;
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
export const routingWorkflow = buildWorkflow({ type: 'deal-routing', inputSchema: input, steps: { route, review, outcome, apply } });
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Inside `applyRouting`, in **one transaction**:
|
|
99
|
+
|
|
100
|
+
1. **Find the run's ledger entry first** — ``ledger.findByWorkflow(`flow:${workflowId}`)``, the
|
|
101
|
+
same id the disclosure log keeps as its correlation id — and write nothing if it is there.
|
|
102
|
+
Steps run at least once; the entry is the idempotency marker.
|
|
103
|
+
2. **Keep what each write replaced** — `proposeFields({ target, current, proposed: outcome.choices })`
|
|
104
|
+
from `@octabits-io/proposal` — so a revert can put it back.
|
|
105
|
+
3. **Record the mode that follows the person**: a reviewer, or any `decidedBy` of `'person'`, is
|
|
106
|
+
`mode: 'reviewed'` with `principal.onBehalfOf` the reviewer; nobody is `mode: 'autopilot'`
|
|
107
|
+
with `principal.authorizationId` naming the policy (`threshold:0.8`). Put the decider, its
|
|
108
|
+
`calibrated` claim, the threshold and each answer's choice and confidence in `decision`.
|
|
109
|
+
4. Emit the change on the outbox (`add-an-event-type`).
|
|
110
|
+
|
|
111
|
+
After the transaction commits, anchor the ledger's head (`anchor-the-ledger` in
|
|
112
|
+
`@octabits-io/agent-ledger`) — never inside it.
|
|
113
|
+
|
|
114
|
+
## Step 3 — the engine, start, decide, revert
|
|
115
|
+
|
|
116
|
+
Register the workflow in the engine's registry exactly as in `add-an-ai-proposal-workflow`
|
|
117
|
+
(Step 3: `createStepHandlerRegistry`, `createAiWorkflowHooks`, `createWorkflowEngine` over the
|
|
118
|
+
`octaflow/postgres` store and the `octaflow/pg-boss` dispatcher). Then three routes:
|
|
119
|
+
|
|
120
|
+
- **start** checks the record exists in the caller's scope, then
|
|
121
|
+
``engine.start(routingWorkflow, { dealId }, { entityRef: `deal:${dealId}`, idempotencyKey })``
|
|
122
|
+
and answers `202`. The decider runs in the step worker, never in the request.
|
|
123
|
+
- **decide** validates only `answers` from the body against the options (a Zod enum per
|
|
124
|
+
question), and calls `engine.resumeStep(workflowId, 'review', { answers, reviewer })` with the
|
|
125
|
+
reviewer from the session. Never take the reviewer from the request body. Refuse a run whose
|
|
126
|
+
`review` step is not `waiting` — `resumeStep` itself ignores it silently.
|
|
127
|
+
- **revert** reads the entry with `findByWorkflow`, refuses one already reverted, inverts its
|
|
128
|
+
operations (`invertOperations`), refuses on drift (`detectDrift` — a person's later change
|
|
129
|
+
stays theirs), writes the inverse and `ledger.recordRevert({ reverts: entry.id, principal })` in
|
|
130
|
+
one transaction, then anchors.
|
|
131
|
+
|
|
132
|
+
**Deleting the record.** Runs name it by `entityRef`, not a foreign key, and their steps hold
|
|
133
|
+
what the decider saw. Before the delete, `engine.purgeWorkflows({ entityRef })` cancels and
|
|
134
|
+
deletes them; it refuses with `workflow_step_running` while a step executes — answer `409`. The
|
|
135
|
+
ledger entries stay.
|
|
136
|
+
|
|
137
|
+
## Step 4 — the sweep and the reminder
|
|
138
|
+
|
|
139
|
+
A step whose worker died stays `running`. Schedule `engine.recoverStuckWorkflows()` — every few
|
|
140
|
+
minutes, per scope with a run in flight (a pg-boss schedule, or your cron). It retries the step
|
|
141
|
+
while it has attempts (`onStuckStep: 'retry'`, the default) and fails it after, so the run
|
|
142
|
+
reaches `failed` and the page can say so. Keep handlers safe to run twice.
|
|
143
|
+
|
|
144
|
+
An unsure answer is never applied without a person, so a review may wait forever. The package
|
|
145
|
+
sends no reminder; read the waiting runs and send one from the same sweep, through your mail or
|
|
146
|
+
feed:
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
const runs = await engine.listWorkflows({ type: 'deal-routing', status: 'running' });
|
|
150
|
+
const waiting = runs.ok ? runs.value.flatMap((run) => run.steps.filter((s) => s.key === 'review' && s.status === 'waiting').map((s) => ({ run, since: s.startedAt }))) : [];
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Step 5 — the card
|
|
154
|
+
|
|
155
|
+
Hand the card the decide step's output with its questions — `{ ...output, questions:
|
|
156
|
+
route.questions, threshold: 0.8 }` fits `DecisionReviewInput` as it is — and post what its
|
|
157
|
+
confirm hands you to the decide route:
|
|
158
|
+
|
|
159
|
+
- React: `DecisionReviewCard` from `@octabits-io/agent-ui/react/plain`, or `useDecisionReview`
|
|
160
|
+
from `@octabits-io/agent-ui/react` under your own design system;
|
|
161
|
+
- Vue: `useDecisionReview` from `@octabits-io/agent-ui/vue`, or
|
|
162
|
+
`@octabits-io/agent-ui/nuxt-ui/DecisionReviewCard.vue` on Nuxt UI;
|
|
163
|
+
- shadcn: `npx shadcn add` the `decision-review-card` registry item;
|
|
164
|
+
- headless: `createDecisionReviewStore` from `@octabits-io/agent-ui`.
|
|
165
|
+
|
|
166
|
+
The page shows who settled each value (autopilot with decider and confidence, or the person),
|
|
167
|
+
how long a review has waited, and Revert. It re-reads on events; it never polls.
|
|
168
|
+
|
|
169
|
+
## Prove it works
|
|
170
|
+
|
|
171
|
+
In your app, a test on an in-memory engine — `createWorkflowEngine` over
|
|
172
|
+
`createInMemoryWorkflowStore()` and a dispatcher that runs each step inline, as
|
|
173
|
+
`packages/octaflow/src/core/decision.test.ts` builds it, with a `functionDecider` or a scripted
|
|
174
|
+
model — that asserts:
|
|
175
|
+
|
|
176
|
+
- a confident run applies itself: the columns are written and one `autopilot` entry records the
|
|
177
|
+
decider, `calibrated`, each choice and confidence;
|
|
178
|
+
- an unsure run waits on `review` and changes nothing; the decide route records the session's
|
|
179
|
+
user even when the body names another reviewer; a second decide is refused;
|
|
180
|
+
- revert restores, a second revert is refused, a revert after a manual change reports drift;
|
|
181
|
+
- another scope sees no runs and no rows.
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
pnpm --filter octaflow test:unit
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Green: `src/core/decision.test.ts` passes, including "waits for a person below the threshold, and
|
|
188
|
+
their choice wins" and "fails the step, without retrying, when the decider answers outside the
|
|
189
|
+
options".
|
|
190
|
+
|
|
191
|
+
## Checklist
|
|
192
|
+
|
|
193
|
+
- [ ] Questions inline with literal options; `input` is the smallest data that answers them
|
|
194
|
+
- [ ] The decider's model goes through the disclosure gate; the threshold is a stated policy
|
|
195
|
+
- [ ] The apply step finds the run's ledger entry before it writes, in one transaction
|
|
196
|
+
- [ ] `mode` is `autopilot` only when nobody decided; `reviewed` names the reviewer
|
|
197
|
+
- [ ] The ledger head is anchored after each apply and revert commits
|
|
198
|
+
- [ ] The decide route takes the reviewer from the session and refuses a review not waiting
|
|
199
|
+
- [ ] `recoverStuckWorkflows` runs on a schedule; records are purged with `purgeWorkflows` before delete
|
|
200
|
+
- [ ] The card shows confidence, who decided, and Revert
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: add-an-ai-proposal-workflow
|
|
3
|
+
description: Add an octaflow AI workflow whose last step emits a reviewable Proposal, plus the host's apply and revert with a drift guard and a ledger row — the "agents propose, humans decide" loop. Use when a model should suggest changes to records that already exist and a person must approve them before they are written.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Add an AI workflow that ends in a proposal
|
|
7
|
+
|
|
8
|
+
> Paths under `apps/` and `packages/` are in the platform repository,
|
|
9
|
+
> <https://github.com/octabits-io/octabits>. In your own app, read a package's README at
|
|
10
|
+
> `node_modules/@octabits-io/<name>/README.md`.
|
|
11
|
+
|
|
12
|
+
A run is a DAG of typed steps (`octaflow/ai`). Its final step turns what the model produced
|
|
13
|
+
into a `Proposal` (`@octabits-io/proposal`): operations against rows the host already has,
|
|
14
|
+
each saying what it replaces, with a drift guard. The host stores the proposal as the run's
|
|
15
|
+
output, renders it for review, and applies the reviewer's decision through one mapping from
|
|
16
|
+
operations to writes, recording a ledger row (`@octabits-io/agent-ledger`) that revert derives
|
|
17
|
+
its inverse from. Nothing in the packages writes to your tables.
|
|
18
|
+
|
|
19
|
+
Reference implementations:
|
|
20
|
+
- Producer: `apps/demo-server/src/ai/workflows.ts` (the `contact-brief` DAG, the `propose` step)
|
|
21
|
+
- Engine wiring: `apps/demo-server/src/ai/engine.ts`
|
|
22
|
+
- Apply/revert on Drizzle: `apps/demo-server/src/ai/proposals.ts`; on Prisma: `apps/starter-next/src/lib/proposals.ts`
|
|
23
|
+
- Routes: `apps/demo-server/src/routes/ai.ts`; tests: `apps/demo-server/src/ai/ai.test.ts`
|
|
24
|
+
|
|
25
|
+
## When to use
|
|
26
|
+
|
|
27
|
+
- The model's output is a change to existing records (fields, new child rows, deletions, an order) — not free text for a chat bubble.
|
|
28
|
+
- The change must be reviewable, partially acceptable, editable before apply, refusable if the record moved meanwhile, and undoable afterwards.
|
|
29
|
+
|
|
30
|
+
## Step 1: the DAG
|
|
31
|
+
|
|
32
|
+
`defineAiStep` is octaflow's `defineStep` with the handler context fixed to
|
|
33
|
+
`AiContext<THost>`: handlers get `ctx.context.model` (already instrumented — usage and cost
|
|
34
|
+
are captured for you) and `ctx.context.host` (whatever your hooks' `resolveHost` returns).
|
|
35
|
+
Independent steps run in parallel; the engine derives that from `dependencies`.
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { z } from 'zod';
|
|
39
|
+
import { generateText } from 'ai';
|
|
40
|
+
import { defineAiStep, buildAiWorkflow, type AiContext } from 'octaflow/ai';
|
|
41
|
+
|
|
42
|
+
const SCHEMA_INPUT = z.object({ orderId: z.string() });
|
|
43
|
+
type Input = z.infer<typeof SCHEMA_INPUT>;
|
|
44
|
+
|
|
45
|
+
const fetch = defineAiStep<Input, { title: string; notes: string | null }, Host>({
|
|
46
|
+
type: 'order-brief.fetch',
|
|
47
|
+
workflowInputSchema: SCHEMA_INPUT,
|
|
48
|
+
outputSchema: z.object({ title: z.string(), notes: z.string().nullable() }),
|
|
49
|
+
handler: async (ctx) => ctx.context.host.orders.get(ctx.workflowInput.orderId),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// THost is explicit on dependent steps: inference cannot recover it from `dependencies`.
|
|
53
|
+
const summarize = defineAiStep<Input, { summary: string }, Host, { fetch: typeof fetch }>({
|
|
54
|
+
type: 'order-brief.summarize',
|
|
55
|
+
workflowInputSchema: SCHEMA_INPUT,
|
|
56
|
+
outputSchema: z.object({ summary: z.string() }),
|
|
57
|
+
dependencies: { fetch },
|
|
58
|
+
retry: { maxAttempts: 3 },
|
|
59
|
+
handler: async (ctx) => {
|
|
60
|
+
const { text } = await generateText({ model: ctx.context.model, prompt: `Summarize: ${ctx.deps.fetch.title}` });
|
|
61
|
+
return { summary: text };
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Step 2: the `propose` step — the proposal IS its output schema
|
|
67
|
+
|
|
68
|
+
Re-read the row **now** so `current` is a fact about the run, not about the page load; carry
|
|
69
|
+
`guard: driftDigest(current)`; say what the model left empty in `skipped`; name the agent in
|
|
70
|
+
`provenance`.
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { buildProposal, driftDigest, entityAnchor, proposalSchema, proposeCreate, proposeFields, SKIP_REASONS } from '@octabits-io/proposal';
|
|
74
|
+
import type { Proposal, SkippedItem } from '@octabits-io/proposal';
|
|
75
|
+
|
|
76
|
+
// flow constrains step outputs to Record<string, unknown>; an interface has no index signature.
|
|
77
|
+
type ProposalOutput = { [K in keyof Proposal]: Proposal[K] };
|
|
78
|
+
export const AGENT = { kind: 'agent', id: 'ai:order-brief', label: 'Order brief' } as const;
|
|
79
|
+
|
|
80
|
+
const propose = defineAiStep<Input, ProposalOutput, Host, { fetch: typeof fetch; summarize: typeof summarize }>({
|
|
81
|
+
type: 'order-brief.propose',
|
|
82
|
+
workflowInputSchema: SCHEMA_INPUT,
|
|
83
|
+
outputSchema: proposalSchema as unknown as z.ZodType<ProposalOutput>,
|
|
84
|
+
dependencies: { fetch, summarize },
|
|
85
|
+
handler: async (ctx) => {
|
|
86
|
+
const order = await ctx.context.host.orders.get(ctx.workflowInput.orderId); // re-read at emit time
|
|
87
|
+
const target = entityAnchor('order', order.id, order.title);
|
|
88
|
+
const summary = ctx.deps.summarize.summary.trim();
|
|
89
|
+
const skipped: SkippedItem[] = [];
|
|
90
|
+
if (!summary) skipped.push({ target, path: ['notes'], reason: SKIP_REASONS.noOutput });
|
|
91
|
+
return buildProposal({
|
|
92
|
+
scope: `order:${order.id}`,
|
|
93
|
+
workflowId: ctx.workflowId,
|
|
94
|
+
workflowType: 'order-brief',
|
|
95
|
+
operations: [
|
|
96
|
+
...proposeFields({
|
|
97
|
+
target,
|
|
98
|
+
current: { notes: order.notes },
|
|
99
|
+
proposed: { notes: summary || undefined }, // dropped if it reproduces `current` verbatim
|
|
100
|
+
guard: { notes: driftDigest(order.notes) },
|
|
101
|
+
display: { notes: { labelKey: 'ai.order.fields.notes', control: 'multiline', maxLength: 2000, order: 1 } },
|
|
102
|
+
}),
|
|
103
|
+
proposeCreate({ collection: 'tasks', ref: 'followup', value: 'Call back next week', display: { labelKey: 'ai.order.fields.task', control: 'multiline', order: 2 } }),
|
|
104
|
+
],
|
|
105
|
+
skipped,
|
|
106
|
+
provenance: { model: 'your-model-id', keySource: 'platform', generatedAt: new Date().toISOString(), principal: AGENT },
|
|
107
|
+
});
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
export const orderBriefWorkflow = buildAiWorkflow<Input, Host>({ type: 'order-brief', inputSchema: SCHEMA_INPUT, steps: { fetch, summarize, propose } });
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`display` is what a generic review surface renders: `labelKey` (the host translates),
|
|
115
|
+
`control` (`text` | `multiline` | `richtext` | …), `maxLength`, `hint`, `order`.
|
|
116
|
+
|
|
117
|
+
## Step 3: the engine
|
|
118
|
+
|
|
119
|
+
`createAiWorkflowHooks` binds a model, a host and a key source per run; the refusal-aware
|
|
120
|
+
wrapper, per-model capability switches, the cost estimator and a quota policy all live in
|
|
121
|
+
`octaflow/ai` (`createRefusalAwareModel`, `createCostEstimator`, `DEFAULT_MODEL_PRICING`,
|
|
122
|
+
`AiQuotaPolicy`); `octaflow/ai/providers` turns a key into a model, `octaflow/ai/registry`
|
|
123
|
+
carries residency metadata. Register every workflow in one registry and build the engine:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import { createStepHandlerRegistry, createWorkflowEngine } from 'octaflow';
|
|
127
|
+
import { createAiWorkflowHooks, createCostEstimator, DEFAULT_MODEL_PRICING } from 'octaflow/ai';
|
|
128
|
+
|
|
129
|
+
const hooks = createAiWorkflowHooks<Host>({
|
|
130
|
+
modelResolver: { resolveModel: () => model, resolveHost: () => host, resolveKeySource: () => 'platform' },
|
|
131
|
+
usageRecorder, quotaPolicy, costEstimator: createCostEstimator({ pricing: DEFAULT_MODEL_PRICING }),
|
|
132
|
+
});
|
|
133
|
+
const registry = createStepHandlerRegistry<AiContext<Host>>();
|
|
134
|
+
orderBriefWorkflow.register(registry);
|
|
135
|
+
const engine = createWorkflowEngine<AiContext<Host>>({ store, dispatcher, registry, partitionKey, logger, hooks });
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Step 4: apply and revert — the host's half, once
|
|
139
|
+
|
|
140
|
+
The same recipe on both stacks; only the write lines differ. Copy
|
|
141
|
+
`apps/demo-server/src/ai/proposals.ts` (Drizzle) or `apps/starter-next/src/lib/proposals.ts`
|
|
142
|
+
(Prisma) and replace the `applyOperation` switch.
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import { detectDrift, invertOperations, proposalSchema, resolveDecision, reversibilityOf, validateProposal } from '@octabits-io/proposal';
|
|
146
|
+
import type { ProposalDecision, ProposedOperation, ResolvedOperation } from '@octabits-io/proposal';
|
|
147
|
+
// ledger: createDrizzleAgentLedgerStore (…/agent-ledger/drizzle) or createPostgresAgentLedgerStore ({ db: pool }, …/agent-ledger/postgres)
|
|
148
|
+
|
|
149
|
+
async function apply(runId: number, decision: ProposalDecision, appliedBy: string | null) {
|
|
150
|
+
const proposal = proposalSchema.safeParse(run.output.propose); // 1. the stored proposal
|
|
151
|
+
const standing = await ledger.findByWorkflow(runId); // 2. refuse while one stands
|
|
152
|
+
if (standing.value && standing.value.revertedAt === null) return err({ key: 'proposal_already_applied', … });
|
|
153
|
+
const issues = validateProposal(proposal.data); // 3. structural validity
|
|
154
|
+
const resolved = resolveDecision(proposal.data, decision); // 4. what the reviewer kept, with edits
|
|
155
|
+
const drifted = detectDrift(resolved, (op) => (isNotesUpdate(op) ? liveRow.notes : undefined)); // 5. 409, never overwrite
|
|
156
|
+
const created: Record<string, string> = {};
|
|
157
|
+
for (const op of resolved) { /* 6. ONE switch: op.target.type / op.collection / op.path → your write */ }
|
|
158
|
+
// Drizzle: await db.update(orders).set({ notes: op.proposed }).where(eq(orders.id, id))
|
|
159
|
+
// Prisma: await prisma.order.update({ where: { id }, data: { notes: op.proposed } })
|
|
160
|
+
await ledger.record({ principal: { ...AGENT, onBehalfOf: appliedBy ?? undefined }, mode: 'reviewed', scope: proposal.data.scope,
|
|
161
|
+
workflowId: runId, decision, operations: resolved, created, reversibility: reversibilityOf(resolved), appliedAt: new Date().toISOString() });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function revert(runId: number, revertedBy: string) {
|
|
165
|
+
const standing = await ledger.findByWorkflow(runId); // must stand: revertedAt === null
|
|
166
|
+
const plan = invertOperations(standing.value.operations as ResolvedOperation[], standing.value.created);
|
|
167
|
+
for (const op of plan.operations) { /* the same switch */ }
|
|
168
|
+
await ledger.markReverted(standing.value.id, { at: new Date().toISOString(), by: revertedBy });
|
|
169
|
+
return { missing: plan.missing, irreversible: plan.irreversible, compensable: plan.compensable };
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Whether a run is applied is never on the proposal: the host joins its ledger to the run
|
|
174
|
+
(`findByWorkflows` for lists), and "standing" means `revertedAt IS NULL`. A production host
|
|
175
|
+
wraps the writes and the ledger row in one transaction.
|
|
176
|
+
|
|
177
|
+
## Step 5: routes
|
|
178
|
+
|
|
179
|
+
Generic read/control routes (list, active, get, snapshot, cancel, resume) come from
|
|
180
|
+
`@octabits-io/server`: `createFlowWorkflowHandler({ engine, extendWorkflow?, errorOverrides? })`
|
|
181
|
+
in `./fetch/octaflow` is the six routes as one fetch handler, and `createFlowWorkflowCore` in
|
|
182
|
+
`./octaflow` is the same logic without HTTP for a host that binds its own framework
|
|
183
|
+
(`apps/demo-server/src/hono/flow.ts` binds it to Hono). Both project `appliedAt`/`revertedAt`
|
|
184
|
+
from the ledger via `extendWorkflow`. Three routes stay app-side on any stack: the trigger (`POST …/workflows`
|
|
185
|
+
with your input vocabulary), `POST …/workflows/:id/apply` (body: `proposalDecisionSchema`)
|
|
186
|
+
and `POST …/workflows/:id/revert`. On Next these are route handlers
|
|
187
|
+
(`apps/starter-next/src/app/api/runs/[id]/apply/route.ts`). Map keys to statuses:
|
|
188
|
+
`proposal_drift` and `proposal_already_*` → 409, `proposal_invalid` → 400, `*_not_found` → 404.
|
|
189
|
+
|
|
190
|
+
## Key rules
|
|
191
|
+
|
|
192
|
+
- `current` is read in the `propose` step, never earlier — the model steps took time.
|
|
193
|
+
- The proposal is the step's output schema; a step whose internals are a tool loop emits the same shape.
|
|
194
|
+
- One `applyOperation` switch is the only place that knows which table an anchor names. Revert goes through it too.
|
|
195
|
+
- Refuse a second apply while one stands; refuse on drift; refuse an empty decision. All three are 4xx, none is silent.
|
|
196
|
+
- The ledger stores the resolved operations as written and the ids assigned to creates — revert never re-reads the entity to guess "before".
|
|
197
|
+
|
|
198
|
+
## Proof
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
cd apps/demo-server && pnpm test # `proposal review loop (apply / revert)`: partial decision with an edit,
|
|
202
|
+
# drift → 409, revert restores and clears appliedAt, second apply refused, empty decision rejected
|
|
203
|
+
pnpm e2e # the loop in a browser against every host
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Write the same five tests for your workflow (`apps/demo-server/src/ai/ai.test.ts` is the
|
|
207
|
+
template, fully in-memory on octaflow's memory store). Green: all five pass and the e2e loop
|
|
208
|
+
shows the edited value landing and the rejected operation not being created.
|
|
209
|
+
|
|
210
|
+
## Checklist
|
|
211
|
+
|
|
212
|
+
- [ ] Steps defined with `defineAiStep`, `THost` explicit on dependent steps, `retry` on model steps
|
|
213
|
+
- [ ] `propose` re-reads the row, sets `guard: driftDigest(current)`, fills `skipped` and `provenance`, output schema is `proposalSchema`
|
|
214
|
+
- [ ] Workflow registered in the engine's registry; hooks bind model, host, key source, usage recorder, cost estimator
|
|
215
|
+
- [ ] Apply: parse → standing check → validate → resolve → drift → one write switch → ledger row
|
|
216
|
+
- [ ] Revert: `invertOperations` over the ledger row → the same switch → `markReverted`
|
|
217
|
+
- [ ] Routes: generic ones from `server/fetch/octaflow` (or `server/octaflow` bound to your framework), trigger/apply/revert app-side, keys mapped to 4xx
|
|
218
|
+
- [ ] Five loop tests green; an e2e host drives it
|
|
219
|
+
|
|
220
|
+
## Key files
|
|
221
|
+
|
|
222
|
+
| File | Purpose |
|
|
223
|
+
|---|---|
|
|
224
|
+
| `packages/octaflow/src/ai/define-ai-step.ts` | `defineAiStep`, `buildAiWorkflow` |
|
|
225
|
+
| `packages/octaflow/src/ai/hooks.ts`, `refusal.ts`, `capabilities.ts`, `cost.ts`, `quota.ts` | model resolution, refusal wrapper, capability switches, pricing, quota |
|
|
226
|
+
| `packages/proposal/src/build.ts`, `apply.ts`, `schema.ts` | `buildProposal`/`proposeFields`/`proposeCreate`/`driftDigest`; `validateProposal`/`resolveDecision`/`detectDrift`/`invertOperations`/`reversibilityOf`; `proposalSchema`, `proposalDecisionSchema` |
|
|
227
|
+
| `packages/agent-ledger/src/index.ts` | `AgentLedgerStore` — `record`, `findByWorkflow(s)`, `markReverted` |
|
|
228
|
+
| `packages/server/src/octaflow.ts`, `src/fetch/octaflow.ts` | `createFlowWorkflowCore`; `createFlowWorkflowHandler` |
|