@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.
@@ -1,4 +1,5 @@
1
1
  import { Hono } from "hono";
2
+ import { type UpstreamFetcher } from "./agents-proxy.ts";
2
3
  import type { HornRoutesEnv } from "./horn-routes.ts";
3
4
  import type { MantaSessionState } from "./session.ts";
4
5
  export declare const planNotFoundMessage = "There is no plan like that in this datastore.";
@@ -12,6 +13,7 @@ export type PlanRunResult = {
12
13
  };
13
14
  export declare function executeActionPlan(args: {
14
15
  agentsUrl: string | undefined;
16
+ upstream?: UpstreamFetcher | undefined;
15
17
  session: MantaSessionState;
16
18
  datastoreId: string;
17
19
  plan: string;
@@ -24,7 +24,7 @@ const executeAnswerSchema = z.object({
24
24
  error: z.string().nullish(),
25
25
  });
26
26
  export async function executeActionPlan(args) {
27
- const sent = await agentsGraphql({ agentsUrl: args.agentsUrl, session: args.session, datastoreId: args.datastoreId }, executeActionPlanMutation, { input: { namedGraphIri: args.plan, dryRun: args.preview } }, "executeActionPlan");
27
+ const sent = await agentsGraphql({ agentsUrl: args.agentsUrl, upstream: args.upstream, session: args.session, datastoreId: args.datastoreId }, executeActionPlanMutation, { input: { namedGraphIri: args.plan, dryRun: args.preview } }, "executeActionPlan");
28
28
  // Being signed out and having no address are about the caller, so they are said plainly. What agents
29
29
  // answered is about the plan, so it is said as such.
30
30
  if (!sent.ok) {
@@ -58,6 +58,7 @@ export function actionPlanRoutes() {
58
58
  try {
59
59
  const found = await agentsSparqlSelect({
60
60
  agentsUrl: c.env.MANTA_AGENTS_URL,
61
+ upstream: c.env.UPSTREAM,
61
62
  session: authorized.session,
62
63
  datastoreId,
63
64
  })(isPlanQuery(realm, body.data.plan), undefined);
@@ -69,6 +70,7 @@ export function actionPlanRoutes() {
69
70
  }
70
71
  const started = await executeActionPlan({
71
72
  agentsUrl: c.env.MANTA_AGENTS_URL,
73
+ upstream: c.env.UPSTREAM,
72
74
  session: authorized.session,
73
75
  datastoreId,
74
76
  plan: body.data.plan,
@@ -1,9 +1,18 @@
1
1
  import { type MantaSessionState } from "./session.ts";
2
2
  export { checkReadOnlySparql, crossOriginBlockedMessage, evaluateProxyPolicy, isAllowedProxyPath, isAuthProxyPath, isReadOnlySparql, originAllowsRequest, proxyMethodBlockedMessage, proxyPathBlockedMessage, sameOriginDeniedResponse, sparqlEmptyMessage, sparqlFederationBlockedMessage, sparqlParseFailedMessage, sparqlWriteBlockedMessage, } from "./manta-proxy-policy.ts";
3
3
  export declare const missingAgentsUrlMessage = "MANTA_AGENTS_URL is not set \u2014 the live data source has no address.";
4
+ /**
5
+ * How this worker reaches the upstream, where a host has a way of its own. A host that is the upstream passes
6
+ * itself, so a call stays in the process rather than leaving through the public hostname; a service binding fits too.
7
+ */
8
+ export type UpstreamFetcher = {
9
+ fetch: (request: Request) => Response | Promise<Response>;
10
+ };
11
+ export declare function fetchUpstream(upstream: UpstreamFetcher | undefined, url: URL, init: RequestInit): Promise<Response>;
4
12
  export declare const signedOutMessage = "Not signed in \u2014 sign in to Manta to reach the live data source.";
5
13
  export type AgentsCaller = {
6
14
  agentsUrl: string | undefined;
15
+ upstream?: UpstreamFetcher | undefined;
7
16
  session: MantaSessionState;
8
17
  datastoreId: string;
9
18
  };
@@ -5,6 +5,9 @@ import { forwardedOriginHeader, logoutPath } from "./auth-routes.js";
5
5
  import { captureUpstreamCookies, clearSessionSetCookies, isSignedIn, requestIsSecure, sessionSetCookies, sessionsDiffer, setCookieHeadersOf, upstreamCookieHeader, } from "./session.js";
6
6
  export { checkReadOnlySparql, crossOriginBlockedMessage, evaluateProxyPolicy, isAllowedProxyPath, isAuthProxyPath, isReadOnlySparql, originAllowsRequest, proxyMethodBlockedMessage, proxyPathBlockedMessage, sameOriginDeniedResponse, sparqlEmptyMessage, sparqlFederationBlockedMessage, sparqlParseFailedMessage, sparqlWriteBlockedMessage, } from "./manta-proxy-policy.js";
7
7
  export const missingAgentsUrlMessage = "MANTA_AGENTS_URL is not set — the live data source has no address.";
8
+ export async function fetchUpstream(upstream, url, init) {
9
+ return isPresent(upstream) ? upstream.fetch(new Request(url, init)) : fetch(url, init);
10
+ }
8
11
  export const signedOutMessage = "Not signed in — sign in to Manta to reach the live data source.";
9
12
  // The one viewer-attributed GraphQL call. Both writers the worker has — staging a rule, uploading a file —
10
13
  // go through it, so a timeout or a request id is added once. `status` is carried for the caller that answers
@@ -13,7 +16,7 @@ export async function agentsGraphql(caller, query, variables, field, signal) {
13
16
  if (!isPresent(caller.agentsUrl))
14
17
  return { ok: false, status: 500, error: missingAgentsUrlMessage };
15
18
  const url = agentsPathUrl(caller.agentsUrl, "/graphql");
16
- const response = await fetch(url, {
19
+ const response = await fetchUpstream(caller.upstream, url, {
17
20
  method: "POST",
18
21
  headers: {
19
22
  cookie: upstreamCookieHeader(caller.session),
@@ -1,6 +1,8 @@
1
+ import { type UpstreamFetcher } from "./agents-proxy.ts";
1
2
  import { type MantaSessionState } from "./session.ts";
2
3
  export type DatastoreAccessEnv = {
3
4
  MANTA_AGENTS_URL?: string;
5
+ UPSTREAM?: UpstreamFetcher;
4
6
  };
5
7
  export type DatastoreAccess = {
6
8
  active?: string;
@@ -1,6 +1,6 @@
1
1
  import { isPresent } from "@manta-eu/lib/present";
2
2
  import { isRecord } from "@manta-eu/horns";
3
- import { agentsPathUrl } from "./agents-proxy.js";
3
+ import { agentsPathUrl, fetchUpstream } from "./agents-proxy.js";
4
4
  import { CustomerDatastoresDocument } from "@manta-eu/client";
5
5
  import { upstreamCookieHeader } from "./session.js";
6
6
  export const datastoreNotGrantedMessage = "That datastore is not one this session can open.";
@@ -57,7 +57,7 @@ async function customerDatastores(env, cookie, active) {
57
57
  if (!isPresent(env.MANTA_AGENTS_URL))
58
58
  return [];
59
59
  try {
60
- const response = await fetch(agentsPathUrl(env.MANTA_AGENTS_URL, "/graphql"), {
60
+ const response = await fetchUpstream(env.UPSTREAM, agentsPathUrl(env.MANTA_AGENTS_URL, "/graphql"), {
61
61
  method: "POST",
62
62
  headers: {
63
63
  cookie,
@@ -93,7 +93,7 @@ export async function datastoreAccessFor(env, session) {
93
93
  const url = agentsPathUrl(env.MANTA_AGENTS_URL, "/api/data/context");
94
94
  let response;
95
95
  try {
96
- response = await fetch(url, { headers: { cookie, accept: "application/json" } });
96
+ response = await fetchUpstream(env.UPSTREAM, url, { headers: { cookie, accept: "application/json" } });
97
97
  }
98
98
  catch {
99
99
  return { ok: false, reason: "unavailable" };
@@ -3,5 +3,8 @@ export type DevSessionEnv = {
3
3
  DEV_AGENTS_COOKIE?: string;
4
4
  /** A host that signs people in itself names its own session cookies here. Defaults to Horns' own. */
5
5
  HORNS_SESSION_COOKIE_PREFIX?: string;
6
+ /** `upstream` when the host is the upstream itself: the session is its own sign-in cookies, read as they are. */
7
+ HORNS_SESSION_SOURCE?: string;
6
8
  };
9
+ export declare const upstreamSessionSource = "upstream";
7
10
  export declare function requestSession(request: Request, env: DevSessionEnv): MantaSessionState | undefined;
@@ -1,10 +1,27 @@
1
1
  import { isPresent } from "@manta-eu/lib/present";
2
- import { agentsJwtCookieName, parseCookieHeader, readSessionState, sessionCookiePrefixOf, } from "./session.js";
2
+ import { agentsJwtCookieName, isForwardableCookie, parseCookieHeader, readSessionState, sessionCookiePrefixOf, } from "./session.js";
3
+ export const upstreamSessionSource = "upstream";
4
+ /** The upstream's access token and the session id it refreshes it with: all a call needs from the browser. */
5
+ const upstreamSessionCookies = [agentsJwtCookieName, `${agentsJwtCookieName}_sid`];
6
+ function upstreamSession(request) {
7
+ const jar = parseCookieHeader(request.headers.get("cookie"));
8
+ const upstreamCookies = Object.fromEntries(upstreamSessionCookies.flatMap((name) => {
9
+ const value = jar[name];
10
+ return isPresent(value) && isForwardableCookie(name, value) ? [[name, value]] : [];
11
+ }));
12
+ return isPresent(upstreamCookies[agentsJwtCookieName]) ? { upstreamCookies } : undefined;
13
+ }
3
14
  const loopbackHosts = new Set(["localhost", "127.0.0.1", "[::1]"]);
4
15
  // HireManta staging will not send a sign-in back to localhost, so `pnpm dev` can borrow a staging agents session
5
16
  // captured by `dev:borrow-session`. It stands in only for requests to this machine that carry no Horns session
6
17
  // yet (agents' token refreshes then land in a real one), and production builds drop it with import.meta.env.DEV.
7
18
  export function requestSession(request, env) {
19
+ if (isPresent(env.HORNS_SESSION_SOURCE)) {
20
+ if (env.HORNS_SESSION_SOURCE !== upstreamSessionSource) {
21
+ throw new Error(`HORNS_SESSION_SOURCE must be "${upstreamSessionSource}" or unset, not "${env.HORNS_SESSION_SOURCE}".`);
22
+ }
23
+ return upstreamSession(request);
24
+ }
8
25
  const session = readSessionState(request.headers.get("cookie"), sessionCookiePrefixOf(env));
9
26
  if (isPresent(session) || !import.meta.env.DEV || !isPresent(env.DEV_AGENTS_COOKIE))
10
27
  return session;
package/dist/env.d.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import type { HornRoutesEnv } from "./horn-routes.ts";
2
2
  import type { StudioRoutesEnv } from "./studio-routes.ts";
3
- import type { StudioModelEnv } from "./llm.ts";
3
+ import type { StudioModelEnv } from "./studio-model.ts";
4
+ import type { HornStudioAgent } from "./studio-agent-class.ts";
4
5
  /** Everything the assembled worker reads. A host mounting only some routes needs only those routes' env. */
5
6
  export type Env = HornRoutesEnv & StudioRoutesEnv & StudioModelEnv & {
7
+ /** The Studio conversation object, where a host binds one. */
8
+ HORN_STUDIO?: DurableObjectNamespace<HornStudioAgent>;
6
9
  SITE_PASSWORD?: string;
7
10
  SENTRY_DSN?: string;
8
11
  CF_VERSION_METADATA?: {
@@ -1,6 +1,7 @@
1
1
  import { Hono } from "hono";
2
2
  import { type RevisionLookup } from "@manta-eu/horns";
3
3
  import type { DevSessionEnv } from "./dev-session.ts";
4
+ import type { UpstreamFetcher } from "./agents-proxy.ts";
4
5
  export declare const maxScreenshotBytes: number;
5
6
  type AssetFetcher = {
6
7
  fetch: (request: Request) => Response | Promise<Response>;
@@ -8,6 +9,7 @@ type AssetFetcher = {
8
9
  export type HornRoutesEnv = DevSessionEnv & {
9
10
  ASSETS: AssetFetcher;
10
11
  MANTA_AGENTS_URL?: string;
12
+ UPSTREAM?: UpstreamFetcher;
11
13
  VITE_KEYCLOAK_REALM?: string;
12
14
  SPARQL_UPDATE_PSK?: string;
13
15
  };
@@ -43,6 +43,7 @@ async function realmLibraryLookup(args) {
43
43
  try {
44
44
  const results = await agentsSparqlSelect({
45
45
  agentsUrl: args.env.MANTA_AGENTS_URL,
46
+ upstream: args.env.UPSTREAM,
46
47
  session: args.session,
47
48
  datastoreId: args.realm,
48
49
  })(libraryItemsQuery(args.realm), undefined);
@@ -91,6 +92,7 @@ export function hornRoutes(deps = {}) {
91
92
  horn: args.body.horn,
92
93
  select: agentsSparqlSelect({
93
94
  agentsUrl: args.env.MANTA_AGENTS_URL,
95
+ upstream: args.env.UPSTREAM,
94
96
  session: args.session,
95
97
  datastoreId: args.datastoreId,
96
98
  }),
@@ -132,6 +134,7 @@ export function hornRoutes(deps = {}) {
132
134
  try {
133
135
  const found = await agentsSparqlSelect({
134
136
  agentsUrl: c.env.MANTA_AGENTS_URL,
137
+ upstream: c.env.UPSTREAM,
135
138
  session: authorized.session,
136
139
  datastoreId,
137
140
  })(hornsInDatastoreQuery(realm), undefined);
@@ -172,6 +175,7 @@ export function hornRoutes(deps = {}) {
172
175
  return refuse(500, "This deployment has no realm configured.").response;
173
176
  const select = agentsSparqlSelect({
174
177
  agentsUrl: c.env.MANTA_AGENTS_URL,
178
+ upstream: c.env.UPSTREAM,
175
179
  session: authorized.session,
176
180
  datastoreId,
177
181
  });
@@ -215,11 +219,13 @@ export function hornRoutes(deps = {}) {
215
219
  const slug = c.req.param("slug");
216
220
  const select = agentsSparqlSelect({
217
221
  agentsUrl: c.env.MANTA_AGENTS_URL,
222
+ upstream: c.env.UPSTREAM,
218
223
  session: authorized.session,
219
224
  datastoreId,
220
225
  });
221
226
  const update = agentsSparqlUpdate({
222
227
  agentsUrl: c.env.MANTA_AGENTS_URL,
228
+ upstream: c.env.UPSTREAM,
223
229
  datastoreId,
224
230
  updateKey: c.env.SPARQL_UPDATE_PSK,
225
231
  purpose: { kind: "horn-save", realm },
@@ -496,6 +502,7 @@ export function hornRoutes(deps = {}) {
496
502
  return refuse(400, `"${about}" is not an IRI.`).response;
497
503
  const select = agentsSparqlSelect({
498
504
  agentsUrl: c.env.MANTA_AGENTS_URL,
505
+ upstream: c.env.UPSTREAM,
499
506
  session: authorized.session,
500
507
  datastoreId,
501
508
  });
@@ -590,6 +597,7 @@ export function hornRoutes(deps = {}) {
590
597
  const slug = c.req.param("slug");
591
598
  const select = agentsSparqlSelect({
592
599
  agentsUrl: c.env.MANTA_AGENTS_URL,
600
+ upstream: c.env.UPSTREAM,
593
601
  session: authorized.session,
594
602
  datastoreId,
595
603
  });
@@ -601,12 +609,13 @@ export function hornRoutes(deps = {}) {
601
609
  if (!isPresent(revision))
602
610
  return refuse(404, `There is no Horn called "${slug}" here.`).response;
603
611
  const number = Number(current?.["number"]?.value) || 1;
604
- const uploaded = await uploadPng({ agentsUrl: c.env.MANTA_AGENTS_URL, session: authorized.session, datastoreId }, { fileName: `horn-${slug}-r${number}.png`, bytes });
612
+ const uploaded = await uploadPng({ agentsUrl: c.env.MANTA_AGENTS_URL, upstream: c.env.UPSTREAM, session: authorized.session, datastoreId }, { fileName: `horn-${slug}-r${number}.png`, bytes });
605
613
  if (!uploaded.ok)
606
614
  return refuse(uploaded.status, uploaded.error).response;
607
615
  const capturedAt = (deps.now?.() ?? new Date()).toISOString();
608
616
  await agentsSparqlUpdate({
609
617
  agentsUrl: c.env.MANTA_AGENTS_URL,
618
+ upstream: c.env.UPSTREAM,
610
619
  datastoreId,
611
620
  updateKey: c.env.SPARQL_UPDATE_PSK,
612
621
  purpose: { kind: "horn-save", realm },
package/dist/index.d.ts CHANGED
@@ -6,14 +6,16 @@ export { studioRoutes, maxHistoryTurns, type StudioRoutesDeps, type StudioRoutes
6
6
  export { actionPlanRoutes } from "./action-plan-routes.ts";
7
7
  export { authUnavailableResponse, callbackPath, localSignOutResponse, loginPath, loginRequestWithMarker, logoutPath, logoutResponseReturningTo, } from "./auth-routes.ts";
8
8
  export { signedInParam } from "./auth-params.ts";
9
- export { proxyToMantaAgents, sameOriginDeniedResponse, signedOutMessage } from "./agents-proxy.ts";
9
+ export { proxyToMantaAgents, sameOriginDeniedResponse, signedOutMessage, type UpstreamFetcher, } from "./agents-proxy.ts";
10
+ export { contentSecurityPolicy } from "./csp.ts";
10
11
  export { datastoreAccessFor, datastoreAccessUnavailableMessage, datastoreNotGrantedMessage, forgetDatastoreAccess, grantsDatastore, resetDatastoreAccessCache, } from "./datastore-access.ts";
11
12
  export { agentsSparqlSelect } from "./sparql-select.ts";
12
13
  export { agentsSparqlUpdate, WriteRefusedError } from "./sparql-update.ts";
13
- export { criticModelFromEnv, studioModelFromEnv, type StudioModelEnv, type StudioModelResult } from "./llm.ts";
14
+ export { criticModel, studioModel, type StudioModelEnv, type StudioModelChoice } from "./studio-model.ts";
14
15
  export { badSessionCookiePrefixMessage, isSignedIn, sessionCookiePrefix, sessionCookiePrefixOf, sessionSetCookies, type MantaSessionState, } from "./session.ts";
15
16
  export { requestSession, type DevSessionEnv } from "./dev-session.ts";
16
17
  export { hornTemplatePath, renderHornDocument } from "./render-document.ts";
17
18
  export { sentryOptionsForDeploy } from "./sentry.ts";
18
19
  export { withoutPiiErrorEvent } from "./pii.ts";
19
20
  export { asJsonRequest, followedLink, noPageToOpen, signInFirst } from "./entity-links.ts";
21
+ export { authorizeStudioConversation, studioConversationName, studioOpening, type StudioAgentProps, type StudioConversationEnv, type StudioOpening, } from "./studio-opening.ts";
package/dist/index.js CHANGED
@@ -5,16 +5,20 @@ export { studioRoutes, maxHistoryTurns } from "./studio-routes.js";
5
5
  export { actionPlanRoutes } from "./action-plan-routes.js";
6
6
  export { authUnavailableResponse, callbackPath, localSignOutResponse, loginPath, loginRequestWithMarker, logoutPath, logoutResponseReturningTo, } from "./auth-routes.js";
7
7
  export { signedInParam } from "./auth-params.js";
8
- export { proxyToMantaAgents, sameOriginDeniedResponse, signedOutMessage } from "./agents-proxy.js";
8
+ export { proxyToMantaAgents, sameOriginDeniedResponse, signedOutMessage, } from "./agents-proxy.js";
9
+ // A host serving Horns' pages from its own documents sends the same policy, since connect-src 'self' is part
10
+ // of what holds a written view in.
11
+ export { contentSecurityPolicy } from "./csp.js";
9
12
  export { datastoreAccessFor, datastoreAccessUnavailableMessage, datastoreNotGrantedMessage, forgetDatastoreAccess, grantsDatastore, resetDatastoreAccessCache, } from "./datastore-access.js";
10
13
  export { agentsSparqlSelect } from "./sparql-select.js";
11
14
  export { agentsSparqlUpdate, WriteRefusedError } from "./sparql-update.js";
12
- // Also its own entry, `@manta-eu/horns-server/llm`: building a model client must not drag in the render
15
+ // Also its own entry, `@manta-eu/horns-server/studio-model`: building a model must not drag in the render
13
16
  // path, which is JSX and which a Node script cannot load.
14
- export { criticModelFromEnv, studioModelFromEnv } from "./llm.js";
17
+ export { criticModel, studioModel } from "./studio-model.js";
15
18
  export { badSessionCookiePrefixMessage, isSignedIn, sessionCookiePrefix, sessionCookiePrefixOf, sessionSetCookies, } from "./session.js";
16
19
  export { requestSession } from "./dev-session.js";
17
20
  export { hornTemplatePath, renderHornDocument } from "./render-document.js";
18
21
  export { sentryOptionsForDeploy } from "./sentry.js";
19
22
  export { withoutPiiErrorEvent } from "./pii.js";
20
23
  export { asJsonRequest, followedLink, noPageToOpen, signInFirst } from "./entity-links.js";
24
+ export { authorizeStudioConversation, studioConversationName, studioOpening, } from "./studio-opening.js";
@@ -1,4 +1,5 @@
1
1
  import { type WritePurpose } from "@manta-eu/horns";
2
+ import { type UpstreamFetcher } from "./agents-proxy.ts";
2
3
  export declare const updateKeyHeader = "x-sparql-update-key";
3
4
  export declare const missingUpdateKeyMessage = "SPARQL_UPDATE_PSK is not set \u2014 this deployment cannot write.";
4
5
  export declare class WriteRefusedError extends Error {
@@ -7,6 +8,7 @@ export declare class WriteRefusedError extends Error {
7
8
  export type SparqlUpdate = (update: string) => Promise<void>;
8
9
  export declare function agentsSparqlUpdate(args: {
9
10
  agentsUrl: string | undefined;
11
+ upstream?: UpstreamFetcher | undefined;
10
12
  datastoreId: string;
11
13
  updateKey: string | undefined;
12
14
  purpose: WritePurpose;
@@ -1,6 +1,6 @@
1
1
  import { isPresent } from "@manta-eu/lib/present";
2
2
  import { checkWrite } from "@manta-eu/horns";
3
- import { agentsPathUrl, missingAgentsUrlMessage } from "./agents-proxy.js";
3
+ import { agentsPathUrl, fetchUpstream, missingAgentsUrlMessage } from "./agents-proxy.js";
4
4
  // Agents' `/d/:ds/sparql-update` authorizes on the pre-shared key alone and does not restrict `GRAPH`, so this
5
5
  // worker is the write-authorization boundary. The guard runs here rather than at each call site: there is one
6
6
  // way to send an update and it cannot be the way that forgot to check.
@@ -23,7 +23,7 @@ export function agentsSparqlUpdate(args) {
23
23
  if (args.updateKey === undefined)
24
24
  throw new Error(missingUpdateKeyMessage);
25
25
  const url = agentsPathUrl(args.agentsUrl, `/d/${encodeURIComponent(args.datastoreId)}/sparql-update`);
26
- const response = await fetch(url, {
26
+ const response = await fetchUpstream(args.upstream, url, {
27
27
  method: "POST",
28
28
  headers: {
29
29
  [updateKeyHeader]: args.updateKey,
@@ -0,0 +1,41 @@
1
+ import { Think } from "@cloudflare/think";
2
+ import type { Connection, ConnectionContext } from "agents";
3
+ import { type LanguageModel, type ModelMessage } from "ai";
4
+ import { type Horn } from "@manta-eu/horns";
5
+ import { type StudioMode } from "@manta-eu/horns/studio";
6
+ import type { UpstreamFetcher } from "./agents-proxy.ts";
7
+ import { type StudioModelEnv } from "./studio-model.ts";
8
+ export { studioOpening, type StudioAgentProps, type StudioOpening } from "./studio-opening.ts";
9
+ export type StudioAgentEnv = StudioModelEnv & {
10
+ MANTA_AGENTS_URL?: string;
11
+ VITE_KEYCLOAK_REALM?: string;
12
+ UPSTREAM?: UpstreamFetcher;
13
+ };
14
+ export type StudioAgentState = {
15
+ draft: Horn | null;
16
+ mode: StudioMode;
17
+ };
18
+ export declare class HornStudioAgent extends Think<StudioAgentEnv, StudioAgentState> {
19
+ #private;
20
+ initialState: StudioAgentState;
21
+ maxSteps: number;
22
+ onRequest(request: Request): Promise<Response>;
23
+ onConnect(connection: Connection, ctx: ConnectionContext): void | Promise<void>;
24
+ private get opened();
25
+ private get realm();
26
+ private select;
27
+ getModel(): LanguageModel;
28
+ /** Where the draft has got to: nothing drawn is designing, drawn but unbound is hydrating. */
29
+ private get stage();
30
+ beforeStep(): {
31
+ activeTools: string[];
32
+ system: string;
33
+ } | undefined;
34
+ beforeTurn(): Promise<{
35
+ instructions: string;
36
+ messages: ModelMessage[];
37
+ tools: {
38
+ [x: string]: (import("ai").Tool<never, never, any> | import("ai").Tool<any, any, any> | import("ai").Tool<any, never, any> | import("ai").Tool<never, any, any>) & Pick<import("ai").Tool<any, any, any>, "execute" | "onInputAvailable" | "onInputStart" | "onInputDelta" | "needsApproval">;
39
+ };
40
+ }>;
41
+ }
@@ -0,0 +1,171 @@
1
+ import { Think } from "@cloudflare/think";
2
+ import { convertToModelMessages } from "ai";
3
+ import { isPresent } from "@manta-eu/lib/present";
4
+ import { hornSchema } from "@manta-eu/horns";
5
+ import { z } from "zod";
6
+ import { mantaReactCatalog } from "@manta-eu/horns-kit";
7
+ import { shapeViewSpecs } from "@manta-eu/horns-kit/shape-views";
8
+ import { buildUsefulContext, datastoreFacts, builtInSkills, selectSkills, studioReadHandlers, studioSystemPrompt, studioWriteHandlers, stageGuidance, stageOf, toolsForStage, } from "@manta-eu/horns/studio";
9
+ import { agentsSparqlSelect } from "./sparql-select.js";
10
+ import { stageRule } from "./manta-rules.js";
11
+ import { studioDeps } from "./studio-deps.js";
12
+ import { pageToolSet, studioToolSet } from "./studio-agent.js";
13
+ import { studioModel } from "./studio-model.js";
14
+ import { readStudioOpening } from "./studio-opening.js";
15
+ export { studioOpening } from "./studio-opening.js";
16
+ const openRequestSchema = z.object({
17
+ horn: hornSchema,
18
+ mode: z.enum(["plan", "build", "investigate"]).optional(),
19
+ });
20
+ export class HornStudioAgent extends Think {
21
+ initialState = { draft: null, mode: "build" };
22
+ maxSteps = 24;
23
+ /** In memory only: a session must not outlive the connection that sent it. */
24
+ #opened;
25
+ async onRequest(request) {
26
+ const opened = readStudioOpening(request);
27
+ if (opened !== undefined)
28
+ this.#opened = opened;
29
+ return this.#answer(request);
30
+ }
31
+ onConnect(connection, ctx) {
32
+ const opened = readStudioOpening(ctx.request);
33
+ if (opened !== undefined)
34
+ this.#opened = opened;
35
+ return super.onConnect(connection, ctx);
36
+ }
37
+ get opened() {
38
+ if (this.#opened === undefined)
39
+ throw new Error("This Studio was reached without a session.");
40
+ return this.#opened;
41
+ }
42
+ get realm() {
43
+ return this.opened.realm;
44
+ }
45
+ select() {
46
+ return agentsSparqlSelect({
47
+ agentsUrl: this.env.MANTA_AGENTS_URL,
48
+ upstream: this.env.UPSTREAM,
49
+ session: this.opened.session,
50
+ datastoreId: this.opened.datastoreId,
51
+ });
52
+ }
53
+ getModel() {
54
+ const chosen = studioModel(this.env);
55
+ if (!chosen.ok)
56
+ throw new Error(chosen.reason);
57
+ return chosen.model;
58
+ }
59
+ /** The Horn this conversation is about. One draft, so a turn and an edit cannot disagree. */
60
+ async #answer(request) {
61
+ const url = new URL(request.url);
62
+ if (!url.pathname.endsWith("/open") || request.method !== "POST")
63
+ return super.onRequest(request);
64
+ const body = await request.json().catch(() => undefined);
65
+ const opened = openRequestSchema.safeParse(body);
66
+ if (!opened.success)
67
+ return Response.json({ error: "Send the Horn to open." }, { status: 400 });
68
+ this.setState({ ...this.state, draft: opened.data.horn, mode: opened.data.mode ?? this.state.mode });
69
+ return Response.json({ opened: true });
70
+ }
71
+ /** Where the draft has got to: nothing drawn is designing, drawn but unbound is hydrating. */
72
+ get stage() {
73
+ const views = this.state.draft?.views ?? [];
74
+ const drawn = views.length > 0 && views.every((view) => "ref" in view || isPresent(view.render));
75
+ const bound = drawn && views.every((view) => "ref" in view || isPresent(view.binding));
76
+ return stageOf({ drawn, bound });
77
+ }
78
+ beforeStep() {
79
+ if (!isPresent(this.state.draft))
80
+ return undefined;
81
+ const stage = this.stage;
82
+ return { activeTools: [...toolsForStage(stage)], system: stageGuidance(stage) };
83
+ }
84
+ async beforeTurn() {
85
+ const draft = this.state.draft;
86
+ if (!isPresent(draft))
87
+ throw new Error("This Studio has no Horn open.");
88
+ const { mode } = this.state;
89
+ const realm = this.realm;
90
+ const select = this.select();
91
+ const shapes = studioDeps().shapes(realm);
92
+ const deps = studioDeps();
93
+ const asked = userWords(this.messages);
94
+ const useful = buildUsefulContext({
95
+ realm,
96
+ prompt: asked,
97
+ horn: draft,
98
+ ...(await datastoreFacts({ realm, select, shapes, prompt: asked })),
99
+ library: [],
100
+ catalog: mantaReactCatalog,
101
+ skills: selectSkills(asked, builtInSkills),
102
+ });
103
+ const handlers = {
104
+ ...studioReadHandlers({
105
+ realm,
106
+ select,
107
+ shapes,
108
+ views: shapeViewSpecs,
109
+ model: this.getModel(),
110
+ ...(deps.componentApi ? { componentApi: deps.componentApi } : {}),
111
+ }),
112
+ ...studioWriteHandlers({
113
+ realm,
114
+ select,
115
+ catalog: mantaReactCatalog,
116
+ lookup: () => undefined,
117
+ ...(deps.typecheck ? { typecheck: deps.typecheck } : {}),
118
+ // As the viewer: staging a rule is attributed to a person, and agents refuses it otherwise.
119
+ stageRule: (rule) => stageRule({
120
+ agentsUrl: this.env.MANTA_AGENTS_URL,
121
+ session: this.opened.session,
122
+ datastoreId: this.opened.datastoreId,
123
+ }, rule),
124
+ }),
125
+ };
126
+ return {
127
+ instructions: studioSystemPrompt({ mode, realm }),
128
+ // Inside the last thing the user said, so the ask and the context are read as one.
129
+ messages: withUsefulContext(await convertToModelMessages(this.messages), useful.text),
130
+ tools: {
131
+ ...studioToolSet({
132
+ mode,
133
+ handlers,
134
+ draft: () => this.state.draft ?? draft,
135
+ onDraft: (next) => this.setState({ ...this.state, draft: next }),
136
+ }),
137
+ // Drawing a Horn needs the design system and a browser, so the page runs this one.
138
+ ...pageToolSet(["look_at_it"]),
139
+ },
140
+ };
141
+ }
142
+ }
143
+ // Every ask, not just the latest: the context is ranked against this.
144
+ function userWords(messages) {
145
+ return messages
146
+ .filter((message) => message.role === "user")
147
+ .flatMap((message) => (message.parts ?? []).flatMap((part) => typeof part === "object" && part !== null && "type" in part && part.type === "text" && "text" in part
148
+ ? [String(part.text)]
149
+ : []))
150
+ .join("\n");
151
+ }
152
+ /** Puts the context into the last thing the user said, leaving earlier messages alone. */
153
+ function withUsefulContext(messages, context) {
154
+ const at = messages.map((message) => message.role).lastIndexOf("user");
155
+ if (at < 0)
156
+ return [...messages, { role: "user", content: context }];
157
+ return messages.map((message, index) => {
158
+ if (index !== at || message.role !== "user")
159
+ return message;
160
+ return { role: "user", content: `${wordsOf(message.content)}\n\n${context}` };
161
+ });
162
+ }
163
+ /** A converted message carries parts; what is sent must be one string. */
164
+ function wordsOf(content) {
165
+ if (typeof content === "string")
166
+ return content;
167
+ return content
168
+ .flatMap((part) => (part.type === "text" ? [part.text] : []))
169
+ .join("\n")
170
+ .trim();
171
+ }
@@ -0,0 +1,18 @@
1
+ import { Hono } from "hono";
2
+ export { provideStudioDeps, type StudioDeps } from "./studio-deps.ts";
3
+ import type { HornStudioAgent, StudioAgentEnv } from "./studio-agent-class.ts";
4
+ import { type StudioConversationEnv } from "./studio-opening.ts";
5
+ export declare const studioUnboundMessage = "This deployment has no Studio: bind HORN_STUDIO to the HornStudioAgent class and add its migration.";
6
+ export type StudioAgentRoutesEnv = StudioAgentEnv & StudioConversationEnv & {
7
+ /** The Durable Object a host binds `HornStudioAgent` to. Absent until a host adds it. */
8
+ HORN_STUDIO?: DurableObjectNamespace<HornStudioAgent>;
9
+ };
10
+ /**
11
+ * The Studio conversation, addressed the way every other Horns route is.
12
+ *
13
+ * Not `routeAgentRequest`: its `/agents/:binding/:name` shape carries no datastore, and the grant is
14
+ * checked before the object is reached rather than inside it.
15
+ */
16
+ export declare function studioAgentRoutes<Env extends StudioAgentRoutesEnv>(): Hono<{
17
+ Bindings: Env;
18
+ }, import("hono/types").BlankSchema, "/">;
@@ -0,0 +1,37 @@
1
+ import { Hono } from "hono";
2
+ // Beside the route a host mounts: the object cannot be handed its dependencies, so it is told them.
3
+ export { provideStudioDeps } from "./studio-deps.js";
4
+ import { getAgentByName } from "agents";
5
+ import { authorizeStudioConversation, serializeStudioOpening, studioAuthHeader, studioConversationName, } from "./studio-opening.js";
6
+ export const studioUnboundMessage = "This deployment has no Studio: bind HORN_STUDIO to the HornStudioAgent class and add its migration.";
7
+ /**
8
+ * The Studio conversation, addressed the way every other Horns route is.
9
+ *
10
+ * Not `routeAgentRequest`: its `/agents/:binding/:name` shape carries no datastore, and the grant is
11
+ * checked before the object is reached rather than inside it.
12
+ */
13
+ export function studioAgentRoutes() {
14
+ const routes = new Hono();
15
+ // The conversation and everything under it: the socket is the path itself, and the chat and `open`
16
+ // hang below it.
17
+ const reach = async (c) => {
18
+ const studio = c.env.HORN_STUDIO;
19
+ if (studio === undefined)
20
+ return c.json({ error: studioUnboundMessage }, 503);
21
+ const datastoreId = c.req.param("datastoreId") ?? "";
22
+ const conversation = c.req.param("conversation") ?? "";
23
+ if (datastoreId === "" || conversation === "")
24
+ return c.json({ error: "No conversation named." }, 404);
25
+ const allowed = await authorizeStudioConversation(c.req.raw, c.env, datastoreId);
26
+ if (!allowed.ok)
27
+ return allowed.response;
28
+ const agent = await getAgentByName(studio, studioConversationName(datastoreId, conversation));
29
+ // `new Request(request, { headers })` keeps a WebSocket upgrade intact; building one from parts does not.
30
+ const headers = new Headers(c.req.raw.headers);
31
+ headers.set(studioAuthHeader, serializeStudioOpening(allowed.props));
32
+ return agent.fetch(new Request(c.req.raw, { headers }));
33
+ };
34
+ routes.all("/api/d/:datastoreId/studio/:conversation", reach);
35
+ routes.all("/api/d/:datastoreId/studio/:conversation/*", reach);
36
+ return routes;
37
+ }
@@ -0,0 +1,20 @@
1
+ import { type ToolSet } from "ai";
2
+ import type { Horn } from "@manta-eu/horns";
3
+ import { type StudioMode, type StudioToolHandlers, type StudioToolInput, type StudioToolName } from "@manta-eu/horns/studio";
4
+ export type StudioToolSetArgs = {
5
+ mode: StudioMode;
6
+ handlers: StudioToolHandlers;
7
+ draft: () => Horn;
8
+ /** A proposal that passed the gate carries the new draft; nothing else may change it. */
9
+ onDraft: (draft: Horn) => void;
10
+ signal?: AbortSignal;
11
+ };
12
+ /** What a tool call does, apart from the SDK wrapper around it. Generic, so input and handler stay bound. */
13
+ export declare function runStudioTool<Name extends StudioToolName>(handler: NonNullable<StudioToolHandlers[Name]>, input: StudioToolInput<Name>, args: StudioToolSetArgs): Promise<string>;
14
+ /**
15
+ * Tools the page runs, not the worker. Declared with no `execute`, so the harness sends the call to the
16
+ * browser and waits for what it answers.
17
+ */
18
+ export declare function pageToolSet(names: readonly StudioToolName[]): ToolSet;
19
+ /** The tools a mode offers, as the SDK wants them: zod in, the handler's words out. */
20
+ export declare function studioToolSet(args: StudioToolSetArgs): ToolSet;