Skip to content
Closed
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ web runtimes.

## 🚀 Installation

> **Prerequisite:** ADK for TypeScript requires a current Node.js LTS release.

```bash
npm install @google/adk
npm install -D @google/adk-devtools
Expand Down
50 changes: 45 additions & 5 deletions core/src/agents/routed_agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,10 @@ export interface RoutedAgentConfig extends BaseAgentConfig {
* Routing is strictly limited to the agents passed in the config.
*
* @remarks
* The inherited {@link BaseAgent.clone} does not support `RoutedAgent`: the
* constructor derives its routing targets from `config.agents` (not
* `subAgents`), so a rebuilt clone re-reads the already-parented originals and
* throws. Cloning a `RoutedAgent` is tracked as a follow-up to
* google/adk-js#534.
* Cloning is supported: {@link RoutedAgent.clone} deep-clones the routing
* targets from `config.agents` and re-parents the fresh copies onto the clone,
* so the clone is a detached root that routes identically to the original while
* leaving the original agent tree untouched.
*/
@experimental
export class RoutedAgent extends BaseAgent<RoutedAgentConfig> {
Expand Down Expand Up @@ -94,6 +93,30 @@ export class RoutedAgent extends BaseAgent<RoutedAgentConfig> {
this.router = config.router;
}

/**
* Creates a detached copy of this routed agent.
*
* The inherited {@link BaseAgent.clone} only deep-clones `subAgents`, but a
* `RoutedAgent` derives its routing targets from `config.agents`. Rebuilding
* through the base clone alone would re-read the already-parented original
* agents and throw "already has a parent agent". This override deep-clones the
* routing targets (unless the caller overrides `agents`) so the rebuilt
* constructor re-parents fresh, detached copies. The array-vs-record shape
* and, for records, the keys are preserved so the router keeps selecting the
* same targets.
*
* @param overrides Config fields to override on the clone. Overriding
* `parentAgent` is rejected by the base implementation.
* @returns A new detached `RoutedAgent` of the same concrete class.
*/
override clone(overrides?: Partial<RoutedAgentConfig>): this {
const nextOverrides: Partial<RoutedAgentConfig> = {...overrides};
if (!('agents' in nextOverrides)) {
nextOverrides.agents = cloneRoutingTargets(this.config.agents);
}
return super.clone(nextOverrides);
}

/**
* Runs the selected agent via text-based conversation.
*/
Expand All @@ -116,3 +139,20 @@ export class RoutedAgent extends BaseAgent<RoutedAgentConfig> {
);
}
}

/**
* Deep-clones a RoutedAgent's routing targets, preserving whether they were
* supplied as an array or a keyed record so the rebuilt constructor derives the
* same routing map. Each clone is detached (no parent), so the constructor can
* re-parent it without conflict.
*/
function cloneRoutingTargets(
agents: Readonly<Record<string, BaseAgent>> | BaseAgent[],
): Readonly<Record<string, BaseAgent>> | BaseAgent[] {
if (Array.isArray(agents)) {
return agents.map((agent) => agent.clone());
}
return Object.fromEntries(
Object.entries(agents).map(([key, agent]) => [key, agent.clone()]),
);
}
7 changes: 7 additions & 0 deletions core/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,11 @@ export type {
export {BaseToolset, isBaseToolset} from './tools/base_toolset.js';
export type {ToolPredicate} from './tools/base_toolset.js';
export {ConsolidateContextTool} from './tools/consolidate_context_tool.js';
export {
ENTERPRISE_WEB_SEARCH,
EnterpriseWebSearchTool,
} from './tools/enterprise_web_search_tool.js';
export {ExampleTool} from './tools/example_tool.js';
export {EXIT_LOOP, ExitLoopTool} from './tools/exit_loop_tool.js';
export {FunctionTool, isFunctionTool} from './tools/function_tool.js';
export type {
Expand All @@ -254,6 +259,8 @@ export {
LoadArtifactsTool,
} from './tools/load_artifacts_tool.js';
export {LOAD_MEMORY, LoadMemoryTool} from './tools/load_memory_tool.js';
export {LOAD_WEB_PAGE, loadWebPage} from './tools/load_web_page.js';
export type {LoadWebPageOptions} from './tools/load_web_page.js';
export {LongRunningFunctionTool} from './tools/long_running_tool.js';
export {
PRELOAD_MEMORY,
Expand Down
1 change: 1 addition & 0 deletions core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export {RunSkillScriptTool} from './tools/skill/run_skill_script_tool.js';
export * from './integrations/agent_registry/agent_registry.js';
export * from './telemetry/google_cloud.js';
export * from './telemetry/setup.js';
export * from './tools/mcp/load_mcp_resource_tool.js';
export * from './tools/mcp/mcp_session_manager.js';
export * from './tools/mcp/mcp_tool.js';
export * from './tools/mcp/mcp_toolset.js';
92 changes: 83 additions & 9 deletions core/src/skills/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,14 +104,14 @@ async function loadDir(
}

/**
* Parses SKILL.md from a raw content string, extracting the YAML frontmatter and the body.
* Splits SKILL.md into its raw, unvalidated YAML frontmatter mapping and its body.
*
* @param content - The raw content of the SKILL.md file.
* @returns An object containing the parsed frontmatter and the remaining markdown body.
* @returns An object containing the raw frontmatter mapping and the remaining markdown body.
* @throws {Error} If the content is not properly formatted with YAML frontmatter.
*/
export function parseSkillMdContent(content: string): {
frontmatter: Frontmatter;
function parseFrontmatterYaml(content: string): {
raw: Record<string, unknown>;
body: string;
} {
if (!content.startsWith('---')) {
Expand All @@ -132,17 +132,73 @@ export function parseSkillMdContent(content: string): {

try {
const parsed = yaml.load(frontmatterStr);
if (typeof parsed !== 'object' || parsed === null) {
if (
typeof parsed !== 'object' ||
parsed === null ||
Array.isArray(parsed)
) {
throw new Error('SKILL.md frontmatter must be a YAML mapping');
}
const frontmatter = FrontmatterSchema.parse(parsed);
return {raw: parsed as Record<string, unknown>, body};
} catch (e: unknown) {
throw new Error(`Invalid YAML in frontmatter: ${(e as Error).message}`);
}
}

return {frontmatter, body};
/**
* Validates a raw frontmatter mapping against {@link FrontmatterSchema}.
*
* @param raw - The raw frontmatter mapping produced by {@link parseFrontmatterYaml}.
* @returns The validated and normalized frontmatter.
* @throws {Error} If the mapping does not satisfy the schema.
*/
function validateFrontmatter(raw: Record<string, unknown>): Frontmatter {
try {
return FrontmatterSchema.parse(raw);
} catch (e: unknown) {
throw new Error(`Invalid YAML in frontmatter: ${(e as Error).message}`);
}
}

/**
* Parses SKILL.md from a raw content string, extracting the YAML frontmatter and the body.
*
* @param content - The raw content of the SKILL.md file.
* @returns An object containing the parsed frontmatter and the remaining markdown body.
* @throws {Error} If the content is not properly formatted with YAML frontmatter.
*/
export function parseSkillMdContent(content: string): {
frontmatter: Frontmatter;
body: string;
} {
const {raw, body} = parseFrontmatterYaml(content);
return {frontmatter: validateFrontmatter(raw), body};
}

/**
* Checks whether a zip member name attempts to escape the extraction root (zip
* slip), mirroring adk-python's `_load_skill_from_zip_bytes`. This is a
* name-shape check on archive metadata, not a sandbox: it says nothing about
* symlinks.
*/
function isDangerousZipEntryName(entryName: string): boolean {
return (
entryName.startsWith('/') ||
entryName.startsWith('../') ||
entryName.includes('/../')
);
}

/**
* Checks that a skill name is a single bare path segment, mirroring
* adk-python's `pathlib.Path(name).name != name`. '.' and '..' are rejected
* explicitly because `path.basename('..') === '..'` whereas
* `pathlib.Path('..').name === ''`.
*/
function isBareSkillName(name: string): boolean {
return name !== '.' && name !== '..' && path.basename(name) === name;
}

/**
* Load a complete skill, including its instructions and resources, from a directory.
*
Expand Down Expand Up @@ -332,13 +388,24 @@ export async function loadAllSkillsInDir(
/**
* Loads a complete skill directly from in-memory zip file buffer.
*
* The whole archive is rejected if any member name escapes the extraction
* root, and the skill name must be a bare path segment.
*
* @param zipBuffer - The raw Buffer of the zip file containing the skill.
* @returns A Skill object with all components loaded.
* @throws {Error} If a member name is a traversal path, if SKILL.md is missing,
* or if the skill name is missing or is not a bare path segment.
*/
export function loadSkillFromZipBuffer(zipBuffer: Buffer): Skill {
const zip = new AdmZip(zipBuffer);
const entries = zip.getEntries();

for (const entry of entries) {
if (isDangerousZipEntryName(entry.entryName)) {
throw new Error(`Dangerous zip entry ignored: ${entry.entryName}`);
}
}

let skillMdContent = '';
for (const entry of entries) {
if (entry.isDirectory) continue;
Expand All @@ -352,8 +419,15 @@ export function loadSkillFromZipBuffer(zipBuffer: Buffer): Skill {
throw new Error('SKILL.md not found in zipped filesystem.');
}

const {frontmatter: parsed, body} = parseSkillMdContent(skillMdContent);
const frontmatter = FrontmatterSchema.parse(parsed);
const {raw, body} = parseFrontmatterYaml(skillMdContent);
const skillName = raw['name'];
if (!skillName) {
throw new Error("SKILL.md frontmatter must contain 'name'");
}
if (typeof skillName !== 'string' || !isBareSkillName(skillName)) {
throw new Error(`Invalid skill name in SKILL.md: ${String(skillName)}`);
}
const frontmatter = validateFrontmatter(raw);

const references: Record<string, string | Buffer> = {};
const assets: Record<string, string | Buffer> = {};
Expand Down
84 changes: 84 additions & 0 deletions core/src/tools/enterprise_web_search_tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {GenerateContentConfig} from '@google/genai';

import {LlmRequest} from '../models/llm_request.js';
import {
isGemini1Model,
isGeminiModel,
isGeminiModelIdCheckDisabled,
} from '../utils/model_name.js';

import {BaseTool, ToolProcessLlmRequest} from './base_tool.js';

/**
* Appends the Enterprise Web Search built-in tool to the LLM request when the
* target model supports it.
*
* NOTE: This is NOT Vertex AI Search (formerly "Enterprise Search"). See
* https://cloud.google.com/vertex-ai/generative-ai/docs/grounding/web-grounding-enterprise
*/
export function applyEnterpriseWebSearch(llmRequest: LlmRequest): void {
if (!llmRequest.model) {
return;
}

const modelCheckDisabled = isGeminiModelIdCheckDisabled();
llmRequest.config = llmRequest.config || ({} as GenerateContentConfig);
llmRequest.config.tools = llmRequest.config.tools || [];

if (isGeminiModel(llmRequest.model) || modelCheckDisabled) {
if (
isGemini1Model(llmRequest.model) &&
llmRequest.config.tools.length > 0
) {
throw new Error(
'Enterprise Web Search tool cannot be used with other tools in Gemini 1.x.',
);
}

llmRequest.config.tools.push({enterpriseWebSearch: {}});

return;
}

throw new Error(
`Enterprise Web Search tool is not supported for model ${llmRequest.model}`,
);
}

/**
* A Gemini 2+ built-in tool that grounds responses on public web data via
* Vertex AI Search with Enterprise (Sec4) compliance.
*
* This tool operates internally within the model and does not require or
* perform local code execution.
*/
export class EnterpriseWebSearchTool extends BaseTool {
constructor() {
super({
name: 'enterprise_web_search',
description: 'Enterprise Web Search Tool',
});
}

runAsync(): Promise<unknown> {
// This is a built-in tool on server side, it's triggered by setting the
// corresponding request parameters.
return Promise.resolve();
}

override async processLlmRequest({
llmRequest,
}: ToolProcessLlmRequest): Promise<void> {
applyEnterpriseWebSearch(llmRequest);
}
}

/**
* A global instance of {@link EnterpriseWebSearchTool}.
*/
export const ENTERPRISE_WEB_SEARCH = new EnterpriseWebSearchTool();
52 changes: 52 additions & 0 deletions core/src/tools/example_tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {BaseExampleProvider} from '../examples/base_example_provider.js';
import {Example} from '../examples/example.js';
import {buildExampleSi} from '../examples/example_util.js';
import {appendInstructions} from '../models/llm_request.js';

import {
BaseTool,
RunAsyncToolRequest,
ToolProcessLlmRequest,
} from './base_tool.js';

/**
* A tool that adds (few-shot) examples to the LLM request.
*
* This tool is executed for each LLM request and is never called by the model;
* it only mutates the outgoing request by appending few-shot instructions built
* from the latest user query.
*/
export class ExampleTool extends BaseTool {
constructor(readonly examples: Example[] | BaseExampleProvider) {
super({
// Name and description are not used because this tool only changes
// llmRequest.
name: 'example_tool',
description: 'example tool',
});
}

override async runAsync(_request: RunAsyncToolRequest): Promise<unknown> {
// Should not be called by model because it's not declared in LLM tools list.
throw new Error('ExampleTool should not be called by model');
}

override async processLlmRequest({
toolContext,
llmRequest,
}: ToolProcessLlmRequest): Promise<void> {
const parts = toolContext.userContent?.parts;
if (!parts || !parts[0]?.text) {
return;
}
appendInstructions(llmRequest, [
buildExampleSi(this.examples, parts[0].text, llmRequest.model),
]);
}
}
Loading
Loading