donobu
Version:
Create browser automations with an LLM agent and replay them as Playwright scripts.
295 lines • 13.3 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.PageAi = exports.PageAiRunner = void 0;
const v4_1 = require("zod/v4");
const GptClient_1 = require("../../clients/GptClient");
const PageAiException_1 = require("../../exceptions/PageAiException");
const DonobuFlow_1 = require("../../managers/DonobuFlow");
const DonobuFlowsManager_1 = require("../../managers/DonobuFlowsManager");
const ToolManager_1 = require("../../managers/ToolManager");
const WebTargetInspector_1 = require("../../managers/WebTargetInspector");
const ControlPanel_1 = require("../../models/ControlPanel");
const Logger_1 = require("../../utils/Logger");
const originalGotoRegistry_1 = require("../page/originalGotoRegistry");
const cache_1 = require("./cache/cache");
const cacheEntryBuilder_1 = require("./cache/cacheEntryBuilder");
const cacheLocator_1 = require("./cache/cacheLocator");
/**
* Prepares and executes a Donobu autonomous flow.
*
* Responsibilities:
* - Gather environment data based on allowed env vars.
* - Seed `DonobuFlow` with deterministic metadata (run mode, allowed tools, etc.).
* - Run the flow, updating persisted metadata regardless of outcome.
*
* The runner does **not** perform cache lookups or updates. That is deferred to
* the higher-level {@link PageAi} facade so that the runner can stay focused on
* “how do we execute a flow safely?” rather than “should we execute a flow at
* all?”.
*/
class PageAiRunner {
constructor(donobu, gptClient) {
this.donobu = donobu;
this.gptClient = gptClient;
}
/**
* Executes a flow using the provided configuration.
*
* @param config Runtime information prepared by the higher-level facade.
* @returns Parsed result (if a schema was provided) along with the executed flow.
* @throws PageAiException when the underlying flow fails.
*/
async run(config) {
const envData = await this.donobu.envDataManager.getByNames(config.envVarNames);
if (config.envVals) {
for (const [k, v] of Object.entries(config.envVals)) {
if (v === undefined) {
delete envData[k];
}
else {
envData[k] = v;
}
}
}
const originalFlowMetadata = {
...config.page._dnb.donobuFlowMetadata,
};
const augmentedMetadata = {
...originalFlowMetadata,
envVars: config.envVarNames,
runMode: config.runMode,
overallObjective: config.instruction,
resultJsonSchema: config.jsonSchema,
result: null,
maxToolCalls: config.maxToolCalls,
state: 'UNSTARTED',
allowedTools: config.allowedTools,
};
const toolManager = await this.buildToolManager(config.allowedTools);
const persistence = await this.donobu.flowsPersistenceRegistry.get();
const pageWithOriginalGoto = Object.create(config.page);
pageWithOriginalGoto.goto = (0, originalGotoRegistry_1.getOriginalGoto)(config.page);
const donobuFlow = new DonobuFlow_1.DonobuFlow(this.donobu.flowsManager, envData, persistence, this.gptClient, toolManager, config.page._dnb.interactionVisualizer, [], [], [], new WebTargetInspector_1.WebTargetInspector({ type: 'web', current: pageWithOriginalGoto }, config.page.context(), config.page._dnb.interactionVisualizer), augmentedMetadata, config.page._dnb.controlPanelFactory
? await config.page._dnb.controlPanelFactory(augmentedMetadata.id)
: new ControlPanel_1.NoOpControlPanel());
const flowContext = { flowId: augmentedMetadata.id };
const rawResult = await Logger_1.loggingContext.run(flowContext, async () => {
Logger_1.loggingContext.enterWith(flowContext);
return donobuFlow.run();
});
const updatedMetadata = {
...originalFlowMetadata,
inputTokensUsed: donobuFlow.metadata.inputTokensUsed,
completionTokensUsed: donobuFlow.metadata.completionTokensUsed,
};
try {
await persistence.setFlowMetadata(updatedMetadata);
}
catch (error) {
Logger_1.appLogger.warn(`Failed to update metadata for flow: ${updatedMetadata.id}`, error);
}
if (donobuFlow.metadata.state !== 'SUCCESS') {
throw new PageAiException_1.PageAiException(config.instruction, donobuFlow.metadata);
}
const parsedResult = config.schema
? (0, GptClient_1.parseOrLogAndThrow)(rawResult, config.schema)
: undefined;
return {
donobuFlow,
parsedResult: parsedResult,
};
}
/**
* Builds a `ToolManager` constrained to the tools the flow is allowed to use.
* We always allow the objective bookkeeping tools even if the caller supplied
* a narrower list.
*/
async buildToolManager(allowedToolsByName) {
const registry = this.donobu.toolRegistry;
if (allowedToolsByName.length === 0) {
return new ToolManager_1.ToolManager(registry.defaultTools());
}
const allTools = registry.allTools();
const minimalToolsByName = registry.minimalTools().map((t) => t.name);
const semifinalToolNames = new Set([
...allowedToolsByName,
...minimalToolsByName,
]);
const finalTools = allTools.filter((tool) => semifinalToolNames.has(tool.name));
return new ToolManager_1.ToolManager(finalTools);
}
}
exports.PageAiRunner = PageAiRunner;
/**
* High-level API used by Playwright tests. It resolves caching policy,
* delegates execution to {@link PageAiRunner}, and records successful runs back
* into the configured cache.
*/
class PageAi {
/**
* @param donobu Donobu stack providing flow managers and shared services.
* @param gptClient GPT client used for autonomous reasoning.
* @param cache Pluggable cache implementation the facade should consult.
*/
constructor(donobu, gptClient, cache) {
this.cache = cache;
this.donobu = donobu;
this.runner = new PageAiRunner(donobu, gptClient);
}
/**
* Builds a `PageAi` instance configured to use the file-backed cache stored
* alongside the current test file. This mirrors the behaviour relied upon by
* the Playwright fixture.
*/
static withFileCache(donobu, gptClient, cacheFilepath) {
return new PageAi(donobu, gptClient, new cache_1.FilePageAiCache(cacheFilepath));
}
static withInMemoryCache(donobu, gptClient) {
return new PageAi(donobu, gptClient, new cache_1.InMemoryPageAiCache());
}
async ai(page, instruction, options) {
const startedAt = Date.now();
let cacheHit = false;
let cacheStored = false;
let thrownError = undefined;
try {
const descriptor = this.buildDescriptor(page, instruction, options);
// Keep the per-page metadata in sync with the env vars needed for this invocation so cached
// replays can resolve interpolations via runTool.
page._dnb.donobuFlowMetadata.envVars = descriptor.envVarNames;
const cachedEntry = descriptor.useCache
? await this.cache.get(descriptor.key)
: null;
cacheHit = !!cachedEntry;
if (cachedEntry) {
page._dnb.donobuFlowMetadata.runMode = 'DETERMINISTIC';
page._dnb.envVals = descriptor.envVals;
try {
await cachedEntry.run({ page });
}
finally {
page._dnb.envVals = undefined;
}
return this.synthesizeResultFromMetadata(page, instruction, descriptor, options);
}
else {
const runResult = await this.runner.run({
page,
instruction,
schema: descriptor.schema,
jsonSchema: descriptor.jsonSchema,
allowedTools: descriptor.allowedTools,
maxToolCalls: descriptor.maxToolCalls,
envVarNames: descriptor.envVarNames,
envVals: descriptor.envVals,
runMode: 'AUTONOMOUS',
gptClient: options?.gptClient,
});
if (descriptor.useCache) {
const preparedToolCalls = await (0, DonobuFlowsManager_1.prepareToolCallsForRerun)(
// Only retain successfully run tool calls, otherwise when a cache file
// with some bad calls in it runs in the future, the test will blow up
// when the first bad tool call is read.
runResult.donobuFlow.invokedToolCalls.filter((tc) => {
return tc.outcome.isSuccessful;
}), {
areElementIdsVolatile: options?.volatileElementIds,
disableSelectorFailover: options?.noSelectorFailover,
}, this.donobu.toolRegistry);
const cacheEntry = cacheEntryBuilder_1.PageAiCacheEntryBuilder.fromMetadata(descriptor.key.pageUrl, runResult.donobuFlow.metadata, preparedToolCalls);
await this.cache.put(cacheEntry);
cacheStored = true;
}
return runResult.parsedResult;
}
}
catch (e) {
thrownError = e;
throw e;
}
finally {
page._dnb.aiInvocations.push({
kind: 'act',
description: instruction,
startedAt,
endedAt: Date.now(),
cacheHit,
cacheStored,
passed: thrownError === undefined,
error: thrownError !== undefined
? { message: thrownError?.message }
: undefined,
});
}
}
/**
* Invalidates cache entries matching the provided invocation parameters.
*
* Returns `true` when a matching record existed and was removed.
*/
async invalidate(page, instruction, options) {
const descriptor = this.buildDescriptor(page, instruction, options);
return this.cache.delete(descriptor.key);
}
async synthesizeResultFromMetadata(page, instruction, descriptor, options) {
if (!descriptor.schema) {
return undefined;
}
const gptClient = options?.gptClient ?? page._dnb.gptClient;
if (!gptClient) {
throw new Error('Cannot synthesize structured result without a configured GPT client.');
}
const toolCallHistory = await page._dnb.persistence.getToolCalls(page._dnb.donobuFlowMetadata.id);
const screenshot = await page.screenshot({ type: 'jpeg' });
const structuredOutput = await (0, DonobuFlow_1.extractFromPage)(instruction, descriptor.schema, screenshot, toolCallHistory, gptClient);
const parsedResult = structuredOutput.output;
page._dnb.donobuFlowMetadata.resultJsonSchema = descriptor.jsonSchema;
page._dnb.donobuFlowMetadata.result = JSON.parse(JSON.stringify(structuredOutput.output));
await page._dnb.persistence.setFlowMetadata(page._dnb.donobuFlowMetadata);
return parsedResult;
}
/**
* Normalises user-provided options into a single structure that can be reused
* across cache operations and flow execution.
*/
buildDescriptor(page, instruction, options) {
const useCache = options?.cache !== undefined ? options.cache : true;
const schema = (options?.schema ?? undefined);
const jsonSchema = schema ? v4_1.z.toJSONSchema(schema) : null;
const maxToolCalls = options?.maxToolCalls ?? DonobuFlowsManager_1.DonobuFlowsManager.DEFAULT_MAX_TOOL_CALLS;
const allowedTools = options?.allowedTools ?? [];
const envVals = options?.envVals;
const envVarNames = (0, DonobuFlowsManager_1.distillAllowedEnvVariableNames)(instruction, [
...(options?.envVars ?? []),
...Object.keys(envVals ?? {}),
]);
return {
key: this.buildCacheKey(page, instruction, jsonSchema, allowedTools, maxToolCalls, envVarNames),
schema,
jsonSchema: jsonSchema,
allowedTools,
maxToolCalls,
envVarNames,
envVals,
useCache,
};
}
/**
* Computes the cache key that uniquely identifies a `page.ai.act(...)` invocation.
* Keep this logic in sync with any external cache generators (e.g. the code
* generator) so that hits and invalidations behave the same everywhere.
*/
buildCacheKey(page, instruction, schema, allowedTools, maxToolCalls, envVars) {
return {
deviceType: 'web',
pageUrl: (0, cacheLocator_1.extractCacheKeyHostname)(page.url()),
instruction,
schema,
allowedTools,
maxToolCalls,
envVars,
};
}
}
exports.PageAi = PageAi;
//# sourceMappingURL=PageAi.js.map