Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,19 @@ Run the validation the task requests. When it does not establish the behavior yo

- The repository root owns the single Next.js application, Eve agent, and shared UI contract.
- The workspace manager lives on `/` and the agent chat on `/chat`; browser execution belongs only to the declared worker's flat tool surface under `agent/subagents/worker/tools`.
- Keep each worker browser tool's schema and implementation together. Share the Kernel SDK client through `src/lib/kernel.ts` and keep only cross-tool ownership guards under `agent/subagents/worker/lib`; do not add a Kernel extension or root browser connection.
- Keep each worker browser tool's schema and implementation together. Share the Kernel SDK client through `src/lib/kernel.ts`; do not add a Kernel extension or root browser connection.
- `agent/subagents/worker/lib` is for code genuinely shared by worker tools. Group a shared worker domain in a lower-case folder, such as `trace/domains.ts` or `autofill/provider.ts`; do not use it as a holding area for a tool's one-off logic.
- Validate runtime environment variables through `src/lib/env.ts`. `KERNEL_API_KEY` is required by the worker browser tools.
- Run `pnpm check` and `pnpm build` before handing off changes.

## Code organization

- Treat `src/lib` as a small shared infrastructure and contract boundary, not a default destination for application code. A file belongs there only when it has real cross-feature ownership; put database access in `db/services`, agent behavior under `agent`, and route or section behavior with its route.
- Do not add a generic `src/modules` layer. Give code a concrete owner and colocate it there. A route section owns its section components, forms, and local parsing; split it only when the files have distinct responsibilities.
- Prefer one cohesive call-site file for code used once. Do not add production factories, dependency containers, server wrappers, or files solely to make a unit test easier to mock.
- Use lower-case file and folder names. When several files share a domain prefix, make that prefix a folder and name files for their role, such as `trace/domains.ts` rather than `trace-domains.ts`. Do not introduce camel-case filenames.
- Avoid catch-all names such as `manager`, `store`, `helpers`, or `utils` for feature ownership. Reuse an existing narrowly named boundary or place the code at the concrete owner instead.

## Design system

Before planning or changing product UI:
Expand Down
4 changes: 2 additions & 2 deletions agent/agent.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { defineAgent, defineDynamic } from "eve";
import { getGatewayModel } from "@/db/services/settings";
import { scopeFromPrincipal } from "@/lib/access-scope";
import { getModelSettings } from "@/lib/model-config";

export default defineAgent({
experimental: {
Expand All @@ -11,7 +11,7 @@ export default defineAgent({
"step.started": async (_event, ctx) => {
const caller = ctx.session.auth.current ?? ctx.session.auth.initiator;
if (!caller) throw new Error("An authenticated user is required.");
return (await getModelSettings(scopeFromPrincipal(caller))).modelId;
return getGatewayModel(scopeFromPrincipal(caller));
},
},
}),
Expand Down
4 changes: 2 additions & 2 deletions agent/channels/linq.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ import { z } from "zod";
import { getAuth } from "@/auth";
import { normalizeAuthPhoneNumber } from "@/auth/phone-number";
import { accessScopeForUser, scopeFromPrincipal } from "@/lib/access-scope";
import { prepareLinqBrowserImageDelivery } from "../lib/linq-browser-image-delivery";
import {
extractBrowserImageMarkdownReferences,
stripBrowserImageMarkdownReferences,
} from "@/lib/browser-images";
} from "../lib/linq-browser-image-markdown";
import { env } from "@/lib/env";
import { consumeWorkerCancellationTurn } from "../lib/worker-cancellation-delivery";
import { prepareLinqBrowserImageDelivery } from "../lib/linq-browser-image-delivery";

const verifiedPhoneUserSchema = z.object({
id: z.string().min(1),
Expand Down
6 changes: 3 additions & 3 deletions agent/lib/google-workspace/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { z } from "zod";
import { env } from "@/lib/env";
import {
googleWorkspaceSubject,
GOOGLE_WORKSPACE_SCOPES,
} from "@/lib/google-workspace/config";
googleWorkspaceScopes,
} from "@/lib/google-workspace";

export const googleWorkspaceAuthOptions = {
connector: env.GOOGLE_CONNECTOR_UID,
Expand All @@ -18,7 +18,7 @@ export const googleWorkspaceAuthOptions = {
}
return googleWorkspaceSubject(principal.id);
},
tokenParams: { scopes: [...GOOGLE_WORKSPACE_SCOPES] },
tokenParams: { scopes: [...googleWorkspaceScopes] },
validate: true,
} satisfies EveAuthorizationOptions;

Expand Down
78 changes: 72 additions & 6 deletions agent/lib/linq-browser-image-delivery.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { createHash } from "node:crypto";
import { get } from "@vercel/blob";
import type { AccessScope } from "@/lib/access-scope";
import { readReadyBrowserImageArtifact } from "@/db/services/browser-images";
import { maximumBrowserImageBytes } from "@/lib/browser-artifact";
import { env } from "@/lib/env";
import { maximumWorkerCompletionImages } from "@/lib/worker-completion";
import {
extractBrowserImageMarkdownReferences,
maximumBrowserImagesPerCompletion,
stripBrowserImageMarkdownReferences,
} from "@/lib/browser-images";
import { readBrowserImageBytes } from "@/lib/browser-images/server";
} from "./linq-browser-image-markdown";

interface LinqBrowserImageFile {
readonly data: Buffer;
Expand All @@ -25,10 +29,10 @@ export async function prepareLinqBrowserImageDelivery(
return { failedArtifactIds: [], files: [], markdown: message };
}

const selected = references.slice(0, maximumBrowserImagesPerCompletion);
const selected = references.slice(0, maximumWorkerCompletionImages);
const loaded = await Promise.all(
selected.map(async (reference) => ({
image: await readBrowserImageBytes(input.scope, reference.id, {
image: await readLinqBrowserImage(input.scope, reference.id, {
rootSessionId: input.rootSessionId,
signal: input.signal,
}).catch(() => undefined),
Expand All @@ -40,7 +44,7 @@ export async function prepareLinqBrowserImageDelivery(
.filter((item) => item.image === undefined)
.map((item) => item.reference.id),
...references
.slice(maximumBrowserImagesPerCompletion)
.slice(maximumWorkerCompletionImages)
.map((reference) => reference.id),
];
const files = loaded.flatMap(({ image }) =>
Expand All @@ -61,3 +65,65 @@ export async function prepareLinqBrowserImageDelivery(
markdown: stripBrowserImageMarkdownReferences(message),
};
}

async function readLinqBrowserImage(
scope: AccessScope,
artifactId: string,
options: { readonly rootSessionId: string; readonly signal?: AbortSignal }
) {
const artifact = await readReadyBrowserImageArtifact(scope, artifactId, {
rootSessionId: options.rootSessionId,
});
if (
!artifact?.byteSize ||
!artifact.contentHash ||
!artifact.filename ||
!artifact.mediaType
)
return;
const blobAuth = env.BLOB_STORE_ID
? { storeId: env.BLOB_STORE_ID }
: env.BLOB_READ_WRITE_TOKEN
? { token: env.BLOB_READ_WRITE_TOKEN }
: undefined;
if (!blobAuth) return;
const result = await get(artifact.storagePathname, {
...blobAuth,
access: "private",
abortSignal: options.signal,
});
if (result?.statusCode !== 200) return;
if (
result.blob.size !== artifact.byteSize ||
result.blob.contentType !== artifact.mediaType
)
return;
const reader = result.stream.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > maximumBrowserImageBytes) return;
chunks.push(value);
}
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
if (createHash("sha256").update(bytes).digest("hex") !== artifact.contentHash)
return;
return {
bytes,
filename: artifact.filename,
id: artifact.id,
mediaType: artifact.mediaType,
};
}
32 changes: 32 additions & 0 deletions agent/lib/linq-browser-image-markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { isBrowserImageArtifactUrl } from "@/lib/browser-artifact";

const browserImageMarkdownPattern =
/!\[((?:\\.|[^\]])*)\]\((\/artifacts\/([^\s)]+))\)/giu;

export function extractBrowserImageMarkdownReferences(message: string) {
const references: {
readonly id: string;
readonly label: string;
readonly markdown: string;
readonly url: string;
}[] = [];
const seen = new Set<string>();

for (const match of message.matchAll(browserImageMarkdownPattern)) {
const [markdown, label, url, id] = match;
if (!markdown || !url || !id || seen.has(id)) continue;
if (!isBrowserImageArtifactUrl(url)) continue;
seen.add(id);
references.push({ id, label: label ?? "", markdown, url });
}

return references;
}

export function stripBrowserImageMarkdownReferences(message: string) {
return message
.replace(browserImageMarkdownPattern, "")
.replace(/[ \t]+\n/gu, "\n")
.replace(/\n{3,}/gu, "\n\n")
.trim();
}
6 changes: 3 additions & 3 deletions agent/subagents/worker/agent.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { defineAgent, defineDynamic } from "eve";
import { getGatewayModel } from "@/db/services/settings";
import { scopeFromPrincipal } from "@/lib/access-scope";
import { getModelSettings } from "@/lib/model-config";
import { taskCompletionSchema } from "@/lib/task-completion";
import { taskCompletionSchema } from "@/lib/worker-completion";

export default defineAgent({
description:
Expand All @@ -11,7 +11,7 @@ export default defineAgent({
"turn.started": async (_event, ctx) => {
const caller = ctx.session.auth.current ?? ctx.session.auth.initiator;
if (!caller) throw new Error("An authenticated user is required.");
return (await getModelSettings(scopeFromPrincipal(caller))).modelId;
return getGatewayModel(scopeFromPrincipal(caller));
},
},
}),
Expand Down
6 changes: 3 additions & 3 deletions agent/subagents/worker/hooks/trace-telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import {
completeBrowserTrace,
recordBrowserTraceEvents,
} from "@/db/services/browser-traces";
import { traceTimelineRows } from "@/agent/subagents/worker/lib/trace-timeline";
import { traceTimelineRows } from "@/agent/subagents/worker/lib/trace/timeline";
import { listWorkerBrowserSessions } from "@/db/services/browsers";
import type { AccessScope } from "@/lib/access-scope";
import { scopeFromPrincipal } from "@/lib/access-scope";
import { taskCompletionOutputSchema } from "@/lib/task-completion";
import { harvestBrowserTraceDomains } from "@/agent/subagents/worker/lib/trace-domains";
import { taskCompletionOutputSchema } from "@/lib/worker-completion";
import { harvestBrowserTraceDomains } from "@/agent/subagents/worker/lib/trace/domains";

export const traceTelemetryDependencies = {
beginBrowserTrace,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AutofillClaim } from "../vault-autofill-protocol";
import type { AutofillClaim } from "./protocol";

export const nativeLoginAutofillTokens = [
"username",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import Kernel from "@onkernel/sdk";
import { z } from "zod";
import { env } from "../../env";
import type { AutofillClaim } from "../vault-autofill-protocol";
import { env } from "@/lib/env";
import type { AutofillClaim } from "./protocol";
import {
classifyNativeLoginControl,
nativeLoginAutofillTokens,
nativeLoginControlInspectionExpression,
nativeLoginFillFunctionDeclaration,
selectNativeLoginFills,
type ClassifiedNativeLoginControl,
} from "./kernel-login-autofill";
} from "./login";

const targetListSchema = z.object({
targetInfos: z.array(
Expand Down
Loading