Skip to content
Draft
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
27 changes: 27 additions & 0 deletions catalog/app/components/Agent/AgentPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import * as React from 'react'
import { Box, Container, Typography } from '@material-ui/core'

import Layout from 'components/Layout'

import { AgentProvider } from './Model/Agent'
import AgentChat from './UI/AgentChat'

export default function AgentPage() {
return (
<Layout>
<Container maxWidth="lg">
<Box mt={4} mb={2}>
<Typography variant="h4" component="h1" gutterBottom>
Agent Assistant (MCP)
</Typography>
<Typography variant="body1" color="textSecondary" paragraph>
Experimental MCP-powered assistant interface
</Typography>
</Box>
<AgentProvider>
<AgentChat />
</AgentProvider>
</Container>
</Layout>
)
}
153 changes: 153 additions & 0 deletions catalog/app/components/Agent/Model/Agent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import * as Eff from 'effect'
import invariant from 'invariant'
import * as React from 'react'

import * as AWS from 'utils/AWS'
import * as Actor from 'utils/Actor'

import * as Bedrock from './Bedrock'
import * as Conversation from './Conversation'
import * as MCPClient from './MCPClient'
import * as MCPToolAdapter from './MCPToolAdapter'

function usePassThru<T>(val: T) {
const ref = React.useRef(val)
ref.current = val
return ref
}

export const DEFAULT_MODEL_ID = 'us.anthropic.claude-3-7-sonnet-20250219-v1:0'
const MODEL_ID_KEY = 'AGENT_BEDROCK_MODEL_ID'

function useModelIdOverride() {
const [value, setValue] = React.useState(
() =>
(typeof localStorage !== 'undefined' && localStorage.getItem(MODEL_ID_KEY)) || '',
)

React.useEffect(() => {
if (typeof localStorage !== 'undefined') {
if (value) {
localStorage.setItem(MODEL_ID_KEY, value)
} else {
localStorage.removeItem(MODEL_ID_KEY)
}
}
}, [value])

const modelIdPassThru = usePassThru(value)
const modelIdEff = React.useMemo(
() => Eff.Effect.sync(() => modelIdPassThru.current || DEFAULT_MODEL_ID),
[modelIdPassThru],
)

return [
modelIdEff,
React.useMemo(() => ({ value, setValue }), [value, setValue]),
] as const
}

function useRecording() {
const [enabled, enable] = React.useState(false)
const [log, setLog] = React.useState<string[]>([])

const clear = React.useCallback(() => setLog([]), [])

const enabledPassThru = usePassThru(enabled)
const record = React.useCallback(
(entry: string) =>
Eff.Effect.sync(() => {
if (enabledPassThru.current) setLog((l) => l.concat(entry))
}),
[enabledPassThru],
)

return [
record,
React.useMemo(() => ({ enabled, log, enable, clear }), [enabled, log, enable, clear]),
] as const
}

function useConstructAgentAPI() {
const [modelId, modelIdOverride] = useModelIdOverride()
const [record, recording] = useRecording()
const [mcpClient, setMcpClient] = React.useState<MCPClient.MCPClient | null>(null)
const [mcpTools, setMcpTools] = React.useState({})
const [mcpError, setMcpError] = React.useState<string | null>(null)

const passThru = usePassThru({
bedrock: AWS.Bedrock.useClient(),
tools: mcpTools,
})

// Connect to MCP server
const connectMCP = React.useCallback(() => {
Eff.Effect.gen(function* () {
setMcpError(null)

const mcpService = yield* MCPClient.MCPClientService
const client = yield* mcpService.connect(MCPClient.getServerUrl('test'))
setMcpClient(client)

// Load tools from the MCP server
const tools = yield* MCPToolAdapter.loadToolsFromMCPServer(client)
setMcpTools(tools)
})
.pipe(
Eff.Effect.provide(MCPClient.MCPClientServiceLive),
Eff.Effect.catchAll((error) =>
Eff.Effect.sync(() => {
const errorMsg = `MCP connection failed: ${error}`
setMcpError(errorMsg)
}),
),
Eff.Effect.runPromise,
)
.catch((e: unknown) => {
setMcpError(`MCP connection error: ${e}`)
})
}, [])

// Auto-connect on mount
React.useEffect(() => {
connectMCP()
}, [connectMCP])

const layerEff = Eff.Effect.sync(() =>
Eff.Layer.merge(
Bedrock.LLMBedrock(passThru.current.bedrock, { modelId, record }),
Eff.Layer.succeed(Conversation.ToolService, passThru.current.tools),
),
)

const [state, dispatch] = Actor.useActorLayer(
Conversation.ConversationActor,
Conversation.init,
layerEff,
)

return {
state,
dispatch,
mcpClient,
mcpTools,
mcpError,
connectMCP,
devTools: { recording, modelIdOverride },
}
}

export type AgentAPI = ReturnType<typeof useConstructAgentAPI>
export type { AgentAPI as API }

const Ctx = React.createContext<AgentAPI | null>(null)

export function AgentProvider({ children }: React.PropsWithChildren<{}>) {
return <Ctx.Provider value={useConstructAgentAPI()}>{children}</Ctx.Provider>
}

export function useAgentAPI() {
const api = React.useContext(Ctx)
invariant(api, 'AgentAPI must be used within an AgentProvider')
return api
}
177 changes: 177 additions & 0 deletions catalog/app/components/Agent/Model/Bedrock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import type * as AWSSDK from 'aws-sdk'
import BedrockRuntime from 'aws-sdk/clients/bedrockruntime'
import * as Eff from 'effect'

import * as Log from 'utils/Logging'

import * as Content from './Content'
import * as LLM from './LLM'

const MODULE = 'Bedrock'

interface BedrockOptions {
modelId: Eff.Effect.Effect<string>
record?: (r: string) => Eff.Effect.Effect<void>
}

const mapContent = (contentBlocks: BedrockRuntime.ContentBlocks | undefined) =>
Eff.pipe(
contentBlocks,
Eff.Option.fromNullable,
Eff.Option.map(
Eff.Array.flatMapNullable((c) => {
if (c.document) {
return Content.ResponseMessageContentBlock.Document({
format: c.document.format as $TSFixMe,
source: c.document.source.bytes as $TSFixMe,
name: c.document.name,
})
}
if (c.image) {
return Content.ResponseMessageContentBlock.Image({
format: c.image.format as $TSFixMe,
source: c.image.source.bytes as $TSFixMe,
})
}
if (c.text) {
return Content.ResponseMessageContentBlock.Text({ text: c.text })
}
if (c.toolUse) {
return Content.ResponseMessageContentBlock.ToolUse(c.toolUse as $TSFixMe)
}
// if (c.guardContent) {
// // TODO
// return acc
// }
// if (c.toolResult) {
// // XXX: is it really supposed to occur here in LLM response?
// return acc
// }
return null
}),
),
)

// TODO: use Schema
const contentToBedrock = Content.PromptMessageContentBlock.$match({
GuardContent: ({ text }) => ({ guardContent: { text: { text } } }),
ToolResult: ({ toolUseId, status, content }) => ({
toolResult: {
toolUseId,
status,
content: content.map(
Content.ToolResultContentBlock.$match({
Json: ({ _tag, ...rest }) => rest,
Text: ({ _tag, ...rest }) => rest,
// XXX: be careful with base64/non-base64 encoding
Image: ({ format, source }) => ({
image: { format, source: { bytes: source } },
}),
Document: ({ format, source, name }) => ({
document: { format, source: { bytes: source }, name },
}),
}),
),
},
}),
ToolUse: ({ _tag, ...toolUse }) => ({ toolUse }),
Text: ({ _tag, ...rest }) => rest,
Image: ({ format, source }) => ({ image: { format, source: { bytes: source } } }),
Document: ({ format, source, name }) => ({
document: { format, source: { bytes: source }, name },
}),
})

const messagesToBedrock = (
messages: Eff.Array.NonEmptyArray<LLM.PromptMessage>,
): BedrockRuntime.Message[] =>
// create an array of alternating assistant and user messages
Eff.pipe(
messages,
Eff.Array.groupWith((m1, m2) => m1.role === m2.role),
Eff.Array.map((group) => ({
role: group[0].role,
content: group.map((m) => contentToBedrock(m.content)),
})),
)

const toolConfigToBedrock = (
toolConfig: LLM.ToolConfig,
): BedrockRuntime.ToolConfiguration => {
if (!toolConfig || !toolConfig.tools) {
return { tools: [] }
}
return {
tools: Object.entries(toolConfig.tools).map(([name, { description, schema }]) => ({
toolSpec: {
name,
description,
inputSchema: { json: schema },
},
})),
toolChoice:
toolConfig.choice &&
LLM.ToolChoice.$match(toolConfig.choice, {
Auto: () => ({ auto: {} }),
Any: () => ({ any: {} }),
Specific: ({ name }) => ({ tool: { name } }),
}),
}
}

function isAWSError(e: any): e is AWSSDK.AWSError {
return e.code !== undefined && e.message !== undefined
}

// a layer providing the service over aws.bedrock
export function LLMBedrock(bedrock: BedrockRuntime, options: BedrockOptions) {
const converse = (prompt: LLM.Prompt, opts?: LLM.Options) =>
Log.scoped({
name: `${MODULE}.converse`,
enter: [Log.br, 'prompt:', prompt, Log.br, 'opts:', opts],
})(
Eff.Effect.gen(function* () {
const requestTimestamp = new Date(yield* Eff.Clock.currentTimeMillis)
const modelId = yield* options.modelId
const requestBody = {
modelId,
system: [{ text: prompt.system }],
messages: messagesToBedrock(prompt.messages),
...(prompt.toolConfig && Object.keys(prompt.toolConfig.tools || {}).length > 0
? { toolConfig: toolConfigToBedrock(prompt.toolConfig) }
: {}),
...opts,
}
const backendResponse = yield* Eff.Effect.tryPromise({
try: () => bedrock.converse(requestBody).promise(),
catch: (e) =>
new LLM.LLMError({
message: isAWSError(e)
? `Bedrock error (${e.code}): ${e.message}`
: `Unexpected error: ${e}`,
}),
})
const responseTimestamp = new Date(yield* Eff.Clock.currentTimeMillis)
if (options.record) {
const entry = JSON.stringify(
{
requestTimestamp,
responseTimestamp,
modelId,
request: requestBody,
response: backendResponse,
},
null,
2,
)
yield* options.record(entry)
}
return {
backendResponse,
content: mapContent(backendResponse.output.message?.content),
}
}),
)

return Eff.Layer.succeed(LLM.LLM, { converse })
}
Loading
Loading