• AG-UI Middleware๋Š” AbstractAgent ์‹คํ–‰๊ณผ ์ด๋ฒคํŠธ ์†Œ๋น„์ž ์‚ฌ์ด์— ๋ผ์–ด๋“ค์–ด ์ด๋ฒคํŠธ ์ŠคํŠธ๋ฆผ์„ ๊ฐ€๊ณตํ•˜๋Š” ๊ณ„์ธต
  • agent.runAgent() ํ˜ธ์ถœ ์‹œ ๋“ฑ๋ก๋œ ๋ฏธ๋“ค์›จ์–ด ์ฒด์ธ์„ ํ†ต๊ณผํ•˜๋Š” RxJS Observable ๊ธฐ๋ฐ˜ ํŒŒ์ดํ”„๋ผ์ธ
  • ํ•ต์‹ฌ ์ฑ…์ž„์€ ์ด๋ฒคํŠธ ๋ณ€ํ™˜ยทํ•„ํ„ฐ๋งยท๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ฃผ์ž…ยท์—๋Ÿฌ ์ฒ˜๋ฆฌยท๋ชจ๋‹ˆํ„ฐ๋ง์˜ 5๊ฐ€์ง€ ํšก๋‹จ ๊ด€์‹ฌ์‚ฌ(cross-cutting concerns)
  • agent ๋ณธ์ฒด ์ฝ”๋“œ๋ฅผ ์ˆ˜์ •ํ•˜์ง€ ์•Š๊ณ  ๊ธฐ๋Šฅ์„ ํ™•์žฅํ•˜๋Š” ์–‘ํŒŒ(onion) ๊ตฌ์กฐ์˜ ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ ํŒจํ„ด

ํ•ด๋‹น ๊ฐœ๋…์ด ํ•„์š”ํ•œ ์ด์œ 

  • agent์˜ ํ•ต์‹ฌ ๋กœ์ง๊ณผ ๋ถ€๊ฐ€ ๊ธฐ๋Šฅ(๋กœ๊น…, ์ธ์ฆ, ํ•„ํ„ฐ๋ง)์„ ๋ถ„๋ฆฌํ•ด ๋‹จ์ผ ์ฑ…์ž„ ์›์น™ ์œ ์ง€
  • ๋™์ผํ•œ ์—์ด์ „ํŠธ๋ฅผ ๋‹ค์–‘ํ•œ ํ™˜๊ฒฝ(๊ฐœ๋ฐœ/์šด์˜, ๋””๋ฒ„๊ทธ/ํ”„๋กœ๋•์…˜)์—์„œ ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅ
  • ์ด๋ฒคํŠธ ์ŠคํŠธ๋ฆผ์— ์ผ๋ฅ ์ ์œผ๋กœ ์ ์šฉ๋˜๋Š” ์ •์ฑ…(์˜ˆ: ์š•์„ค ํ•„ํ„ฐ)์„ ์„ ์–ธ์ ์œผ๋กœ ์กฐํ•ฉ
  • ์ธ์ฆยท๊ถŒํ•œยทrate limit ๊ฐ™์€ ์ •์ฑ…์„ agent ์™ธ๋ถ€์—์„œ ๊ฐ•์ œํ•ด ๋ณด์•ˆ ๊ฒฝ๊ณ„ ๋ช…ํ™•ํ™”

AS-IS

๋ฏธ๋“ค์›จ์–ด ์—†์ด agent ์•ˆ์— ๋ชจ๋“  ๋ถ€๊ฐ€ ๊ธฐ๋Šฅ์ด ์„ž์ธ ๊ตฌ์กฐ:

class MyAgent extends AbstractAgent {
  run(input: RunAgentInput): Observable<BaseEvent> {
    console.log("[REQ] start")                     // ๋กœ๊น…
    if (!input.forwardedProps?.token) throw Error  // ์ธ์ฆ
    return this.callBackend(input).pipe(
      filter(e => !this.isDangerous(e)),           // ๋„๊ตฌ ํ•„ํ„ฐ
      map(e => this.maskProfanity(e)),             // ์š•์„ค ๋งˆ์Šคํ‚น
      tap(e => this.recordMetric(e)),              // ๋ฉ”ํŠธ๋ฆญ
      finalize(() => console.log("[DONE]"))        // ์ข…๋ฃŒ ๋กœ๊ทธ
    )
  }
}

TO-BE

๋ฏธ๋“ค์›จ์–ด๋กœ ๊ด€์‹ฌ์‚ฌ๋ฅผ ๋ถ„๋ฆฌํ•œ ๊ตฌ์กฐ:

flowchart LR
    UI[UI: runAgent input] --> A[authMiddleware]
    A --> P[profanityMiddleware]
    P --> L[loggingMiddleware]
    L --> F[filterToolCallsMiddleware]
    F --> AG[agent.run]
    AG -. event .-> F
    F -. event .-> L
    L -. event .-> P
    P -. event .-> A
    A -. event .-> UI
agent.use(authMiddleware, profanityMiddleware, loggingMiddleware, filterToolCallsMiddleware)
await agent.runAgent(input)

Middleware์˜ ๋‘ ํŽ˜์ด์ฆˆ

Middleware๋Š” ์‹œ์ ์ด ๋‹ค๋ฅธ ๋‘ ํ๋ฆ„์„ ๋™์‹œ์— ๋‹ค๋ฃฌ๋‹ค.

ํŽ˜์ด์ฆˆ์‹œ์ ํšŸ์ˆ˜RxJS ๊ด€์ 
Phase 1: ์ž…๋ ฅ์‚ฌ์šฉ์ž๊ฐ€ ๋ฉ”์‹œ์ง€ ๋ณด๋‚ผ ๋•Œ1ํšŒsubscribe() ์‹œ์  ๋™๊ธฐ ํ•จ์ˆ˜ ํ˜ธ์ถœ
Phase 2: ์ด๋ฒคํŠธAI ์‘๋‹ต์ด ํ˜๋Ÿฌ๋‚˜์˜ฌ ๋•ŒNํšŒ๋น„๋™๊ธฐ emit per event

ํ•œ ๋ฏธ๋“ค์›จ์–ด ์ฝ”๋“œ ์•ˆ์—์„œ ๋‘ ํŽ˜์ด์ฆˆ ์˜์—ญ์ด ๋ช…ํ™•ํžˆ ๊ตฌ๋ถ„๋œ๋‹ค:

const myMiddleware: MiddlewareFunction = (input, next) => {
  // โ”€โ”€ Phase 1 ์˜์—ญ: ์ž…๋ ฅ ๊ฐ€๊ณต (1ํšŒ ์‹คํ–‰) โ”€โ”€
  const modifiedInput = { ...input, /* ... */ }
 
  return next.run(modifiedInput).pipe(
    // โ”€โ”€ Phase 2 ์˜์—ญ: ์ด๋ฒคํŠธ ๊ฐ€๊ณต (event๋งˆ๋‹ค ์‹คํ–‰) โ”€โ”€
    map(event => /* ... */),
    tap(event => /* ... */),
    finalize(() => /* ์ข…๋ฃŒ ์‹œ 1ํšŒ */)
  )
}

์–‘ํŒŒ(Onion) ํŒจํ„ด ๋™์ž‘ ๋ชจ๋ธ

Phase 1 โ€” ์ž…๋ ฅ์ด ์•ˆ์ชฝ์œผ๋กœ ํ๋ฆ„

sequenceDiagram
    autonumber
    participant UI
    participant Auth as authMiddleware
    participant Prof as profanityMiddleware
    participant Log as loggingMiddleware
    participant Filter as filterToolCallsMiddleware
    participant Agent as agent.run

    UI->>Auth: runAgent(input)
    Auth->>Auth: forwardedProps์— ํ† ํฐ ์ฃผ์ž…
    Auth->>Prof: next.run(authedInput)
    Prof->>Prof: messages ์š•์„ค ๋งˆ์Šคํ‚น
    Prof->>Log: next.run(cleanedInput)
    Log->>Log: "[REQ] runId=..." ๊ธฐ๋ก
    Log->>Filter: next.run(input)
    Filter->>Agent: next.run(input)
    Agent->>Agent: ๋ฐฑ์—”๋“œ๋กœ SSE ์š”์ฒญ

Phase 2 โ€” ์ด๋ฒคํŠธ๊ฐ€ ๋ฐ”๊นฅ์ชฝ์œผ๋กœ ํ๋ฆ„

sequenceDiagram
    autonumber
    participant UI
    participant Auth as authMiddleware
    participant Prof as profanityMiddleware
    participant Log as loggingMiddleware
    participant Filter as filterToolCallsMiddleware
    participant Agent as agent.run

    loop ๋งค ์ด๋ฒคํŠธ๋งˆ๋‹ค
        Agent->>Filter: emit BaseEvent
        Filter->>Filter: ์œ„ํ—˜ ๋„๊ตฌ๋ฉด DROP
        Filter->>Log: pass event
        Log->>Log: tap "[EVT] type"
        Log->>Prof: pass event
        Prof->>Prof: TEXT_MESSAGE_CONTENT ๋งˆ์Šคํ‚น
        Prof->>Auth: pass event
        Auth->>UI: ์ตœ์ข… ์ด๋ฒคํŠธ ์ „๋‹ฌ
    end

ํ•ต์‹ฌ ๋น„๋Œ€์นญ์€ ์ž…๋ ฅ์€ 1๋ฒˆ, ์ด๋ฒคํŠธ๋Š” ๋งค emit๋งˆ๋‹ค ์ฒด์ธ์„ ํ†ต๊ณผํ•œ๋‹ค๋Š” ์ ์ด๋‹ค. ๊ทธ๋ž˜์„œ pipe(...) ์•ˆ์˜ map/tap์€ ์ด๋ฒคํŠธ ๊ฐœ์ˆ˜๋งŒํผ ์‹คํ–‰๋œ๋‹ค.

์ด๋ฒคํŠธ๋Š” ์ฒญํฌ ๋‹จ์œ„๋กœ ํ๋ฅธ๋‹ค

Phase 2๊ฐ€ โ€œ์ด๋ฒคํŠธ๋งˆ๋‹ค N๋ฒˆ ํ˜ธ์ถœ๋œ๋‹คโ€๊ณ  ํ–ˆ์„ ๋•Œ, ๊ทธ N์˜ ์ •์ฒด๋ฅผ ์ •ํ™•ํžˆ ์ดํ•ดํ•ด์•ผ ๋ฏธ๋“ค์›จ์–ด ๋กœ์ง์„ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. AG-UI์˜ ํ…์ŠคํŠธ ๋ฉ”์‹œ์ง€์™€ ๋„๊ตฌ ํ˜ธ์ถœ์€ ๋‘˜ ๋‹ค ์ฒญํฌ ๋‹จ์œ„๋กœ ๋ถ„ํ• ๋˜์–ด ํ˜๋Ÿฌ๋‚˜์˜ค๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค(AG-UI Event ์ฐธ๊ณ ).

ํ…์ŠคํŠธ ๋ฉ”์‹œ์ง€์˜ ์ฒญํฌ ๊ตฌ์กฐ

AI๊ฐ€ โ€œ์•ˆ๋…•ํ•˜์„ธ์š” ๋ฐ˜๊ฐ‘์Šต๋‹ˆ๋‹คโ€๋ฅผ ์ŠคํŠธ๋ฆฌ๋ฐํ•  ๋•Œ ๋ฐฑ์—”๋“œ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์ด ๋ถ„๋ฆฌ๋œ ์ด๋ฒคํŠธ๋“ค์„ ์ˆœ์ฐจ emitํ•œ๋‹ค:

TEXT_MESSAGE_START         (messageId)
TEXT_MESSAGE_CONTENT       (delta="์•ˆ๋…•")
TEXT_MESSAGE_CONTENT       (delta="ํ•˜์„ธ์š”")
TEXT_MESSAGE_CONTENT       (delta=" ๋ฐ˜๊ฐ‘")
TEXT_MESSAGE_CONTENT       (delta="์Šต๋‹ˆ๋‹ค")
TEXT_MESSAGE_END           (messageId)

์ด 6๊ฐœ ์ด๋ฒคํŠธ ๊ฐ๊ฐ์ด ๋ฏธ๋“ค์›จ์–ด ์ฒด์ธ์„ ํ†ต๊ณผํ•œ๋‹ค. ๋”ฐ๋ผ์„œ profanityMiddleware์˜ pipe(map(...))์€ 6๋ฒˆ ํ˜ธ์ถœ๋˜๋ฉฐ, delta๊ฐ€ ๋“  4๋ฒˆ์—์„œ๋งŒ ๋งˆ์Šคํ‚น ๋กœ์ง์ด ์‹ค์ œ๋กœ ๋™์ž‘ํ•œ๋‹ค.

AG-UI๋Š” ๋™์ผํ•œ ์ •๋ณด๋ฅผ ๋” ์ปดํŒฉํŠธํ•˜๊ฒŒ ํ‘œํ˜„ํ•˜๋Š” TEXT_MESSAGE_CHUNK ํ˜•ํƒœ๋„ ์ง€์›ํ•œ๋‹ค. ํ•œ ์ด๋ฒคํŠธ์— messageId+delta๊ฐ€ ๋ฌถ์—ฌ ๋“ค์–ด์˜ค๊ณ  ๋ณ„๋„ START/END๊ฐ€ ์—†๋Š” ํ˜•ํƒœ๋‹ค. ๋ฏธ๋“ค์›จ์–ด๋Š” ์–‘์ชฝ ํ˜•ํƒœ๋ฅผ ๋ชจ๋‘ ์ฒ˜๋ฆฌํ•˜๋„๋ก ์ž‘์„ฑํ•˜๋Š” ๊ฒŒ ์•ˆ์ „ํ•˜๋‹ค.

๋„๊ตฌ ํ˜ธ์ถœ๋„ ๋งˆ์ฐฌ๊ฐ€์ง€

TOOL_CALL_START   (toolCallId, toolCallName="search")
TOOL_CALL_ARGS    (delta='{"query":')
TOOL_CALL_ARGS    (delta='"AG-UI"}')
TOOL_CALL_END     (toolCallId)

๋„๊ตฌ ์ธ์ž(JSON ๋ฌธ์ž์—ด)๋„ ์ฒญํฌ ๋‹จ์œ„๋กœ emit๋˜๋ฏ€๋กœ ํ•œ ํ˜ธ์ถœ๋‹น ARGS ์ด๋ฒคํŠธ๊ฐ€ ์—ฌ๋Ÿฌ ๋ฒˆ ๋ฐœ์ƒํ•œ๋‹ค. ๋ฏธ๋“ค์›จ์–ด๋Š” โ€œ์™„์„ฑ๋œ ์ธ์ž ๊ฐ์ฒดโ€๋ฅผ ํ•œ ๋ฒˆ์— ๋ฐ›๋Š” ๊ฒŒ ์•„๋‹ˆ๋ผ JSON ์กฐ๊ฐ์„ ๋ˆ„์ ํ•ด์•ผ ํ•˜๋Š” ์ƒํ™ฉ์„ ํ•ญ์ƒ ์ „์ œํ•ด์•ผ ํ•œ๋‹ค.

์ž‘์„ฑ ๋ฐฉ์‹ ๋‘ ๊ฐ€์ง€

Function-Based โ€” ๋‹จ์ˆœ ๋ณ€ํ™˜์šฉ

const prefixMiddleware: MiddlewareFunction = (input, next) => {
  return next.run(input).pipe(
    map(event => {
      if (event.type === EventType.TEXT_MESSAGE_CONTENT) {
        return { ...event, delta: `[AI]: ${event.delta}` }
      }
      return event
    })
  )
}

์‹œ๊ทธ๋‹ˆ์ฒ˜: (input: RunAgentInput, next: AbstractAgent) => Observable<BaseEvent>

Class-Based โ€” ์ƒํƒœ/์„ค์ • ํ•„์š” ์‹œ

class MetricsMiddleware extends Middleware {
  private eventCount = 0
 
  constructor(private metricsService: MetricsService) { super() }
 
  run(input: RunAgentInput, next: AbstractAgent): Observable<BaseEvent> {
    const start = Date.now()
    return this.runNext(input, next).pipe(
      tap(event => {
        this.eventCount++
        this.metricsService.recordEvent(event.type)
      }),
      finalize(() => {
        this.metricsService.recordDuration(Date.now() - start)
      })
    )
  }
}

์ฒญํฌ๋ฅผ ์˜๋ฏธ ๋‹จ์œ„๋กœ ๋ฐ›๊ธฐ โ€” runNext์™€ runNextWithState

์ฒญํฌ ๋ถ„ํ• ์˜ ์กด์žฌ๋ฅผ ์•Œ๋ฉด ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ์˜๋ฌธ์ด ์ƒ๊ธด๋‹ค. โ€œ๋งค๋ฒˆ ์ฒญํฌ๋ฅผ ์ง์ ‘ ๋ˆ„์ ํ•ด์•ผ ํ•˜๋‚˜?โ€ ํด๋ž˜์Šค ๋ฏธ๋“ค์›จ์–ด๊ฐ€ ์ œ๊ณตํ•˜๋Š” ๋‘ ํ—ฌํผ๊ฐ€ ์ด ๋ถ€๋ถ„์„ ๋„์™€์ค€๋‹ค. ๋‹จ, ํ—ฌํผ์˜ ์˜๋ฏธ๋ฅผ ์ •ํ™•ํžˆ ์•Œ์•„์•ผ ์˜คํ•ด๊ฐ€ ์—†๋‹ค.

runNext() โ€” ํ•ฉ์น˜๊ธฐ๊ฐ€ ์•„๋‹ˆ๋ผ โ€œ์ •๊ทœํ™”โ€

runNext()์˜ ์—ญํ• ์€ ์ฒญํฌ๋“ค์„ ํ•˜๋‚˜์˜ ์ด๋ฒคํŠธ๋กœ ํ•ฉ์ณ์ฃผ๋Š” ๊ฒƒ์ด ์•„๋‹ˆ๋ผ, ์–ด๋–ค ํ˜•ํƒœ๋กœ ๋“ค์–ด์˜ค๋“  ์ผ๊ด€๋œ ๋ถ„๋ฆฌํ˜•(START/CONTENT/END)์œผ๋กœ ๋ณ€ํ™˜ํ•ด์ฃผ๋Š” ๊ฒƒ์ด๋‹ค.

[์ž…๋ ฅ โ€” CHUNK ํ˜•ํƒœ๋กœ ๋“ค์–ด์™”๋‹ค๊ณ  ๊ฐ€์ •]
TEXT_MESSAGE_CHUNK(delta="์•ˆ๋…•")
TEXT_MESSAGE_CHUNK(delta="ํ•˜์„ธ์š”")
TEXT_MESSAGE_CHUNK(delta=" ๋ฐ˜๊ฐ‘")
TEXT_MESSAGE_CHUNK(delta="์Šต๋‹ˆ๋‹ค")

[runNext() ํ†ต๊ณผ ํ›„ โ€” ๋ถ„๋ฆฌํ˜•์œผ๋กœ ์ •๊ทœํ™”]
TEXT_MESSAGE_START
TEXT_MESSAGE_CONTENT(delta="์•ˆ๋…•")
TEXT_MESSAGE_CONTENT(delta="ํ•˜์„ธ์š”")
TEXT_MESSAGE_CONTENT(delta=" ๋ฐ˜๊ฐ‘")
TEXT_MESSAGE_CONTENT(delta="์Šต๋‹ˆ๋‹ค")
TEXT_MESSAGE_END

์ด๋ฒคํŠธ ๊ฐœ์ˆ˜๋Š” ๊ทธ๋Œ€๋กœ(๋˜๋Š” START/END๊ฐ€ ์ถ”๊ฐ€๋˜์–ด ๋” ๋งŽ์•„์ง)์ด๋ฉฐ, ๋ฏธ๋“ค์›จ์–ด pipe๋Š” ์—ฌ์ „ํžˆ N๋ฒˆ ํ˜ธ์ถœ๋œ๋‹ค. runNext๊ฐ€ ๋ณด์žฅํ•˜๋Š” ๊ฑด **โ€œ๋‘ ๊ฐ€์ง€ ์ž…๋ ฅ ํ˜•ํƒœ๋ฅผ ํ•œ ๊ฐ€์ง€๋กœ ํ†ต์ผํ•ด ๋ถ„๊ธฐ ์ฒ˜๋ฆฌ๋ฅผ ์ค„์—ฌ์ฃผ๋Š” ๊ฒƒโ€**์ด์ง€ ์ฒญํฌ ํ†ตํ•ฉ์ด ์•„๋‹ˆ๋‹ค.

์™„์„ฑ๋œ ๋ฉ”์‹œ์ง€ ํ•œ ๋ฉ์–ด๋ฆฌ๋ฅผ ๋ฐ›๋Š” ๋‘ ๊ฐ€์ง€ ํŒจํ„ด

์ฒญํฌ๊ฐ€ ์•„๋‹ˆ๋ผ ์™„์„ฑ๋œ ํ…์ŠคํŠธ ํ•œ ๋ฉ์–ด๋ฆฌ๋ฅผ ๋‹ค๋ฃจ๊ณ  ์‹ถ๋‹ค๋ฉด ๋‘ ๊ฐ€์ง€ ๋ฐฉ๋ฒ•์ด ์žˆ๋‹ค.

๋ฐฉ๋ฒ• A โ€” TEXT_MESSAGE_END ์‹œ์ ์— ์ง์ ‘ ๋ˆ„์ 

class FullMessageMiddleware extends Middleware {
  private buffer = ""
 
  run(input, next) {
    return this.runNext(input, next).pipe(
      tap(event => {
        if (event.type === EventType.TEXT_MESSAGE_CONTENT) {
          this.buffer += event.delta
        }
        if (event.type === EventType.TEXT_MESSAGE_END) {
          console.log("์™„์„ฑ๋œ ๋ฉ”์‹œ์ง€:", this.buffer)
          this.buffer = ""
        }
      })
    )
  }
}

๋ฐฉ๋ฒ• B โ€” runNextWithState()์˜ ๋ˆ„์  messages ํ™œ์šฉ

class StateMiddleware extends Middleware {
  run(input, next) {
    return this.runNextWithState(input, next).pipe(
      tap(({ event, messages, state }) => {
        const lastMsg = messages[messages.length - 1]
        if (event.type === EventType.TEXT_MESSAGE_END) {
          console.log("์™„์„ฑ๋œ ๋ฉ”์‹œ์ง€:", lastMsg.content)
        }
      })
    )
  }
}

runNextWithState()๋Š” ์ด๋ฒคํŠธ๋Š” ๊ทธ๋Œ€๋กœ N๋ฒˆ emitํ•˜๋˜, ๊ฐ ์‹œ์ ์— **โ€œ์ง€๊ธˆ๊นŒ์ง€ ๋ˆ„์ ๋œ messages์™€ stateโ€**๋ฅผ ํ•จ๊ป˜ ์ œ๊ณตํ•œ๋‹ค. ์ฒญํฌ ๋ˆ„์  ๋กœ์ง์„ ์ง์ ‘ ์งค ํ•„์š” ์—†์ด ๋งˆ์ง€๋ง‰ ๋ฉ”์‹œ์ง€๋ฅผ ์ฝ๊ธฐ๋งŒ ํ•˜๋ฉด ๋œ๋‹ค.

์„ธ ๋‹จ๊ณ„ ๋น„๊ต

ํ—ฌํผ์ด๋ฒคํŠธ ๊ฐœ์ˆ˜ ๋ณ€ํ™”์ œ๊ณตํ•˜๋Š” ๊ฒƒ
next.run() (raw)๊ทธ๋Œ€๋กœevent๋งŒ
runNext()CHUNK โ†’ START/CONTENT/END๋กœ ํ˜•ํƒœ ํ†ต์ผevent๋งŒ, ํ˜•ํƒœ ์ผ๊ด€์„ฑ
runNextWithState()์œ„์™€ ๋™์ผevent + ๋ˆ„์  messages + ๋ˆ„์  state

์š”์•ฝํ•˜์ž๋ฉด โ€œ์ด๋ฒคํŠธ๋ฅผ 1๊ฐœ๋กœ ํ•ฉ์ณ์ฃผ๋Š” ํ—ฌํผ๋Š” ์—†๋‹คโ€. ํ•ฉ์น˜๊ณ  ์‹ถ๋‹ค๋ฉด END ์‹œ์ ์— buffer๋ฅผ ๋น„์šฐ๊ฑฐ๋‚˜ runNextWithState์˜ ๋ˆ„์  messages๋ฅผ ์ฝ์–ด์•ผ ํ•œ๋‹ค.

๋‚ด์žฅ Middleware โ€” FilterToolCallsMiddleware

๋„๊ตฌ ํ˜ธ์ถœ ์ด๋ฒคํŠธ๋ฅผ ํ™”์ดํŠธ๋ฆฌ์ŠคํŠธ/๋ธ”๋ž™๋ฆฌ์ŠคํŠธ๋กœ ํ•„ํ„ฐ๋งํ•œ๋‹ค.

import { FilterToolCallsMiddleware } from "@ag-ui/client"
 
const allow = new FilterToolCallsMiddleware({
  allowedToolCalls: ["search", "calculate"]
})
 
const block = new FilterToolCallsMiddleware({
  disallowedToolCalls: ["delete", "modify"]
})
 
agent.use(allow)

์ด ๋ฏธ๋“ค์›จ์–ด๊ฐ€ ์ฐจ๋‹จํ•˜๋Š” ๊ฑด ์ด๋ฏธ ๋ฐฉ์ถœ๋œ TOOL_CALL_* ์ด๋ฒคํŠธ๋‹ค. ๋ชจ๋ธ์ด๋‚˜ ๋ฐฑ์—”๋“œ ๋Ÿฐํƒ€์ž„์—์„œ ๋„๊ตฌ๊ฐ€ ์‹ค์ œ๋กœ ์‹คํ–‰๋˜๋Š” ๊ฒƒ์„ ๋ง‰๋Š” ๊ฒŒ ์•„๋‹ˆ๋‹ค. ์ด ์ฐจ์ด๋Š” ๋„๊ตฌ ์‹คํ–‰ ์ฃผ์ฒด๊ฐ€ ์–ด๋””๋ƒ์— ๋”ฐ๋ผ ๊ฒฐ๊ณผ๊ฐ€ ํฌ๊ฒŒ ๋‹ฌ๋ผ์ง€๋ฏ€๋กœ ๋ณ„๋„๋กœ ์งš์„ ํ•„์š”๊ฐ€ ์žˆ๋‹ค.

Backend vs Frontend ๋„๊ตฌ โ€” ์ฐจ๋‹จ์˜ ๋น„๋Œ€์นญ

AG-UI์—์„œ ๋„๊ตฌ๋Š” ๋‘ ๊ตฐ๋ฐ์—์„œ ์‹คํ–‰๋  ์ˆ˜ ์žˆ๊ณ , ์ฐจ๋‹จ๋˜๋Š” ์‹œ์ ์€ ๋™์ผํ•˜๊ฒŒ Phase 2(์ด๋ฒคํŠธ ๋ฐ˜ํ™˜ ํ๋ฆ„)์ง€๋งŒ ์‹คํ–‰ ์‹œ์ ์ด ๋‹ฌ๋ผ ๊ฒฐ๊ณผ๊ฐ€ ๋‹ค๋ฅด๋‹ค.

Backend ๋„๊ตฌ โ€” ์‹คํ–‰์€ ๋๋‚ฌ๊ณ  ํ‘œ์‹œ๋งŒ ๊ฐ€๋ ค์ง„๋‹ค

sequenceDiagram
    autonumber
    participant UI
    participant MW as filterToolCallsMiddleware
    participant Agent as ๋ฐฑ์—”๋“œ agent
    participant Tool as bash ์‹คํ–‰๊ธฐ

    UI->>MW: Phase 1: runAgent(input)
    MW->>Agent: input ํ†ต๊ณผ
    Agent->>Tool: bash ์‹ค์ œ ์‹คํ–‰ (์„œ๋ฒ„์—์„œ)
    Tool-->>Agent: ๊ฒฐ๊ณผ ๋ฐ˜ํ™˜
    Note over Agent,Tool: ์‹คํ–‰์€ ์ด๋ฏธ ๋๋‚จ
    Agent->>MW: Phase 2: TOOL_CALL_START(name='bash')
    MW->>MW: ์ฐจ๋‹จ ๋Œ€์ƒ โ†’ DROP
    Note over MW,UI: UI๋Š” ์ด ์ด๋ฒคํŠธ๋ฅผ ๋ชป ๋ด„
    Agent->>MW: TEXT_MESSAGE_CONTENT(๊ฒฐ๊ณผ ์š”์•ฝ)
    MW->>UI: ํ†ต๊ณผ

๋„๊ตฌ ์‹คํ–‰์ด ์ด๋ฒคํŠธ๊ฐ€ emit๋˜๊ธฐ ์ „์— ์ด๋ฏธ ๋ฐฑ์—”๋“œ์—์„œ ๋๋‚ฌ๋‹ค. ๋ฏธ๋“ค์›จ์–ด๊ฐ€ ๋ณด๋Š” TOOL_CALL_*๋Š” โ€œ์‹คํ–‰๋๋‹ค๋Š” ์•Œ๋ฆผโ€์ผ ๋ฟ์ด๋ฏ€๋กœ ์ฐจ๋‹จํ•ด๋„ ์‹คํ–‰์—๋Š” ์˜ํ–ฅ์ด ์—†๊ณ  UI ํ‘œ์‹œ๋งŒ ๊ฐ€๋ ค์ง„๋‹ค. ๋‚ด๋ถ€ ํ—ฌํผ ๋„๊ตฌ๋‚˜ ๋””๋ฒ„๊ทธ์šฉ ๊ฒ€์ƒ‰์ฒ˜๋Ÿผ UI ๋…ธ์ด์ฆˆ๋ฅผ ์ค„์ด๊ณ  ์‹ถ์„ ๋•Œ ์œ ์šฉํ•˜๋‹ค.

Frontend ๋„๊ตฌ โ€” ํŠธ๋ฆฌ๊ฑฐ ์ž์ฒด๊ฐ€ ์‚ฌ๋ผ์ง„๋‹ค

Frontend ๋„๊ตฌ๋Š” ๋ฐฑ์—”๋“œ๊ฐ€ ์‹คํ–‰ํ•˜์ง€ ์•Š๊ณ , โ€œUI์•ผ, ์ด ๋„๊ตฌ ์ข€ ์‹คํ–‰ํ•ด์ค˜โ€๋ผ๋Š” ์š”์ฒญ ์ด๋ฒคํŠธ๋งŒ ๋ณด๋‚ธ๋‹ค. UI๊ฐ€ ๊ทธ ์ด๋ฒคํŠธ๋ฅผ ๋ฐ›์•„์„œ ์ง์ ‘ ์‹คํ–‰ํ•œ๋‹ค(์˜ˆ: confirm() ๋‹ค์ด์–ผ๋กœ๊ทธ, DOM ์กฐ์ž‘, ๋กœ์ปฌ ํŒŒ์ผ ์ ‘๊ทผ).

sequenceDiagram
    autonumber
    participant UI
    participant MW as filterToolCallsMiddleware
    participant Agent as ๋ฐฑ์—”๋“œ agent
    participant FETool as UI ๋‚ด๋ถ€์˜<br/>tool executor

    UI->>MW: Phase 1: runAgent(input)
    MW->>Agent: input ํ†ต๊ณผ
    Agent->>Agent: LLM์ด 'confirm()' ๋„๊ตฌ ํ˜ธ์ถœ ๊ฒฐ์ •
    Note over Agent: ๋ฐฑ์—”๋“œ๋Š” ์‹คํ–‰ ์•ˆ ํ•จ<br/>"UI์— ์œ„์ž„" ๋ชจ๋“œ
    Agent->>MW: Phase 2: TOOL_CALL_START(name='confirm')
    MW->>MW: ์ฐจ๋‹จ ๋Œ€์ƒ โ†’ DROP
    Note over MW,UI: UI๋Š” ์ด๋ฒคํŠธ๋ฅผ ๋ชป ๋ด„
    Note over UI,FETool: ๋„๊ตฌ ์‹คํ–‰ ํŠธ๋ฆฌ๊ฑฐ ์ž์ฒด๊ฐ€<br/>์˜ค์ง€ ์•Š์Œ โ†’ ์‹คํ–‰ ์•ˆ ๋จ

๋„๊ตฌ ์‹คํ–‰์ด ์ด๋ฒคํŠธ๊ฐ€ UI์— ๋„์ฐฉํ•ด์•ผ ๋น„๋กœ์†Œ ํŠธ๋ฆฌ๊ฑฐ๋œ๋‹ค. ๋ฏธ๋“ค์›จ์–ด๊ฐ€ ๊ทธ ์ด๋ฒคํŠธ๋ฅผ ์ฐจ๋‹จํ•˜๋ฉด UI๋Š” ํŠธ๋ฆฌ๊ฑฐ๋ฅผ ๋ฐ›์ง€ ๋ชปํ•˜๊ณ , ์‹คํ–‰ ์ž์ฒด๊ฐ€ ์ผ์–ด๋‚˜์ง€ ์•Š๋Š”๋‹ค.

๊ฐ™์€ ์ฐจ๋‹จ, ๋‹ค๋ฅธ ๊ฒฐ๊ณผ

์ผ€์ด์Šค๋„๊ตฌ ์‹คํ–‰ ์‹œ์ ๋ฏธ๋“ค์›จ์–ด๊ฐ€ ์ฐจ๋‹จํ•˜๋Š” ๊ฒƒ์ฐจ๋‹จ์˜ ํšจ๊ณผ
Backend ๋„๊ตฌPhase 2 emit ์ „TOOL_CALL_* ์ด๋ฒคํŠธUI ํ‘œ์‹œ๋งŒ ์ฐจ๋‹จ. ์‹คํ–‰์€ ์ด๋ฏธ ๋๋‚จ
Frontend ๋„๊ตฌPhase 2 emit์„ ํŠธ๋ฆฌ๊ฑฐ๋กœ ์‚ฌ์šฉTOOL_CALL_* ์ด๋ฒคํŠธํŠธ๋ฆฌ๊ฑฐ ์ž์ฒด๊ฐ€ ์ฐจ๋‹จ โ†’ ์‹คํ–‰ ์•ˆ ๋จ

์ด ๋น„๋Œ€์นญ ๋•Œ๋ฌธ์— FilterToolCallsMiddleware๋ฅผ ๋ณด์•ˆ ํ†ต์ œ๋กœ ์“ฐ๋ฉด ์•ˆ ๋œ๋‹ค. ์ง„์งœ ์œ„ํ—˜ํ•œ ๋„๊ตฌ๋ฅผ ์ฐจ๋‹จํ•˜๋ ค๋ฉด:

  • Backend ๋„๊ตฌ๋Š” ๋ฐฑ์—”๋“œ ๊ถŒํ•œ ์‹œ์Šคํ…œ์—์„œ ์ฐจ๋‹จํ•ด์•ผ ํ•œ๋‹ค. ๋ฏธ๋“ค์›จ์–ด๋Š” ๋ชจ๋ธ ํ˜ธ์ถœ๊ณผ ์‹ค์ œ ์‹คํ–‰์„ ๋ง‰์„ ์ˆ˜ ์—†๋‹ค.
  • Frontend ๋„๊ตฌ๋Š” ๋ฏธ๋“ค์›จ์–ด๋กœ ์ด๋ฒคํŠธ๋ฅผ ์ฐจ๋‹จํ•˜๋ฉด ์‹คํ–‰์€ ๋ง‰์„ ์ˆ˜ ์žˆ์œผ๋‚˜, ๋ชจ๋ธ์€ ์—ฌ์ „ํžˆ ํ˜ธ์ถœ ์˜๋„๋ฅผ ๊ฐ€์ง€๊ณ  ์žˆ๋‹ค. ๋ชจ๋ธ ์ธก ๊ฐ€๋“œ๋„ ํ•จ๊ป˜ ํ•„์š”ํ•˜๋‹ค.

๋ฏธ๋“ค์›จ์–ด ํ•„ํ„ฐ์˜ ์ง„์งœ ์šฉ๋„๋Š” ๋ณด์•ˆ์ด ์•„๋‹ˆ๋ผ UI ๋…ธ์ด์ฆˆ ๊ฐ์†Œ๋ผ๊ณ  ์ •๋ฆฌํ•  ์ˆ˜ ์žˆ๋‹ค.

์‹ค์ „ ํŒจํ„ด 5๊ฐ€์ง€

โ‘  Auth โ€” Phase 1๋งŒ ์‚ฌ์šฉ

const authMiddleware: MiddlewareFunction = (input, next) => {
  const token = localStorage.getItem("jwt")
  return next.run({
    ...input,
    forwardedProps: { ...input.forwardedProps, authorization: `Bearer ${token}` }
  })
}

โ‘ก Tool Filter โ€” Phase 2 ์ฐจ๋‹จ

const dangerousTools = new Set(["deleteAccount", "transferFunds"])
 
const filterDangerousMiddleware: MiddlewareFunction = (input, next) => {
  let blockedId: string | null = null
  return next.run(input).pipe(
    filter(event => {
      if (event.type === EventType.TOOL_CALL_START && dangerousTools.has(event.toolCallName)) {
        blockedId = event.toolCallId
        return false
      }
      if ((event.type === EventType.TOOL_CALL_ARGS || event.type === EventType.TOOL_CALL_END)
          && event.toolCallId === blockedId) {
        return false
      }
      return true
    })
  )
}

โ‘ข Profanity Filter โ€” Phase 2 ๋ณ€ํ˜•

const profanityMiddleware: MiddlewareFunction = (input, next) => {
  return next.run(input).pipe(
    map(event => {
      if (event.type === EventType.TEXT_MESSAGE_CONTENT ||
          event.type === EventType.TEXT_MESSAGE_CHUNK) {
        return { ...event, delta: maskProfanity(event.delta) }
      }
      return event
    })
  )
}

โ‘ฃ Logging โ€” ์–‘์ชฝ ํŽ˜์ด์ฆˆ

const loggingMiddleware: MiddlewareFunction = (input, next) => {
  console.log(`[REQ] runId=${input.runId} threadId=${input.threadId}`)
  const start = Date.now()
  return next.run(input).pipe(
    tap(event => console.log(`[EVT] ${event.type}`)),
    finalize(() => console.log(`[DONE] ${Date.now() - start}ms`))
  )
}

โ‘ค Rate Limit โ€” Phase 1 ์ง€์—ฐ

let lastRunAt = 0
const MIN_INTERVAL = 2000
 
const rateLimitMiddleware: MiddlewareFunction = (input, next) => {
  const wait = Math.max(0, lastRunAt + MIN_INTERVAL - Date.now())
  lastRunAt = Date.now() + wait
  return timer(wait).pipe(switchMap(() => next.run(input)))
}

๋“ฑ๋ก ์ˆœ์„œ์™€ ๋ฐฐ์น˜ ์ „๋žต

agent.use(authMiddleware, profanityMiddleware, loggingMiddleware, filterMiddleware)
ํŽ˜์ด์ฆˆํ๋ฆ„ ๋ฐฉํ–ฅ์ˆœ์„œ
Phase 1 (์ž…๋ ฅ)๋ฐ”๊นฅ โ†’ ์•ˆ์ชฝauth โ†’ profanity โ†’ logging โ†’ filter โ†’ agent
Phase 2 (์ด๋ฒคํŠธ)์•ˆ์ชฝ โ†’ ๋ฐ”๊นฅagent โ†’ filter โ†’ logging โ†’ profanity โ†’ auth โ†’ UI

๋ฐฐ์น˜ ๊ฐ€์ด๋“œ:

  • ์ธ์ฆ/์ธ๊ฐ€ โ†’ ๊ฐ€์žฅ ๋ฐ”๊นฅ (์š”์ฒญ ์ฐจ๋‹จ ์‹œ ์•ˆ์ชฝ ์‹คํ–‰ ์•ˆ ๋จ)
  • ๋กœ๊น…/๋ฉ”ํŠธ๋ฆญ โ†’ ์ค‘๊ฐ„ (๋ณ€ํ˜•๋œ ์ž…๋ ฅ๊ณผ ๋ณ€ํ˜•๋œ ์ด๋ฒคํŠธ ๋ชจ๋‘ ๊ด€์ฐฐ)
  • ์ด๋ฒคํŠธ ํ•„ํ„ฐ/๋ณ€ํ˜• โ†’ ๊ฐ€์žฅ ์•ˆ์ชฝ (agent์— ๊ฐ€๊นŒ์šด raw ์ด๋ฒคํŠธ ์ง์ ‘ ์ฒ˜๋ฆฌ)

์ฃผ์˜์‚ฌํ•ญ ๋ฐ ํ•จ์ •

  1. connectAgent()๋Š” ๋ฏธ๋“ค์›จ์–ด๋ฅผ ๊ฑฐ์น˜์ง€ ์•Š๋Š”๋‹ค โ€” connect()๋ฅผ ์ง์ ‘ ํ˜ธ์ถœํ•˜๋ฏ€๋กœ ์ธ์ฆยทํ•„ํ„ฐ ๋“ฑ์ด ์šฐํšŒ๋œ๋‹ค. ๋ฏธ๋“ค์›จ์–ด๋Š” runAgent()์—์„œ๋งŒ ์ ์šฉ๋œ๋‹ค.
  2. FilterToolCallsMiddleware๋Š” ๋ณด์•ˆ ํ†ต์ œ๊ฐ€ ์•„๋‹ˆ๋‹ค โ€” ์ด๋ฒคํŠธ๋งŒ ๊ฐ€๋ฆด ๋ฟ ์‹ค์ œ ๋„๊ตฌ ์‹คํ–‰์€ ๋ง‰์ง€ ์•Š๋Š”๋‹ค. Frontend ๋„๊ตฌ์˜ ๊ฒฝ์šฐ ํŠธ๋ฆฌ๊ฑฐ๊ฐ€ ์ฐจ๋‹จ๋˜์–ด ์‹คํ–‰์€ ๋ง‰ํžˆ์ง€๋งŒ, ๋ชจ๋ธ์€ ์—ฌ์ „ํžˆ ํ˜ธ์ถœ ์˜๋„๋ฅผ ๊ฐ€์ง„๋‹ค.
  3. pipe ์•ˆ ์—ฐ์‚ฐ์€ ์ด๋ฒคํŠธ ๊ฐœ์ˆ˜๋งŒํผ ์‹คํ–‰๋œ๋‹ค โ€” ํ…์ŠคํŠธ์™€ ๋„๊ตฌ ์ธ์ž๊ฐ€ ์ฒญํฌ๋กœ ๋ถ„ํ• ๋˜๋ฏ€๋กœ ๋ฌด๊ฑฐ์šด ๋™๊ธฐ ์—ฐ์‚ฐ์„ ๋„ฃ์œผ๋ฉด ์ŠคํŠธ๋ฆผ ์ „์ฒด๊ฐ€ ๋А๋ ค์ง„๋‹ค.
  4. ์—๋Ÿฌ๋Š” RxJS ์˜คํผ๋ ˆ์ดํ„ฐ๋กœ ์ฒ˜๋ฆฌ โ€” try/catch๊ฐ€ ์•„๋‹Œ catchError๋กœ ๊ฐ์‹ธ์•ผ ์ŠคํŠธ๋ฆผ์ด ํ†ต์งธ๋กœ ์ฃฝ์ง€ ์•Š๋Š”๋‹ค.
  5. ์ƒํƒœ๋ฅผ ๊ฐ€์ง€๋Š” ๋ฏธ๋“ค์›จ์–ด๋Š” ์ธ์Šคํ„ด์Šค ๊ณต์œ  ์ฃผ์˜ โ€” ๋™์ผ ์ธ์Šคํ„ด์Šค๋ฅผ ์—ฌ๋Ÿฌ agent์— ๋“ฑ๋กํ•˜๋ฉด ์นด์šดํ„ฐยท๋ฒ„ํผ ๋“ฑ์ด ์„ž์ธ๋‹ค.

Best Practices

  1. Single responsibility โ€” ํ•œ ๋ฏธ๋“ค์›จ์–ด = ํ•œ ๊ฐ€์ง€ ์ผ
  2. Graceful error handling โ€” catchError๋กœ ์ŠคํŠธ๋ฆผ ๋ณดํ˜ธ
  3. Non-blocking โ€” I/O๋Š” switchMap + from(promise) ๋“ฑ async ํŒจํ„ด
  4. Side-effect ๋ช…์‹œ โ€” state ์ˆ˜์ • ์‹œ ๋ฌธ์„œํ™”
  5. ๋…๋ฆฝ ํ…Œ์ŠคํŠธ โ€” mock next agent๋กœ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ
  6. ์„ฑ๋Šฅ ์ธ์ง€ โ€” ๋ชจ๋“  ์ด๋ฒคํŠธ๊ฐ€ ํ†ต๊ณผํ•˜๋ฏ€๋กœ ๋ฌด๊ฑฐ์šด ์—ฐ์‚ฐ ๊ธˆ์ง€

์ฐธ๊ณ  ๋ฌธ์„œ