Skip to content
8 changes: 7 additions & 1 deletion ts/packages/agentRpc/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -642,9 +642,15 @@ export async function createAgentRpcClient(

const invokeCloseAgentContext = result.closeAgentContext;
result.closeAgentContext = async (context: SessionContext<ShimContext>) => {
// TODO: Clean up the associated options.
const result = await invokeCloseAgentContext?.(context);
contextMap.close(context);
// Clean up the options RPC channel once this agent context is closed.
// Options are agent-scoped (created once per initializeAgentContext call)
// so they can be released when the context is torn down.
if (optionsRpc !== undefined) {
channelProvider.deleteChannel(`options:${name}`);
optionsRpc = undefined;
}
return result;
};

Expand Down
1 change: 1 addition & 0 deletions ts/packages/agentSdk/src/agentInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export type AppAgentManifest = {
cachedActivities?: Record<string, ActivityCacheSpec>; // Key is activity name, default (if not specified) is false
// Registered flow programs: actionName → path to .flow.json (relative to manifest file)
flows?: Record<string, string>;
allowDynamicAgents?: boolean; // whether this agent can add/remove dynamic sub-agents at runtime, default is false
} & ActionManifest;

export type SchemaTypeNames = {
Expand Down
1 change: 1 addition & 0 deletions ts/packages/agents/browser/src/agent/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"defaultEnabled": true,
"description": "Agent that allows you control an existing browser window",
"localView": true,
"allowDynamicAgents": true,
"indexingServices": {
"website": {
"serviceScript": "./dist/agent/indexing/browserIndexingService.js",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1150,7 +1150,7 @@ export class AppAgentManager implements ActionConfigProvider {
record.name,
agentContext,
context,
record.name === "browser", // TODO: Make this not hard coded
record.manifest.allowDynamicAgents === true,
);

debug(`Session context created for ${record.name}`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,25 +185,23 @@ function clarifyRequestAction(
return result;
}

async function clarifyWithLookup(
action: ClarifyUnresolvedReference,
context: ActionContext<CommandHandlerContext>,
): Promise<ActionResult | undefined> {
const systemContext = context.sessionContext.agentContext;
const agents = systemContext.agents;
if (
!agents.isSchemaActive("dispatcher.lookup") ||
!agents.isActionActive("dispatcher.lookup")
) {
// lookup is disabled either for translation or action. Just ask the user.
return undefined;
}
// Cache the lookup clarify translator per systemContext since it is expensive
// to create and only depends on the agents + promptLogger for the session.
const lookupClarifyTranslatorCache = new WeakMap<
CommandHandlerContext,
ReturnType<typeof loadAgentJsonTranslator>
>();

function getLookupClarifyTranslator(systemContext: CommandHandlerContext) {
const cached = lookupClarifyTranslatorCache.get(systemContext);
if (cached !== undefined) {
return cached;
}
const agents = systemContext.agents;
const actionConfigs = [
agents.getActionConfig("dispatcher.lookup"),
agents.getActionConfig("dispatcher"),
];
// TODO: cache this?
const translator = loadAgentJsonTranslator(
actionConfigs,
[],
Expand All @@ -213,6 +211,25 @@ async function clarifyWithLookup(
undefined,
systemContext.promptLogger,
);
lookupClarifyTranslatorCache.set(systemContext, translator);
return translator;
}

async function clarifyWithLookup(
action: ClarifyUnresolvedReference,
context: ActionContext<CommandHandlerContext>,
): Promise<ActionResult | undefined> {
const systemContext = context.sessionContext.agentContext;
const agents = systemContext.agents;
if (
!agents.isSchemaActive("dispatcher.lookup") ||
!agents.isActionActive("dispatcher.lookup")
) {
// lookup is disabled either for translation or action. Just ask the user.
return undefined;
}

const translator = getLookupClarifyTranslator(systemContext);

const question = `What is ${action.parameters.reference}?`;
const result = await translator.translate(question);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,19 @@ function loadParsedActionSchema(
const parsedActionSchemaJSON = JSON.parse(
source,
) as ParsedActionSchemaJSON;
// TODO: validate the json
if (
typeof parsedActionSchemaJSON !== "object" ||
parsedActionSchemaJSON === null ||
typeof parsedActionSchemaJSON.version !== "number" ||
typeof parsedActionSchemaJSON.entry !== "object" ||
parsedActionSchemaJSON.entry === null ||
typeof parsedActionSchemaJSON.types !== "object" ||
parsedActionSchemaJSON.types === null
) {
throw new Error(
"Invalid action schema cache: malformed JSON structure",
);
}
const parsedActionSchema = fromJSONParsedActionSchema(
parsedActionSchemaJSON,
);
Expand Down
35 changes: 25 additions & 10 deletions ts/packages/dispatcher/dispatcher/src/translation/actionTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,17 @@ function getDefaultActionTemplate(
return template;
}

function toTemplateTypeObject(type: ActionParamObject) {
function toTemplateTypeObject(
type: ActionParamObject,
visited: ReadonlySet<string>,
) {
const templateType: TemplateFieldObject = {
type: "object",
fields: {},
};

for (const [key, field] of Object.entries(type.fields)) {
const type = toTemplateType(field.type);
const type = toTemplateType(field.type, visited);
if (type === undefined) {
// Skip undefined fields.
continue;
Expand All @@ -60,8 +63,11 @@ function toTemplateTypeObject(type: ActionParamObject) {
return templateType;
}

function toTemplateTypeArray(type: ActionParamArray) {
const elementType = toTemplateType(type.elementType);
function toTemplateTypeArray(
type: ActionParamArray,
visited: ReadonlySet<string>,
) {
const elementType = toTemplateType(type.elementType, visited);
if (elementType === undefined) {
// Skip undefined fields.
return undefined;
Expand All @@ -73,21 +79,30 @@ function toTemplateTypeArray(type: ActionParamArray) {
return templateType;
}

function toTemplateType(type: ActionParamType): TemplateType | undefined {
function toTemplateType(
type: ActionParamType,
visited: ReadonlySet<string> = new Set<string>(),
): TemplateType | undefined {
switch (type.type) {
case "type-union":
// TODO: smarter about type unions.
return toTemplateType(type.types[0]);
return toTemplateType(type.types[0], visited);
case "type-reference":
// TODO: need to handle circular references (or error on circular references)
if (type.definition === undefined) {
throw new Error(`Unresolved type reference: ${type.name}`);
}
return toTemplateType(type.definition.type);
if (visited.has(type.name)) {
// Circular reference — skip to avoid infinite recursion.
return undefined;
}
return toTemplateType(
type.definition.type,
new Set([...visited, type.name]),
);
case "object":
return toTemplateTypeObject(type);
return toTemplateTypeObject(type, visited);
case "array":
return toTemplateTypeArray(type);
return toTemplateTypeArray(type, visited);
case "undefined":
return undefined;
case "string":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,49 @@ export type AssistantSelection = {
action: string;
};

type TranslatorPartition = {
names: string[];
translator: {
translate: (request: string) => Promise<Result<AssistantSelection>>;
};
};

/**
* Run all translator partitions in parallel and return the first non-"unknown"
* result in partition order, or "unknown" if all partitions return "unknown".
*/
export async function selectFromPartitions(
partitions: TranslatorPartition[],
request: string,
): Promise<Result<AssistantSelection>> {
partitions.forEach(({ names }) =>
debugSwitchSearch(`Switch: searching ${names.join(", ")}`),
);
// Start all translations in parallel.
const promises = partitions.map(({ translator }) =>
translator.translate(request).catch(
(err): Result<AssistantSelection> => ({
success: false,
message: err instanceof Error ? err.message : String(err),
}),
),
);
// Await results in partition order; return as soon as outcome is decided.
for (const promise of promises) {
const result = await promise;
if (!result.success) {
return result;
}
if (result.data.assistant !== "unknown") {
return result;
}
}
return success({
assistant: "unknown",
action: "unknown",
});
}

// GPT-4 has 8192 token window, with an estimated 4 chars per token, so use only 3 times to leave room for output.
const assistantSelectionLimit = 8192 * 3;

Expand Down Expand Up @@ -179,24 +222,7 @@ export function loadAssistantSelectionJsonTranslator(
});

return {
translate: async (
request: string,
): Promise<Result<AssistantSelection>> => {
for (const { names, translator } of translators) {
// TODO: we can parallelize this
debugSwitchSearch(`Switch: searching ${names.join(", ")}`);
const result = await translator.translate(request);
if (!result.success) {
return result;
}
if (result.data.assistant !== "unknown") {
return result;
}
}
return success({
assistant: "unknown",
action: "unknown",
});
},
translate: (request: string) =>
selectFromPartitions(translators, request),
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,80 @@ import { ActionSchemaFileCache } from "../src/translation/actionSchemaFileCache.
import { ActionConfig } from "../src/translation/actionConfig.js";
import { SchemaContent } from "@typeagent/agent-sdk";

function makePasActionConfig(content: string): ActionConfig {
const schemaFile: SchemaContent = {
format: "pas",
content,
config: undefined,
};
return {
emojiChar: "🔧",
cachedActivities: undefined,
schemaDefaultEnabled: true,
actionDefaultEnabled: true,
transient: false,
schemaName: "testSchema",
schemaFilePath: undefined,
originalSchemaFilePath: undefined,
description: "test",
schemaType: "TestAction",
schemaFile,
grammarFile: undefined,
} as unknown as ActionConfig;
}

describe("ActionSchemaFileCache", () => {
describe("loadParsedActionSchema JSON validation", () => {
it("throws for JSON missing version field", () => {
const cache = new ActionSchemaFileCache();
const content = JSON.stringify({ entry: {}, types: {} });
expect(() =>
cache.getActionSchemaFile(makePasActionConfig(content)),
).toThrow("Failed to load parsed action schema 'testSchema'");
});

it("throws for JSON with non-number version", () => {
const cache = new ActionSchemaFileCache();
const content = JSON.stringify({
version: "1",
entry: {},
types: {},
});
expect(() =>
cache.getActionSchemaFile(makePasActionConfig(content)),
).toThrow("malformed JSON structure");
});

it("throws for JSON missing entry field", () => {
const cache = new ActionSchemaFileCache();
const content = JSON.stringify({ version: 1, types: {} });
expect(() =>
cache.getActionSchemaFile(makePasActionConfig(content)),
).toThrow("malformed JSON structure");
});

it("throws for JSON missing types field", () => {
const cache = new ActionSchemaFileCache();
const content = JSON.stringify({ version: 1, entry: {} });
expect(() =>
cache.getActionSchemaFile(makePasActionConfig(content)),
).toThrow("malformed JSON structure");
});

it("passes structure validation for valid JSON (fails on type name mismatch)", () => {
const cache = new ActionSchemaFileCache();
// Valid structure but wrong version — will fail at fromJSONParsedActionSchema
const content = JSON.stringify({
version: 99,
entry: {},
types: {},
});
expect(() =>
cache.getActionSchemaFile(makePasActionConfig(content)),
).toThrow("Unsupported ParsedActionSchema version");
});
});

describe("getSchemaSource preserves config", () => {
it("should include config in hash when schema has a config", () => {
const schemaContentWithConfig: SchemaContent = {
Expand Down
Loading
Loading