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
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Agent Stack

## GitHub Operations

Use `gh` command for GitHub operations.

Repo: `i-am-bee/agentstack`
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Copyright 2025 © BeeAI a Series of LF Projects, LLC
* SPDX-License-Identifier: Apache-2.0
*/

export function createAuthenticatedFetch(token: string, baseFetch?: typeof fetch): typeof fetch {
const fetchImpl = baseFetch ?? (typeof globalThis.fetch !== 'undefined' ? globalThis.fetch : undefined);

if (!fetchImpl) {
throw new Error(
'fetch is not available. In Node.js < 18 or environments without global fetch, ' +
'provide a fetch implementation via the baseFetch parameter.',
);
}

return async (input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers);
headers.set('Authorization', `Bearer ${token}`);
return fetchImpl(input, { ...init, headers });
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ export interface Fulfillments {
secrets: (demand: SecretDemands) => Promise<SecretFulfillments>;
form: (demand: FormDemands) => Promise<FormFulfillments>;
oauthRedirectUri: () => string | null;
/**
* @deprecated - keeping this for backwards compatibility, context token is now passed via A2A client headers
*/
getContextToken: () => ContextToken;
}

Expand Down
18 changes: 12 additions & 6 deletions apps/agentstack-sdk-ts/src/client/api/build-api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@
import type { z } from 'zod';

import type { ContextPermissionsGrant, GlobalPermissionsGrant, ModelCapability } from './types';
import { contextSchema, contextTokenSchema, listConnectorsResponseSchema, modelProviderMatchSchema } from './types';
import {
contextPermissionsGrantSchema,
contextSchema,
contextTokenSchema,
globalPermissionsGrantSchema,
listConnectorsResponseSchema,
modelProviderMatchSchema,
} from './types';

export interface MatchProvidersParams {
suggestedModels: string[] | null;
Expand Down Expand Up @@ -79,16 +86,15 @@ export const buildApiClient = (
await callApi('POST', '/api/v1/contexts', { metadata: {}, provider_id: providerId }, contextSchema);

const createContextToken = async ({ contextId, globalPermissions, contextPermissions }: CreateContextTokenParams) => {
if (!globalPermissions.a2a_proxy?.includes('*') && globalPermissions.a2a_proxy?.length === 0) {
throw new Error("Invalid audience: You must specify providers or use '*' in globalPermissions.a2a_proxy.");
}
const validatedGlobalPerms = globalPermissionsGrantSchema.parse(globalPermissions);
const validatedContextPerms = contextPermissionsGrantSchema.parse(contextPermissions);

const token = await callApi(
'POST',
`/api/v1/contexts/${contextId}/token`,
{
grant_global_permissions: globalPermissions,
grant_context_permissions: contextPermissions,
grant_global_permissions: validatedGlobalPerms,
grant_context_permissions: validatedContextPerms,
},
contextTokenSchema,
);
Expand Down
64 changes: 44 additions & 20 deletions apps/agentstack-sdk-ts/src/client/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,26 +58,50 @@ export const contextPermissionsGrantSchema = z.object({

export type ContextPermissionsGrant = z.infer<typeof contextPermissionsGrantSchema>;

export const globalPermissionsGrantSchema = contextPermissionsGrantSchema.extend({
feedback: z.array(z.literal('write')).optional(),

llm: z.array(z.union([z.literal('*'), resourceIdPermissionSchema])).optional(),
embeddings: z.array(z.union([z.literal('*'), resourceIdPermissionSchema])).optional(),
model_providers: z.array(z.literal(['read', 'write', '*'])).optional(),

a2a_proxy: z.array(z.union([z.literal('*'), z.string()])).optional(),

providers: z.array(z.literal(['read', 'write', '*'])).optional(),
provider_variables: z.array(z.literal(['read', 'write', '*'])).optional(),

contexts: z.array(z.literal(['read', 'write', '*'])).optional(),

mcp_providers: z.array(z.literal(['read', 'write', '*'])).optional(),
mcp_tools: z.array(z.literal(['read', '*'])).optional(),
mcp_proxy: z.array(z.literal('*')).optional(),

connectors: z.array(z.literal(['read', 'write', 'proxy', '*'])).optional(),
});
export const globalPermissionsGrantSchema = contextPermissionsGrantSchema
.extend({
feedback: z.array(z.literal('write')).optional(),

llm: z.array(z.union([z.literal('*'), resourceIdPermissionSchema])).optional(),
embeddings: z.array(z.union([z.literal('*'), resourceIdPermissionSchema])).optional(),
model_providers: z.array(z.literal(['read', 'write', '*'])).optional(),

a2a_proxy: z.array(z.union([z.literal('*'), z.string()])).optional(),

providers: z.array(z.literal(['read', 'write', '*'])).optional(),
provider_variables: z.array(z.literal(['read', 'write', '*'])).optional(),

contexts: z.array(z.literal(['read', 'write', '*'])).optional(),

mcp_providers: z.array(z.literal(['read', 'write', '*'])).optional(),
mcp_tools: z.array(z.literal(['read', '*'])).optional(),
mcp_proxy: z.array(z.literal('*')).optional(),

connectors: z.array(z.literal(['read', 'write', 'proxy', '*'])).optional(),
})
.superRefine((val, ctx) => {
if (!val.a2a_proxy) return;

if (val.a2a_proxy.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'a2a_proxy cannot be empty array',
path: ['a2a_proxy'],
});
return;
}

const hasWildcard = val.a2a_proxy.includes('*');
const hasOthers = val.a2a_proxy.some((v) => v !== '*');

if (hasWildcard && hasOthers) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "a2a_proxy cannot mix '*' with specific providers",
path: ['a2a_proxy'],
});
}
});

export type GlobalPermissionsGrant = z.infer<typeof globalPermissionsGrantSchema>;

Expand Down
1 change: 1 addition & 0 deletions apps/agentstack-sdk-ts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

export { createAuthenticatedFetch } from './client/a2a/create-authenticated-fetch';
export * from './client/a2a/extensions/common/form';
export * from './client/a2a/extensions/fulfillment-resolvers/build-llm-extension-fulfillment-resolver';
export { type Fulfillments, handleAgentCard } from './client/a2a/extensions/handle-agent-card';
Expand Down
9 changes: 2 additions & 7 deletions apps/agentstack-ui/src/api/a2a/agent-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,15 @@
*/

import { A2AClient } from '@a2a-js/sdk/client';
import { createAuthenticatedFetch } from 'agentstack-sdk';

import { UnauthenticatedError } from '#api/errors.ts';
import { getBaseUrl } from '#utils/api/getBaseUrl.ts';

export async function getAgentClient(providerId: string, token?: string) {
const agentCardUrl = `${getBaseUrl()}/api/v1/a2a/${providerId}/.well-known/agent-card.json`;

const fetchImpl = token
? async (input: RequestInfo, init?: RequestInit) => {
const headers = new Headers(init?.headers);
headers.set('Authorization', `Bearer ${token}`);
return clientFetch(input, { ...init, headers });
}
: clientFetch;
const fetchImpl = token ? createAuthenticatedFetch(token, clientFetch) : clientFetch;
return await A2AClient.fromCardUrl(agentCardUrl, { fetchImpl });
}

Expand Down
12 changes: 8 additions & 4 deletions apps/agentstack-ui/src/app/api/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ type RouteContext = {
}>;
};

const isA2AEndpoint = (path: string[]) => path[0] === 'v1' && path[1] === 'a2a';

async function handler(request: NextRequest, context: RouteContext) {
const { isAuthEnabled } = runtimeConfig;
const { method, headers, body, nextUrl } = request;
Expand All @@ -31,10 +33,12 @@ async function handler(request: NextRequest, context: RouteContext) {
targetUrl += '/';
}
targetUrl += search;
if (request.url.includes('/api/v1/a2a')) {
console.log(path, headers);
}
if (isAuthEnabled && !(path[0] == 'v1' && path[1] == 'a2a')) {
if (isAuthEnabled && !isA2AEndpoint(path)) {
if (isA2AEndpoint(path)) {
// Skip JWT auth for A2A endpoints - they use context tokens passed via A2A client
return;
}

const token = await ensureToken(request);

if (!token?.accessToken) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ export async function matchProviders(matchProvidersParams: MatchProvidersParams)
}

export async function createContextToken(createContextTokenParams: CreateContextTokenParams) {
console.log(createContextTokenParams);
const result = await agentstackClient.createContextToken(createContextTokenParams);
return result.token;
}
Expand Down
2 changes: 2 additions & 0 deletions apps/agentstack-ui/src/modules/platform-context/api/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ export const contextKeys = {
histories: () => [...contextKeys.all(), 'history'] as const,
history: ({ contextId, query = {} }: ListContextHistoryParams) =>
[...contextKeys.histories(), contextId, query] as const,
tokens: () => [...contextKeys.all(), 'token'] as const,
token: (contextId: string, providerId: string) => [...contextKeys.tokens(), contextId, providerId] as const,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Copyright 2025 © BeeAI a Series of LF Projects, LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { useQuery } from '@tanstack/react-query';
import type { ContextToken } from 'agentstack-sdk';

import { useApp } from '#contexts/App/index.ts';
import type { Agent } from '#modules/agents/api/types.ts';

import { usePlatformContext } from '../../contexts';
import { createContextToken } from '..';
import { contextKeys } from '../keys';

export function useContextToken(agent: Agent) {
const {
config: { contextTokenPermissions },
} = useApp();
const { contextId } = usePlatformContext();

return useQuery<ContextToken>({
queryKey: contextKeys.token(contextId ?? '', agent.provider.id),
queryFn: async () => {
if (!contextId) {
throw new Error('Context ID is not set.');
}

const token = await createContextToken({
contextId,
contextPermissions: contextTokenPermissions.grant_context_permissions ?? {},
globalPermissions: contextTokenPermissions.grant_global_permissions ?? {},
});

if (!token) {
throw new Error('Could not generate context token');
}

return token;
},
enabled: !!contextId,
staleTime: Infinity,
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const contextTokenPermissionsDefaults: DeepRequired<ContextTokenPermissio
llm: ['*'],
embeddings: ['*'],
model_providers: [],
a2a_proxy: [],
a2a_proxy: ['*'],
providers: [],
provider_variables: [],
contexts: [],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Copyright 2025 © BeeAI a Series of LF Projects, LLC
* SPDX-License-Identifier: Apache-2.0
*/

'use client';
import type { PropsWithChildren } from 'react';
import { useMemo } from 'react';

import type { Agent } from '#modules/agents/api/types.ts';
import { useContextToken } from '#modules/platform-context/api/queries/useContextToken.ts';
import { useBuildA2AClient } from '#modules/runs/api/queries/useBuildA2AClient.ts';

import { A2AClientContext } from './a2a-client-context';

interface Props {
agent: Agent;
}

export function A2AClientProvider({ agent, children }: PropsWithChildren<Props>) {
const { data: contextToken } = useContextToken(agent);
const { agentClient } = useBuildA2AClient({
providerId: agent.provider.id,
authToken: contextToken,
});

const contextValue = useMemo(() => {
if (!contextToken || !agentClient) {
return null;
}

return {
contextToken,
agentClient,
};
}, [contextToken, agentClient]);

if (!contextValue) {
return null;
}

return <A2AClientContext.Provider value={contextValue}>{children}</A2AClientContext.Provider>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Copyright 2025 © BeeAI a Series of LF Projects, LLC
* SPDX-License-Identifier: Apache-2.0
*/
'use client';
import type { ContextToken } from 'agentstack-sdk';
import { createContext } from 'react';

import type { AgentA2AClient } from '#api/a2a/types.ts';

export const A2AClientContext = createContext<A2AClientContextValue | null>(null);

export interface A2AClientContextValue {
contextToken: ContextToken;
agentClient: AgentA2AClient;
}
20 changes: 20 additions & 0 deletions apps/agentstack-ui/src/modules/runs/contexts/a2a-client/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Copyright 2025 © BeeAI a Series of LF Projects, LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { use } from 'react';

import { A2AClientContext } from './a2a-client-context';

export function useA2AClient() {
const context = use(A2AClientContext);

if (!context) {
throw new Error('useA2AClient must be used within A2AClientProvider');
}

return context;
}

export { A2AClientProvider } from './A2AClientProvider';
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,22 @@
* SPDX-License-Identifier: Apache-2.0
*/

import type { ContextToken } from 'agentstack-sdk';
import { type AgentSettings, type FormFulfillments, ModelCapability } from 'agentstack-sdk';
import { type PropsWithChildren, useCallback, useRef, useState } from 'react';

import type { AgentA2AClient } from '#api/a2a/types.ts';
import { useListConnectors } from '#modules/connectors/api/queries/useListConnectors.ts';
import type { RunFormValues } from '#modules/form/types.ts';
import { useMatchProviders } from '#modules/platform-context/api/mutations/useMatchProviders.ts';
import { getSettingsDemandsDefaultValues } from '#modules/runs/settings/utils.ts';

import { useA2AClient } from '../a2a-client';
import { useAgentSecrets } from '../agent-secrets';
import type { FulfillmentsContext } from './agent-demands-context';
import { AgentDemandsContext } from './agent-demands-context';
import { buildFulfillments } from './build-fulfillments';

interface Props<UIGenericPart> {
agentClient: AgentA2AClient<UIGenericPart>;
contextToken: ContextToken;
}

export function AgentDemandsProvider<UIGenericPart>({
agentClient,
contextToken,
children,
}: PropsWithChildren<Props<UIGenericPart>>) {
export function AgentDemandsProvider({ children }: PropsWithChildren) {
const { agentClient, contextToken } = useA2AClient();
const { demandedSecrets } = useAgentSecrets();

const [selectedEmbeddingProviders, setSelectedEmbeddingProviders] = useState<Record<string, string>>({});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const buildFulfillments = ({
connectors,
}: BuildFulfillmentsParams): Fulfillments => {
return {
// @deprecated - token now passed via A2A client headers
getContextToken: () => contextToken,

settings: async () => {
Expand Down
Loading
Loading