@manta-eu/horns-server 0.1.3 → 0.2.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/dist/action-plan-routes.d.ts +2 -0
- package/dist/action-plan-routes.js +3 -1
- package/dist/agents-proxy.d.ts +9 -0
- package/dist/agents-proxy.js +4 -1
- package/dist/datastore-access.d.ts +2 -0
- package/dist/datastore-access.js +3 -3
- package/dist/dev-session.d.ts +3 -0
- package/dist/dev-session.js +18 -1
- package/dist/env.d.ts +4 -1
- package/dist/horn-routes.d.ts +2 -0
- package/dist/horn-routes.js +10 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +7 -3
- package/dist/sparql-update.d.ts +2 -0
- package/dist/sparql-update.js +2 -2
- package/dist/studio-agent-class.d.ts +41 -0
- package/dist/studio-agent-class.js +171 -0
- package/dist/studio-agent-routes.d.ts +18 -0
- package/dist/studio-agent-routes.js +37 -0
- package/dist/studio-agent.d.ts +20 -0
- package/dist/studio-agent.js +44 -0
- package/dist/studio-deps.d.ts +15 -0
- package/dist/studio-deps.js +14 -0
- package/dist/studio-model.d.ts +30 -0
- package/dist/studio-model.js +104 -0
- package/dist/studio-opening.d.ts +42 -0
- package/dist/studio-opening.js +72 -0
- package/dist/studio-routes.d.ts +12 -3
- package/dist/studio-routes.js +8 -90
- package/dist/studio-worker.d.ts +12 -0
- package/dist/studio-worker.js +34 -0
- package/dist/worker.d.ts +8 -3
- package/dist/worker.js +6 -2
- package/package.json +29 -7
- package/dist/llm.d.ts +0 -45
- package/dist/llm.js +0 -124
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { isPresent } from "@manta-eu/lib/present";
|
|
3
|
+
import { studioToolDefinitions, studioToolSchemas, studioToolsByMode, } from "@manta-eu/horns/studio";
|
|
4
|
+
/** What a tool call does, apart from the SDK wrapper around it. Generic, so input and handler stay bound. */
|
|
5
|
+
export async function runStudioTool(handler, input, args) {
|
|
6
|
+
const outcome = await handler(input, {
|
|
7
|
+
draft: args.draft(),
|
|
8
|
+
mode: args.mode,
|
|
9
|
+
...(args.signal ? { signal: args.signal } : {}),
|
|
10
|
+
});
|
|
11
|
+
if (isPresent(outcome.draft))
|
|
12
|
+
args.onDraft(outcome.draft);
|
|
13
|
+
return outcome.content;
|
|
14
|
+
}
|
|
15
|
+
function studioTool(name, description, handler, args) {
|
|
16
|
+
return tool({
|
|
17
|
+
description,
|
|
18
|
+
inputSchema: studioToolSchemas[name],
|
|
19
|
+
execute: (input) => runStudioTool(handler, input, args),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Tools the page runs, not the worker. Declared with no `execute`, so the harness sends the call to the
|
|
24
|
+
* browser and waits for what it answers.
|
|
25
|
+
*/
|
|
26
|
+
export function pageToolSet(names) {
|
|
27
|
+
const described = new Map(studioToolDefinitions("build").map((one) => [one.function.name, one.function.description]));
|
|
28
|
+
return Object.fromEntries(names.map((name) => [name, pageTool(name, described.get(name) ?? name)]));
|
|
29
|
+
}
|
|
30
|
+
function pageTool(name, description) {
|
|
31
|
+
return tool({ description, inputSchema: studioToolSchemas[name] });
|
|
32
|
+
}
|
|
33
|
+
/** The tools a mode offers, as the SDK wants them: zod in, the handler's words out. */
|
|
34
|
+
export function studioToolSet(args) {
|
|
35
|
+
const described = new Map(studioToolDefinitions(args.mode).map((definition) => [definition.function.name, definition.function.description]));
|
|
36
|
+
const entries = studioToolsByMode[args.mode].flatMap((name) => {
|
|
37
|
+
const handler = args.handlers[name];
|
|
38
|
+
// A tool with no handler costs the model a step and teaches it to distrust the list.
|
|
39
|
+
if (handler === undefined)
|
|
40
|
+
return [];
|
|
41
|
+
return [[name, studioTool(name, described.get(name) ?? name, handler, args)]];
|
|
42
|
+
});
|
|
43
|
+
return Object.fromEntries(entries);
|
|
44
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Realm } from "@manta-eu/horns";
|
|
2
|
+
import type { Shape } from "@manta-eu/horns/shapes";
|
|
3
|
+
import type { ComponentApiLookup, TypeProblem } from "@manta-eu/horns/studio";
|
|
4
|
+
export type StudioDeps = {
|
|
5
|
+
/** The realm's SHACL shapes, which is what a turn reads classes from. */
|
|
6
|
+
shapes: (realm: Realm) => readonly Shape[];
|
|
7
|
+
componentApi?: ComponentApiLookup;
|
|
8
|
+
typecheck?: (source: string) => readonly TypeProblem[];
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* A Durable Object is built by the runtime, so it takes no constructor arguments: a host registers what
|
|
12
|
+
* the Studio reads at module scope instead, beside where it exports the agent.
|
|
13
|
+
*/
|
|
14
|
+
export declare function provideStudioDeps(deps: StudioDeps): void;
|
|
15
|
+
export declare function studioDeps(): StudioDeps;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
let registered;
|
|
2
|
+
/**
|
|
3
|
+
* A Durable Object is built by the runtime, so it takes no constructor arguments: a host registers what
|
|
4
|
+
* the Studio reads at module scope instead, beside where it exports the agent.
|
|
5
|
+
*/
|
|
6
|
+
export function provideStudioDeps(deps) {
|
|
7
|
+
registered = deps;
|
|
8
|
+
}
|
|
9
|
+
export function studioDeps() {
|
|
10
|
+
if (registered === undefined) {
|
|
11
|
+
throw new Error("provideStudioDeps was never called; the Studio has no shapes to read.");
|
|
12
|
+
}
|
|
13
|
+
return registered;
|
|
14
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { LanguageModel } from "ai";
|
|
2
|
+
export declare const aiGatewayHost = "gateway.ai.cloudflare.com";
|
|
3
|
+
export type StudioModelEnv = {
|
|
4
|
+
HORNS_CRITIC_BASE_URL?: string;
|
|
5
|
+
HORNS_CRITIC_MODEL?: string;
|
|
6
|
+
HORNS_LLM_BASE_URL?: string;
|
|
7
|
+
HORNS_LLM_MODEL?: string;
|
|
8
|
+
CLOUDFLARE_AI_GATEWAY_TOKEN?: string;
|
|
9
|
+
CLOUDFLARE_ENV?: string;
|
|
10
|
+
GOOGLE_VERTEX_PROJECT?: string;
|
|
11
|
+
GOOGLE_VERTEX_LOCATION?: string;
|
|
12
|
+
GOOGLE_VERTEX_MODEL?: string;
|
|
13
|
+
VERTEX_SERVICE_ACCOUNT?: string;
|
|
14
|
+
};
|
|
15
|
+
/** `monitored` says whether the call lands in the AI Gateway's log. */
|
|
16
|
+
export type StudioModelChoice = {
|
|
17
|
+
ok: true;
|
|
18
|
+
model: LanguageModel;
|
|
19
|
+
monitored: boolean;
|
|
20
|
+
} | {
|
|
21
|
+
ok: false;
|
|
22
|
+
reason: string;
|
|
23
|
+
};
|
|
24
|
+
/** Vertex where a realm's agreement bars the gateway, the gateway otherwise. */
|
|
25
|
+
export declare function studioModel(env: StudioModelEnv, fetcher?: typeof fetch): StudioModelChoice;
|
|
26
|
+
/**
|
|
27
|
+
* The critic: its own route where one is named, the Studio's own model otherwise. It judges a picture, so
|
|
28
|
+
* name a route where the Studio's model has no image input.
|
|
29
|
+
*/
|
|
30
|
+
export declare function criticModel(env: StudioModelEnv, fetcher?: typeof fetch): StudioModelChoice;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createVertexAnthropic } from "@ai-sdk/google-vertex/anthropic/edge";
|
|
2
|
+
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
3
|
+
import { isPresent } from "@manta-eu/lib/present";
|
|
4
|
+
import { createVertexAccessTokens, parseVertexServiceAccount } from "@manta-eu/horns/studio";
|
|
5
|
+
export const aiGatewayHost = "gateway.ai.cloudflare.com";
|
|
6
|
+
const loopbackHosts = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
7
|
+
function vertexModel(env, fetcher) {
|
|
8
|
+
const project = env.GOOGLE_VERTEX_PROJECT;
|
|
9
|
+
const location = env.GOOGLE_VERTEX_LOCATION;
|
|
10
|
+
const model = env.GOOGLE_VERTEX_MODEL;
|
|
11
|
+
const serviceAccount = env.VERTEX_SERVICE_ACCOUNT;
|
|
12
|
+
const named = [project, location, model, serviceAccount].filter(isPresent).length;
|
|
13
|
+
if (named === 0)
|
|
14
|
+
return undefined;
|
|
15
|
+
if (!isPresent(project) || !isPresent(location) || !isPresent(model) || !isPresent(serviceAccount)) {
|
|
16
|
+
return {
|
|
17
|
+
ok: false,
|
|
18
|
+
reason: "Vertex needs GOOGLE_VERTEX_PROJECT, GOOGLE_VERTEX_LOCATION, GOOGLE_VERTEX_MODEL and VERTEX_SERVICE_ACCOUNT.",
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const vertex = createVertexAnthropic({
|
|
23
|
+
project,
|
|
24
|
+
location,
|
|
25
|
+
generateAuthToken: createVertexAccessTokens({
|
|
26
|
+
account: parseVertexServiceAccount(serviceAccount),
|
|
27
|
+
...(fetcher ? { fetch: fetcher } : {}),
|
|
28
|
+
}),
|
|
29
|
+
...(fetcher ? { fetch: fetcher } : {}),
|
|
30
|
+
});
|
|
31
|
+
// Not in the gateway's log because it never reaches it, which is the point where a realm's agreement
|
|
32
|
+
// bars that log. Google Cloud's audit log accounts for these.
|
|
33
|
+
return { ok: true, monitored: false, model: vertex(model) };
|
|
34
|
+
}
|
|
35
|
+
catch (caught) {
|
|
36
|
+
// Only the service account's shape can fail here, and it must not be echoed.
|
|
37
|
+
return { ok: false, reason: caught instanceof Error ? caught.message : "The Vertex service account is unusable." };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Vertex where a realm's agreement bars the gateway, the gateway otherwise. */
|
|
41
|
+
export function studioModel(env, fetcher) {
|
|
42
|
+
const vertex = vertexModel(env, fetcher);
|
|
43
|
+
if (vertex !== undefined)
|
|
44
|
+
return vertex;
|
|
45
|
+
const baseURL = env.HORNS_LLM_BASE_URL;
|
|
46
|
+
const model = env.HORNS_LLM_MODEL;
|
|
47
|
+
if (!isPresent(baseURL) || !isPresent(model)) {
|
|
48
|
+
return { ok: false, reason: "HORNS_LLM_BASE_URL and HORNS_LLM_MODEL must both be set." };
|
|
49
|
+
}
|
|
50
|
+
if (!URL.canParse(baseURL))
|
|
51
|
+
return { ok: false, reason: "HORNS_LLM_BASE_URL is not a URL." };
|
|
52
|
+
const { hostname, protocol } = new URL(baseURL);
|
|
53
|
+
// A stub on this machine, which tests point at. Unmonitored by definition: there is no gateway in front.
|
|
54
|
+
if (loopbackHosts.has(hostname)) {
|
|
55
|
+
const local = createOpenAICompatible({ name: "horns-local", baseURL, ...(fetcher ? { fetch: fetcher } : {}) });
|
|
56
|
+
return { ok: true, monitored: false, model: local(model) };
|
|
57
|
+
}
|
|
58
|
+
if (protocol !== "https:" || hostname !== aiGatewayHost) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
reason: `HORNS_LLM_BASE_URL must be the AI Gateway (${aiGatewayHost}) so model calls are logged, not ${hostname}.`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const gatewayToken = env.CLOUDFLARE_AI_GATEWAY_TOKEN;
|
|
65
|
+
if (!isPresent(gatewayToken)) {
|
|
66
|
+
return { ok: false, reason: "CLOUDFLARE_AI_GATEWAY_TOKEN must be set to call the AI Gateway." };
|
|
67
|
+
}
|
|
68
|
+
const gateway = createOpenAICompatible({
|
|
69
|
+
name: "horns-gateway",
|
|
70
|
+
baseURL,
|
|
71
|
+
// The gateway holds the provider key; one of ours would override it.
|
|
72
|
+
headers: {
|
|
73
|
+
"cf-aig-authorization": `Bearer ${gatewayToken}`,
|
|
74
|
+
// No user identifiers: the log is shared.
|
|
75
|
+
"cf-aig-metadata": JSON.stringify({ app: "manta-horns", environment: env.CLOUDFLARE_ENV ?? "local" }),
|
|
76
|
+
},
|
|
77
|
+
...(fetcher ? { fetch: fetcher } : {}),
|
|
78
|
+
});
|
|
79
|
+
return { ok: true, monitored: true, model: gateway(model) };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The critic: its own route where one is named, the Studio's own model otherwise. It judges a picture, so
|
|
83
|
+
* name a route where the Studio's model has no image input.
|
|
84
|
+
*/
|
|
85
|
+
export function criticModel(env, fetcher) {
|
|
86
|
+
const baseURL = env.HORNS_CRITIC_BASE_URL;
|
|
87
|
+
const model = env.HORNS_CRITIC_MODEL;
|
|
88
|
+
if (!isPresent(baseURL) && !isPresent(model))
|
|
89
|
+
return studioModel(env, fetcher);
|
|
90
|
+
// One of the pair alone is a misconfiguration, and falling back would hide it.
|
|
91
|
+
if (!isPresent(baseURL) || !isPresent(model)) {
|
|
92
|
+
return { ok: false, reason: "HORNS_CRITIC_BASE_URL and HORNS_CRITIC_MODEL must both be set, or neither." };
|
|
93
|
+
}
|
|
94
|
+
// Every Vertex setting cleared: a named critic route is the route.
|
|
95
|
+
return studioModel({
|
|
96
|
+
...env,
|
|
97
|
+
HORNS_LLM_BASE_URL: baseURL,
|
|
98
|
+
HORNS_LLM_MODEL: model,
|
|
99
|
+
GOOGLE_VERTEX_PROJECT: undefined,
|
|
100
|
+
GOOGLE_VERTEX_LOCATION: undefined,
|
|
101
|
+
GOOGLE_VERTEX_MODEL: undefined,
|
|
102
|
+
VERTEX_SERVICE_ACCOUNT: undefined,
|
|
103
|
+
}, fetcher);
|
|
104
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type Realm } from "@manta-eu/horns";
|
|
2
|
+
import { type DevSessionEnv } from "./dev-session.ts";
|
|
3
|
+
import { type MantaSessionState } from "./session.ts";
|
|
4
|
+
/** Handed over when the connection opens: the worker has already authorized it. */
|
|
5
|
+
export type StudioAgentProps = {
|
|
6
|
+
session: MantaSessionState;
|
|
7
|
+
datastoreId: string;
|
|
8
|
+
/** Narrowed below: props cross a boundary, so this arrives as whatever the host sent. */
|
|
9
|
+
realm: string;
|
|
10
|
+
};
|
|
11
|
+
export type StudioOpening = {
|
|
12
|
+
session: MantaSessionState;
|
|
13
|
+
datastoreId: string;
|
|
14
|
+
realm: Realm;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* The header a turn's authorization rides on.
|
|
18
|
+
*
|
|
19
|
+
* Not `onStart` props: those arrive when the object starts, so a reconnect to a live one keeps whatever
|
|
20
|
+
* the first connection brought. A header is read per request and cannot go stale.
|
|
21
|
+
*/
|
|
22
|
+
export declare const studioAuthHeader = "x-horns-studio-auth";
|
|
23
|
+
export declare function serializeStudioOpening(opening: StudioOpening): string;
|
|
24
|
+
export declare function readStudioOpening(request: Request): StudioOpening | undefined;
|
|
25
|
+
/** What the worker handed over, checked. A turn without it is a turn nobody authorized. */
|
|
26
|
+
export declare function studioOpening(props: StudioAgentProps | undefined): StudioOpening;
|
|
27
|
+
export type StudioConversationEnv = DevSessionEnv & {
|
|
28
|
+
VITE_KEYCLOAK_REALM?: string;
|
|
29
|
+
MANTA_AGENTS_URL?: string;
|
|
30
|
+
};
|
|
31
|
+
/** One object per conversation per datastore: a name that crossed datastores would carry a draft with it. */
|
|
32
|
+
export declare function studioConversationName(datastoreId: string, conversation: string): string;
|
|
33
|
+
/**
|
|
34
|
+
* Who may open a Studio conversation, decided before the object is reached rather than inside it.
|
|
35
|
+
*/
|
|
36
|
+
export declare function authorizeStudioConversation(request: Request, env: StudioConversationEnv, datastoreId: string): Promise<{
|
|
37
|
+
ok: true;
|
|
38
|
+
props: StudioOpening;
|
|
39
|
+
} | {
|
|
40
|
+
ok: false;
|
|
41
|
+
response: Response;
|
|
42
|
+
}>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { isRealm } from "@manta-eu/horns";
|
|
3
|
+
import { isPresent } from "@manta-eu/lib/present";
|
|
4
|
+
import { sameOriginDeniedResponse, signedOutMessage } from "./agents-proxy.js";
|
|
5
|
+
import { datastoreAccessFor, datastoreNotGrantedMessage, grantsDatastore } from "./datastore-access.js";
|
|
6
|
+
import { requestSession } from "./dev-session.js";
|
|
7
|
+
import { isSignedIn } from "./session.js";
|
|
8
|
+
/**
|
|
9
|
+
* The header a turn's authorization rides on.
|
|
10
|
+
*
|
|
11
|
+
* Not `onStart` props: those arrive when the object starts, so a reconnect to a live one keeps whatever
|
|
12
|
+
* the first connection brought. A header is read per request and cannot go stale.
|
|
13
|
+
*/
|
|
14
|
+
export const studioAuthHeader = "x-horns-studio-auth";
|
|
15
|
+
export function serializeStudioOpening(opening) {
|
|
16
|
+
return JSON.stringify(opening);
|
|
17
|
+
}
|
|
18
|
+
export function readStudioOpening(request) {
|
|
19
|
+
const sent = request.headers.get(studioAuthHeader);
|
|
20
|
+
if (!isPresent(sent))
|
|
21
|
+
return undefined;
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(sent);
|
|
24
|
+
return studioOpening(openingSchema.parse(parsed));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const openingSchema = z.object({
|
|
31
|
+
session: z.object({ upstreamCookies: z.record(z.string(), z.string()) }),
|
|
32
|
+
datastoreId: z.string().min(1),
|
|
33
|
+
realm: z.string().min(1),
|
|
34
|
+
});
|
|
35
|
+
/** What the worker handed over, checked. A turn without it is a turn nobody authorized. */
|
|
36
|
+
export function studioOpening(props) {
|
|
37
|
+
if (props === undefined)
|
|
38
|
+
throw new Error("This Studio was reached without a session.");
|
|
39
|
+
if (!isRealm(props.realm))
|
|
40
|
+
throw new Error("This deployment has no realm configured.");
|
|
41
|
+
return { session: props.session, datastoreId: props.datastoreId, realm: props.realm };
|
|
42
|
+
}
|
|
43
|
+
/** One object per conversation per datastore: a name that crossed datastores would carry a draft with it. */
|
|
44
|
+
export function studioConversationName(datastoreId, conversation) {
|
|
45
|
+
return `${datastoreId}/${conversation}`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Who may open a Studio conversation, decided before the object is reached rather than inside it.
|
|
49
|
+
*/
|
|
50
|
+
export async function authorizeStudioConversation(request, env, datastoreId) {
|
|
51
|
+
const denied = sameOriginDeniedResponse(request);
|
|
52
|
+
if (denied)
|
|
53
|
+
return { ok: false, response: denied };
|
|
54
|
+
const session = requestSession(request, env);
|
|
55
|
+
if (!isPresent(session) || !isSignedIn(session)) {
|
|
56
|
+
return { ok: false, response: Response.json({ error: signedOutMessage }, { status: 401 }) };
|
|
57
|
+
}
|
|
58
|
+
const realm = env.VITE_KEYCLOAK_REALM;
|
|
59
|
+
if (!isPresent(realm) || !isRealm(realm)) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
response: Response.json({ error: "This deployment has no realm configured." }, { status: 500 }),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const resolved = await datastoreAccessFor(env, session);
|
|
66
|
+
if (!resolved.ok)
|
|
67
|
+
return { ok: false, response: Response.json({ error: signedOutMessage }, { status: 401 }) };
|
|
68
|
+
if (!grantsDatastore(resolved.access, datastoreId)) {
|
|
69
|
+
return { ok: false, response: Response.json({ error: datastoreNotGrantedMessage }, { status: 403 }) };
|
|
70
|
+
}
|
|
71
|
+
return { ok: true, props: { session, datastoreId, realm } };
|
|
72
|
+
}
|
package/dist/studio-routes.d.ts
CHANGED
|
@@ -2,11 +2,13 @@ import { Hono } from "hono";
|
|
|
2
2
|
import { type Realm } from "@manta-eu/horns";
|
|
3
3
|
import { type ComponentApiLookup, type TypeProblem } from "@manta-eu/horns/studio";
|
|
4
4
|
import { type Shape } from "@manta-eu/horns/shapes";
|
|
5
|
-
import { type
|
|
5
|
+
import { type UpstreamFetcher } from "./agents-proxy.ts";
|
|
6
|
+
import { type StudioModelChoice, type StudioModelEnv } from "./studio-model.ts";
|
|
6
7
|
import { type DevSessionEnv } from "./dev-session.ts";
|
|
7
8
|
export type StudioRoutesEnv = DevSessionEnv & StudioModelEnv & {
|
|
8
9
|
MANTA_AGENTS_URL?: string;
|
|
9
10
|
VITE_KEYCLOAK_REALM?: string;
|
|
11
|
+
UPSTREAM?: UpstreamFetcher;
|
|
10
12
|
};
|
|
11
13
|
export type StudioRoutesDeps<Env extends StudioRoutesEnv> = {
|
|
12
14
|
/** The realm's SHACL shapes, which is what a turn reads classes from. Absent shapes answer no classes. */
|
|
@@ -19,13 +21,20 @@ export type StudioRoutesDeps<Env extends StudioRoutesEnv> = {
|
|
|
19
21
|
model?: (env: Env, context: {
|
|
20
22
|
feature: string;
|
|
21
23
|
datastoreId: string;
|
|
22
|
-
}) =>
|
|
24
|
+
}) => StudioModelChoice;
|
|
23
25
|
/** The critic's model, which can look at a picture. Absent, the critic reads the card as text instead. */
|
|
24
26
|
criticModel?: (env: Env, context: {
|
|
25
27
|
feature: string;
|
|
26
28
|
datastoreId: string;
|
|
27
|
-
}) =>
|
|
29
|
+
}) => StudioModelChoice;
|
|
28
30
|
};
|
|
31
|
+
/**
|
|
32
|
+
* How many model rounds one request may take.
|
|
33
|
+
*
|
|
34
|
+
* Twenty-four ran out on real work — fifteen queries and three repairs and the turn is over, which costs
|
|
35
|
+
* the person a whole round trip to say "continue". dyad allows a hundred tool-call steps and keeps only the
|
|
36
|
+
* last few chat turns in context; this is the same trade, sized for a worker request rather than a desktop.
|
|
37
|
+
*/
|
|
29
38
|
/**
|
|
30
39
|
* How much of the conversation goes back to the model.
|
|
31
40
|
*
|
package/dist/studio-routes.js
CHANGED
|
@@ -2,15 +2,14 @@ import { Hono } from "hono";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { isPresent } from "@manta-eu/lib/present";
|
|
4
4
|
import { hornSchema, isRealm } from "@manta-eu/horns";
|
|
5
|
-
import { acceptedSpecs,
|
|
5
|
+
import { acceptedSpecs, createDryRun, evaluateProposal, formatFailures, hornPatchOpSchema, evaluateShapeViewOnZoo, runShapeCoverage, runShapeViewTurn, adviseOnHorn, sampleShapeFillRates, } from "@manta-eu/horns/studio";
|
|
6
6
|
import { shapeLinks, shapeViewSpecSchema, termNotes } from "@manta-eu/horns/shapes";
|
|
7
7
|
import { mantaReactCatalog } from "@manta-eu/horns-kit/catalog";
|
|
8
8
|
import { shapeViewSpecs } from "@manta-eu/horns-kit/shape-views";
|
|
9
9
|
import { sameOriginDeniedResponse, signedOutMessage } from "./agents-proxy.js";
|
|
10
10
|
import { datastoreAccessFor, datastoreAccessUnavailableMessage, datastoreNotGrantedMessage, forgetDatastoreAccess, grantsDatastore, } from "./datastore-access.js";
|
|
11
11
|
import { parseBody } from "./authorize.js";
|
|
12
|
-
import {
|
|
13
|
-
import { criticModelFromEnv, studioModelFromEnv } from "./llm.js";
|
|
12
|
+
import { criticModel, studioModel } from "./studio-model.js";
|
|
14
13
|
import { requestSession } from "./dev-session.js";
|
|
15
14
|
import { ndjsonResponse } from "./ndjson.js";
|
|
16
15
|
import { isSignedIn } from "./session.js";
|
|
@@ -23,7 +22,6 @@ const noStore = { "cache-control": "no-store" };
|
|
|
23
22
|
* the person a whole round trip to say "continue". dyad allows a hundred tool-call steps and keeps only the
|
|
24
23
|
* last few chat turns in context; this is the same trade, sized for a worker request rather than a desktop.
|
|
25
24
|
*/
|
|
26
|
-
const turnStepLimit = 48;
|
|
27
25
|
/**
|
|
28
26
|
* How much of the conversation goes back to the model.
|
|
29
27
|
*
|
|
@@ -52,10 +50,6 @@ const viewTurnRequestSchema = z.object({
|
|
|
52
50
|
spec: shapeViewSpecSchema,
|
|
53
51
|
messages: messagesSchema,
|
|
54
52
|
});
|
|
55
|
-
const turnRequestSchema = z.object({
|
|
56
|
-
horn: hornSchema,
|
|
57
|
-
messages: messagesSchema,
|
|
58
|
-
});
|
|
59
53
|
// One shape is a minute or two of watching. Three is the most anyone waits at a page before it stops being
|
|
60
54
|
// live and becomes a job, and a job is what the script is for.
|
|
61
55
|
const coverageRequestSchema = z.object({ limit: z.number().int().min(1).max(3).optional() });
|
|
@@ -107,85 +101,6 @@ export function studioRoutes(deps) {
|
|
|
107
101
|
}
|
|
108
102
|
return { ok: true, session, realm: configured, datastoreId };
|
|
109
103
|
};
|
|
110
|
-
routes.post("/api/d/:datastoreId/studio", async (c) => {
|
|
111
|
-
const allowed = await context(c);
|
|
112
|
-
if (!allowed.ok)
|
|
113
|
-
return allowed.response;
|
|
114
|
-
const { session, realm, datastoreId } = allowed;
|
|
115
|
-
const parsed = await parseBody(c.req.raw, turnRequestSchema);
|
|
116
|
-
if (!parsed.ok)
|
|
117
|
-
return parsed.response;
|
|
118
|
-
const { horn, messages } = parsed.data;
|
|
119
|
-
if (horn.datastoreId !== datastoreId) {
|
|
120
|
-
return c.json({ error: `This Horn belongs to the "${horn.datastoreId}" datastore.` }, 400);
|
|
121
|
-
}
|
|
122
|
-
const model = (deps.model ?? studioModelFromEnv)(c.env, { feature: "horns-studio", datastoreId });
|
|
123
|
-
if (!model.ok)
|
|
124
|
-
return c.json({ error: model.reason }, 503);
|
|
125
|
-
const select = agentsSparqlSelect({
|
|
126
|
-
agentsUrl: c.env.MANTA_AGENTS_URL,
|
|
127
|
-
session,
|
|
128
|
-
datastoreId,
|
|
129
|
-
});
|
|
130
|
-
const typed = realm;
|
|
131
|
-
const shapes = deps.shapes(typed);
|
|
132
|
-
const [latest, ...earlier] = [...messages].reverse();
|
|
133
|
-
// Newest first here, so this keeps the most recent exchanges and drops the oldest.
|
|
134
|
-
const carried = earlier.slice(0, maxHistoryTurns * 2);
|
|
135
|
-
const asked = latest?.content ?? "";
|
|
136
|
-
// Streamed like a shape view's turn: each event as the loop records it, then the result. A turn that
|
|
137
|
-
// looks at the data before it changes anything is a minute of work, and that minute should be visible.
|
|
138
|
-
return ndjsonResponse(async (emit) => {
|
|
139
|
-
emit({ type: "progress", event: { type: "reading" } });
|
|
140
|
-
// A class's shape is what a platform sends; what Manta's rules work out about it is on nothing's property
|
|
141
|
-
// list, so the turn is given it up front rather than left to discover that half of the graph exists.
|
|
142
|
-
const useful = buildUsefulContext({
|
|
143
|
-
realm: typed,
|
|
144
|
-
prompt: asked,
|
|
145
|
-
horn,
|
|
146
|
-
...(await datastoreFacts({ realm: typed, select, shapes, prompt: asked, signal: c.req.raw.signal })),
|
|
147
|
-
library: [],
|
|
148
|
-
catalog: mantaReactCatalog,
|
|
149
|
-
skills: selectSkills(asked, builtInSkills),
|
|
150
|
-
});
|
|
151
|
-
const turn = await runStudioTurn({
|
|
152
|
-
model: model.model,
|
|
153
|
-
mode: "build",
|
|
154
|
-
draft: horn,
|
|
155
|
-
messages: assembleStudioMessages({
|
|
156
|
-
system: studioSystemPrompt({ mode: "build", realm: typed }),
|
|
157
|
-
history: [...carried].reverse(),
|
|
158
|
-
usefulContext: useful.text,
|
|
159
|
-
userMessage: asked,
|
|
160
|
-
}),
|
|
161
|
-
handlers: {
|
|
162
|
-
...studioReadHandlers({
|
|
163
|
-
realm: typed,
|
|
164
|
-
select,
|
|
165
|
-
shapes,
|
|
166
|
-
views: shapeViewSpecs,
|
|
167
|
-
model: model.model,
|
|
168
|
-
...(deps.componentApi ? { componentApi: deps.componentApi } : {}),
|
|
169
|
-
// The investigation is a loop of its own; without this it is one line that takes a minute.
|
|
170
|
-
onEvent: (event) => emit({ type: "progress", event }),
|
|
171
|
-
}),
|
|
172
|
-
...studioWriteHandlers({
|
|
173
|
-
realm: typed,
|
|
174
|
-
select,
|
|
175
|
-
catalog: mantaReactCatalog,
|
|
176
|
-
lookup: () => undefined,
|
|
177
|
-
...(deps.typecheck ? { typecheck: deps.typecheck } : {}),
|
|
178
|
-
// As the viewer: staging a rule is attributed to a person, and agents refuses it otherwise.
|
|
179
|
-
stageRule: (rule) => stageRule({ agentsUrl: c.env.MANTA_AGENTS_URL, session, datastoreId }, rule),
|
|
180
|
-
}),
|
|
181
|
-
},
|
|
182
|
-
maxSteps: turnStepLimit,
|
|
183
|
-
signal: c.req.raw.signal,
|
|
184
|
-
onEvent: (event) => emit({ type: "progress", event }),
|
|
185
|
-
});
|
|
186
|
-
emit({ type: "result", horn: turn.draft, reply: turn.reply, status: turn.status, steps: turn.steps });
|
|
187
|
-
}, noStore);
|
|
188
|
-
});
|
|
189
104
|
/**
|
|
190
105
|
* One turn of talking to a shape's view. The same lane as a Horn's, over a smaller thing: a view is a card
|
|
191
106
|
* and the fields it reads, and its gate is the zoo — the gate the coverage runs already judge by, so a view
|
|
@@ -215,7 +130,7 @@ export function studioRoutes(deps) {
|
|
|
215
130
|
// Never the Studio's own model as a fallback, unlike the shape critic: that one can read a card as text,
|
|
216
131
|
// and this one has only a picture. Qwen would be shown an image it cannot see and would answer about
|
|
217
132
|
// nothing. Unconfigured means no reviewer, and the caller is told why.
|
|
218
|
-
const critic = (deps.criticModel ??
|
|
133
|
+
const critic = (deps.criticModel ?? ((env) => criticModel(env)))(c.env, { feature: "horn-critic", datastoreId });
|
|
219
134
|
if (!critic.ok)
|
|
220
135
|
return c.json({ error: critic.reason }, 503);
|
|
221
136
|
const advice = await adviseOnHorn({
|
|
@@ -240,6 +155,7 @@ export function studioRoutes(deps) {
|
|
|
240
155
|
}
|
|
241
156
|
const select = agentsSparqlSelect({
|
|
242
157
|
agentsUrl: c.env.MANTA_AGENTS_URL,
|
|
158
|
+
upstream: c.env.UPSTREAM,
|
|
243
159
|
session,
|
|
244
160
|
datastoreId,
|
|
245
161
|
});
|
|
@@ -268,7 +184,7 @@ export function studioRoutes(deps) {
|
|
|
268
184
|
const parsed = coverageRequestSchema.safeParse(body ?? {});
|
|
269
185
|
if (!parsed.success)
|
|
270
186
|
return c.json({ error: "The request is invalid — limit must be between 1 and 3." }, 400);
|
|
271
|
-
const model = (deps.model ??
|
|
187
|
+
const model = (deps.model ?? ((env) => studioModel(env)))(c.env, { feature: "shape-coverage", datastoreId });
|
|
272
188
|
if (!model.ok)
|
|
273
189
|
return c.json({ error: model.reason }, 503);
|
|
274
190
|
// Optional: with no critic route it is the Studio's own model. Either way it reads the card as text —
|
|
@@ -276,6 +192,7 @@ export function studioRoutes(deps) {
|
|
|
276
192
|
const critic = deps.criticModel?.(c.env, { feature: "shape-critic", datastoreId });
|
|
277
193
|
const select = agentsSparqlSelect({
|
|
278
194
|
agentsUrl: c.env.MANTA_AGENTS_URL,
|
|
195
|
+
upstream: c.env.UPSTREAM,
|
|
279
196
|
session,
|
|
280
197
|
datastoreId,
|
|
281
198
|
});
|
|
@@ -334,11 +251,12 @@ export function studioRoutes(deps) {
|
|
|
334
251
|
if (parsed.data.spec.schema !== schema) {
|
|
335
252
|
return c.json({ error: `That view is for ${parsed.data.spec.schema}, not ${schema}.` }, 400);
|
|
336
253
|
}
|
|
337
|
-
const model = (deps.model ??
|
|
254
|
+
const model = (deps.model ?? ((env) => studioModel(env)))(c.env, { feature: "horns-shape-view", datastoreId });
|
|
338
255
|
if (!model.ok)
|
|
339
256
|
return c.json({ error: model.reason }, 503);
|
|
340
257
|
const select = agentsSparqlSelect({
|
|
341
258
|
agentsUrl: c.env.MANTA_AGENTS_URL,
|
|
259
|
+
upstream: c.env.UPSTREAM,
|
|
342
260
|
session,
|
|
343
261
|
datastoreId,
|
|
344
262
|
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Shape } from "@manta-eu/horns/shapes";
|
|
2
|
+
import type { Env } from "./env.ts";
|
|
3
|
+
import { type TypecheckFs } from "./typecheck.ts";
|
|
4
|
+
import { type HornsWorkerOptions } from "./worker.ts";
|
|
5
|
+
export { HornStudioAgent } from "./studio-agent-class.ts";
|
|
6
|
+
export type HornsStudioWorkerOptions = Omit<HornsWorkerOptions, "shapes" | "typecheck"> & {
|
|
7
|
+
/** The realm's shapes, built by `hornsShapeCatalog`: a worker cannot import the SDK's schema entry. */
|
|
8
|
+
shapes: () => readonly Shape[];
|
|
9
|
+
/** The declarations a written view is checked against, from `virtual:horns/typecheck-fs`. */
|
|
10
|
+
typecheckFs?: TypecheckFs;
|
|
11
|
+
};
|
|
12
|
+
export declare function createHornsStudioWorker(options: HornsStudioWorkerOptions): ExportedHandler<Env, unknown, unknown, unknown>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// A whole Horns worker, Studio included, from the three things a build has to hand it.
|
|
2
|
+
//
|
|
3
|
+
// `worker.ts` cannot reach the Studio itself: the conversation object imports `cloudflare:workers`, which
|
|
4
|
+
// that module must stay clear of so Node can load it. This entry may, so it does the wiring a host would
|
|
5
|
+
// otherwise repeat — registering what the Studio reads, and mounting its routes.
|
|
6
|
+
import { Hono } from "hono";
|
|
7
|
+
import { createTypecheck } from "./typecheck.js";
|
|
8
|
+
import { createHornsWorker } from "./worker.js";
|
|
9
|
+
import { provideStudioDeps, studioAgentRoutes } from "./studio-agent-routes.js";
|
|
10
|
+
export { HornStudioAgent } from "./studio-agent-class.js";
|
|
11
|
+
export function createHornsStudioWorker(options) {
|
|
12
|
+
const { shapes, componentApi, typecheckFs, routes, ...rest } = options;
|
|
13
|
+
const typecheck = typecheckFs === undefined ? undefined : createTypecheck(typecheckFs);
|
|
14
|
+
const parts = {
|
|
15
|
+
shapes,
|
|
16
|
+
...(componentApi ? { componentApi } : {}),
|
|
17
|
+
...(typecheck ? { typecheck } : {}),
|
|
18
|
+
};
|
|
19
|
+
// Registered rather than passed: the conversation object is built by the runtime, not by this call.
|
|
20
|
+
provideStudioDeps(parts);
|
|
21
|
+
return createHornsWorker({
|
|
22
|
+
...rest,
|
|
23
|
+
...parts,
|
|
24
|
+
routes: mount(routes),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
// Hono's `route` mutates what it is called on, so a host's own app is composed into a fresh one rather than
|
|
28
|
+
// having the Studio's routes added to it. The host's come first.
|
|
29
|
+
function mount(routes) {
|
|
30
|
+
const app = new Hono();
|
|
31
|
+
if (routes !== undefined)
|
|
32
|
+
app.route("/", routes);
|
|
33
|
+
return app.route("/", studioAgentRoutes());
|
|
34
|
+
}
|
package/dist/worker.d.ts
CHANGED
|
@@ -2,13 +2,18 @@ import { Hono } from "hono";
|
|
|
2
2
|
import type { Env } from "./env.ts";
|
|
3
3
|
import { type StudioRoutesDeps } from "./studio-routes.ts";
|
|
4
4
|
export type { Env };
|
|
5
|
-
export type HornsWorkerOptions = Pick<StudioRoutesDeps<Env>, "shapes" | "componentApi" | "typecheck"> & Partial<Pick<StudioRoutesDeps<Env>, "criticModel"
|
|
5
|
+
export type HornsWorkerOptions = Pick<StudioRoutesDeps<Env>, "shapes" | "componentApi" | "typecheck"> & Partial<Pick<StudioRoutesDeps<Env>, "criticModel">> & {
|
|
6
|
+
/** A host's own routes, mounted before the assets catch-all. */
|
|
7
|
+
routes?: Hono<{
|
|
8
|
+
Bindings: Env;
|
|
9
|
+
}>;
|
|
10
|
+
};
|
|
6
11
|
/**
|
|
7
12
|
* The Horns worker as a Hono app. `shapes` is the host's: the catalog describes a realm's data model, so
|
|
8
13
|
* Horns takes it rather than shipping one.
|
|
9
14
|
*/
|
|
10
|
-
export declare function createHornsApp({ shapes, componentApi, typecheck, criticModel, }: HornsWorkerOptions): Hono<{
|
|
15
|
+
export declare function createHornsApp({ shapes, componentApi, typecheck, routes, criticModel, }: HornsWorkerOptions): Hono<{
|
|
11
16
|
Bindings: Env;
|
|
12
17
|
}, import("hono/types").BlankSchema, "/">;
|
|
13
18
|
/** The same app behind Sentry, ready to be a Worker's default export. */
|
|
14
|
-
export declare function createHornsWorker(options: HornsWorkerOptions): ExportedHandler<Env,
|
|
19
|
+
export declare function createHornsWorker(options: HornsWorkerOptions): ExportedHandler<Env, unknown, unknown, unknown>;
|
package/dist/worker.js
CHANGED
|
@@ -8,7 +8,7 @@ import { hornRoutes } from "./horn-routes.js";
|
|
|
8
8
|
import { actionPlanRoutes } from "./action-plan-routes.js";
|
|
9
9
|
import { studioRoutes } from "./studio-routes.js";
|
|
10
10
|
import { contentSecurityPolicy } from "./csp.js";
|
|
11
|
-
import { criticModelFromEnv } from "./
|
|
11
|
+
import { criticModel as criticModelFromEnv } from "./studio-model.js";
|
|
12
12
|
import { requestSession } from "./dev-session.js";
|
|
13
13
|
import { sessionCookiePrefixOf } from "./session.js";
|
|
14
14
|
import { asJsonRequest, followedLink, noPageToOpen, signInFirst } from "./entity-links.js";
|
|
@@ -18,7 +18,7 @@ import { sentryOptionsForDeploy } from "./sentry.js";
|
|
|
18
18
|
* The Horns worker as a Hono app. `shapes` is the host's: the catalog describes a realm's data model, so
|
|
19
19
|
* Horns takes it rather than shipping one.
|
|
20
20
|
*/
|
|
21
|
-
export function createHornsApp({ shapes, componentApi, typecheck, criticModel = criticModelFromEnv, }) {
|
|
21
|
+
export function createHornsApp({ shapes, componentApi, typecheck, routes, criticModel = (env) => criticModelFromEnv(env), }) {
|
|
22
22
|
const app = new Hono();
|
|
23
23
|
// On the document only: a JSON answer nobody renders has nothing to protect, and the header is not small.
|
|
24
24
|
app.use("*", async (c, next) => {
|
|
@@ -180,6 +180,10 @@ export function createHornsApp({ shapes, componentApi, typecheck, criticModel =
|
|
|
180
180
|
...(componentApi ? { componentApi } : {}),
|
|
181
181
|
...(typecheck ? { typecheck } : {}),
|
|
182
182
|
}));
|
|
183
|
+
// A host's own routes, mounted before the assets catch-all. The Studio conversation arrives this way:
|
|
184
|
+
// importing it pulls in `cloudflare:workers`, which this module must not do.
|
|
185
|
+
if (routes)
|
|
186
|
+
app.route("/", routes);
|
|
183
187
|
app.all("*", (c) => c.env.ASSETS.fetch(c.req.raw));
|
|
184
188
|
app.onError((err, c) => {
|
|
185
189
|
captureException(err, { tags: { path: c.req.routePath, method: c.req.method } });
|