diff --git a/agent-docs/agentuity.yaml b/agent-docs/agentuity.yaml index 89d8f17b..e4b99487 100644 --- a/agent-docs/agentuity.yaml +++ b/agent-docs/agentuity.yaml @@ -78,3 +78,6 @@ agents: - id: agent_9ccc5545e93644bd9d7954e632a55a61 name: doc-qa description: Agent that can answer questions based on dev docs as the knowledge base + - id: agent_ddcb59aa4473f1323be5d9f5fb62b74e + name: agent-pulse + description: Agentuity web app agent that converses with users for generate conversation and structured docs tutorials. diff --git a/agent-docs/src/agents/agent-pulse/README.md b/agent-docs/src/agents/agent-pulse/README.md new file mode 100644 index 00000000..8ff25f7c --- /dev/null +++ b/agent-docs/src/agents/agent-pulse/README.md @@ -0,0 +1,102 @@ +# Pulse Agent + +A conversational AI agent for tutorial management built with OpenAI and structured responses. + +## Overview + +Pulse is a friendly AI assistant that helps users discover, start, and navigate through tutorials. It uses OpenAI's GPT-4o-mini with structured response generation to provide both conversational responses and actionable instructions. + +## Architecture + +### Core Components + +- **`index.ts`**: Main agent logic using `generateObject` for structured responses +- **`chat-helpers.ts`**: Conversation history management +- **`tutorial-helpers.ts`**: Tutorial content fetching and formatting +- **`tutorial.ts`**: Tutorial API integration + +### Response Structure + +The agent uses `generateObject` to return structured responses with two parts: + +```typescript +{ + message: string, // Conversational response for the user + actionable?: { // Optional action for the program to execute + type: 'start_tutorial' | 'next_step' | 'previous_step' | 'get_tutorials' | 'none', + tutorialId?: string, + step?: number + } +} +``` + +### How It Works + +1. **User Input**: Agent receives user message and conversation history +2. **LLM Processing**: OpenAI generates structured response with message and optional actionable object +3. **Action Execution**: Program intercepts actionable objects and executes them: + - `get_tutorials`: Fetches available tutorial list + - `start_tutorial`: Fetches real tutorial content from API + - `next_step`/`previous_step`: Navigate through tutorial steps (TODO) +4. **Response**: Returns conversational message plus any additional data (tutorial content, tutorial list, etc.) + +## Key Features + +- **Structured Responses**: Clean separation between conversation and actions +- **Real Tutorial Content**: No hallucinated content - all tutorial data comes from actual APIs +- **Context Awareness**: Maintains conversation history for natural references +- **Extensible Actions**: Easy to add new action types (next step, hints, etc.) +- **Debug Logging**: Comprehensive logging for troubleshooting + +## Example Interactions + +### Starting a Tutorial +**User**: "I want to learn the JavaScript SDK" + +**LLM Response**: +```json +{ + "message": "I'd be happy to help you start the JavaScript SDK tutorial!", + "actionable": { + "type": "start_tutorial", + "tutorialId": "javascript-sdk" + } +} +``` + +**Final Response**: +```json +{ + "response": "I'd be happy to help you start the JavaScript SDK tutorial!", + "tutorialData": { + "type": "tutorial_step", + "tutorialId": "javascript-sdk", + "tutorialTitle": "JavaScript SDK Tutorial", + "currentStep": 1, + "stepContent": "Welcome to the JavaScript SDK tutorial...", + "codeBlock": {...} + }, + "conversationHistory": [...] +} +``` + +### General Conversation +**User**: "What's the difference between TypeScript and JavaScript?" + +**LLM Response**: +```json +{ + "message": "TypeScript is a superset of JavaScript that adds static type checking...", + "actionable": { + "type": "none" + } +} +``` + +## Benefits + +- **Reliable**: No parsing or tool interception needed +- **Extensible**: Easy to add new action types +- **Clean**: Clear separation between conversation and actions +- **Debuggable**: Can see exactly what the LLM wants to do +- **No Hallucination**: Tutorial content comes from real APIs, not LLM generation diff --git a/agent-docs/src/agents/agent-pulse/context/builder.ts b/agent-docs/src/agents/agent-pulse/context/builder.ts new file mode 100644 index 00000000..ca48a4f1 --- /dev/null +++ b/agent-docs/src/agents/agent-pulse/context/builder.ts @@ -0,0 +1,54 @@ +import type { AgentContext } from "@agentuity/sdk"; + +export async function buildSystemPrompt(tutorialContext: string, ctx: AgentContext): Promise { + try { + const systemPrompt = `=== ROLE === +You are Pulse, an AI assistant designed to help developers learn and navigate the Agentuity platform through interactive tutorials and clear guidance. Your primary goal is to assist users with understanding and using the Agentuity SDK effectively. When a user's query is vague, unclear, or lacks specific intent, subtly suggest relevant interactive tutorial to guide them toward learning the platform. For clear, specific questions related to the Agentuity SDK or other topics, provide direct, accurate, and concise answers without mentioning tutorials unless relevant. Always maintain a friendly and approachable tone to encourage engagement. + +Your role is to ensure user have a smooth tutorial experience! + +When user is asking to move to the next tutorial, simply increment the step for them. + +=== PERSONALITY === +- Friendly and encouraging with light humour +- Patient with learners at all levels +- Clear and concise in explanations +- Enthusiastic about teaching and problem-solving + +=== Available Tools or Functions === +You have access to various tools you can use -- use when appropriate! +1. Tutorial management + - startTutorialAtStep: Starting the user off at a specific step of a tutorial. +2. General assistance + - askDocsAgentTool: retrieve Agentuity documentation snippets + +=== TOOL-USAGE RULES (must follow) === +- startTutorialById must only be used when user select a tutorial. If the user starts a new tutorial, the step number should be set to one. Valid step is between 1 and totalSteps of the specific tutorial. +- Treat askDocsAgentTool as a search helper; ignore results you judge irrelevant. + +=== RESPONSE STYLE (format guidelines) === +- Begin with a short answer, then elaborate if necessary. +- Add brief comments to complex code; skip obvious lines. +- End with a question when further clarification could help the user. + +=== SAFETY & BOUNDARIES === +- If asked for private data or secrets, refuse. +- If the user requests actions outside your capabilities, apologise and explain. +- Keep every response < 400 words + +Generate a response to the user query accordingly and try to be helpful + +=== CONTEXT === +${tutorialContext} + +=== END OF PROMPT === + +Stream your reasoning steps clearly.`; + + ctx.logger.debug("Built system prompt with tutorial context"); + return systemPrompt; + } catch (error) { + ctx.logger.error("Failed to build system prompt: %s", error instanceof Error ? error.message : String(error)); + throw error; // Re-throw for centralized handling + } +} \ No newline at end of file diff --git a/agent-docs/src/agents/agent-pulse/index.ts b/agent-docs/src/agents/agent-pulse/index.ts new file mode 100644 index 00000000..5932c5fc --- /dev/null +++ b/agent-docs/src/agents/agent-pulse/index.ts @@ -0,0 +1,143 @@ +import type { AgentRequest, AgentResponse, AgentContext } from "@agentuity/sdk"; +import { streamText } from "ai"; +import { openai } from "@ai-sdk/openai"; +import { createTools } from "./tools"; +import { createAgentState } from "./state"; +import { getTutorialList, type Tutorial } from "./tutorial"; +import { parseAgentRequest } from "./request/parser"; +import { buildSystemPrompt } from "./context/builder"; +import { createStreamingProcessor } from "./streaming/processor"; +import type { ConversationMessage, TutorialState } from "./request/types"; + +/** + * Builds a context string containing available tutorials for the system prompt + */ +async function buildContext( + ctx: AgentContext, + tutorialState?: TutorialState +): Promise { + try { + const tutorials = await getTutorialList(ctx); + + // Handle API failure early + if (!tutorials.success || !tutorials.data) { + ctx.logger.warn("Failed to load tutorial list"); + return defaultFallbackContext(); + } + + const tutorialContent = JSON.stringify(tutorials.data, null, 2); + const currentTutorialInfo = buildCurrentTutorialInfo( + tutorials.data, + tutorialState + ); + + return `===AVAILABLE TUTORIALS==== + + ${tutorialContent} + + ${currentTutorialInfo} + + Note: You should not expose the details of the tutorial IDs to the user. +`; + } catch (error) { + ctx.logger.error("Error building tutorial context: %s", error); + return defaultFallbackContext(); + } +} + +/** + * Builds current tutorial information string if user is in a tutorial + */ +function buildCurrentTutorialInfo( + tutorials: Tutorial[], + tutorialState?: TutorialState +): string { + if (!tutorialState?.tutorialId) { + return ""; + } + + const currentTutorial = tutorials.find( + (t) => t.id === tutorialState.tutorialId + ); + if (!currentTutorial) { + return "\nWarning: User appears to be in an unknown tutorial."; + } + if (tutorialState.currentStep > currentTutorial.totalSteps) { + return `\nUser has completed the tutorial: ${currentTutorial.title} (${currentTutorial.totalSteps} steps)`; + } + return `\nUser is currently on this tutorial: ${currentTutorial.title} (Step ${tutorialState.currentStep} of ${currentTutorial.totalSteps})`; +} + +/** + * Returns fallback context when tutorial list can't be loaded + */ +function defaultFallbackContext(): string { + return `===AVAILABLE TUTORIALS==== +Unable to load tutorial list. Please try again later or contact support.`; +} + +export default async function Agent( + req: AgentRequest, + resp: AgentResponse, + ctx: AgentContext +) { + try { + const parsedRequest = parseAgentRequest(await req.data.json(), ctx); + + // Create state manager + const state = createAgentState(); + + // Build messages for the conversation + const messages: ConversationMessage[] = [ + ...parsedRequest.conversationHistory, + { author: "USER", content: parsedRequest.message }, + ]; + + let tools: any; + let systemPrompt: string = ""; + // Direct LLM access won't require any tools or system prompt + if (!parsedRequest.useDirectLLM) { + // Create tools with state context + tools = await createTools({ + state, + agentContext: ctx, + }); + + // Build tutorial context and system prompt + const tutorialContext = await buildContext( + ctx, + parsedRequest.tutorialData + ); + systemPrompt = await buildSystemPrompt(tutorialContext, ctx); + } + + // Generate streaming response + const result = await streamText({ + model: openai("gpt-4o"), + messages: messages.map((msg) => ({ + role: msg.author === "USER" ? "user" : "assistant", + content: msg.content, + })), + tools, + maxSteps: 3, + system: systemPrompt, + }); + + // Create and return streaming response + const stream = createStreamingProcessor(result, state, ctx); + return resp.stream(stream, "text/event-stream"); + } catch (error) { + ctx.logger.error( + "Agent request failed: %s", + error instanceof Error ? error.message : String(error) + ); + return resp.json( + { + error: + "Sorry, I encountered an error while processing your request. Please try again.", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 } + ); + } +} diff --git a/agent-docs/src/agents/agent-pulse/request/parser.ts b/agent-docs/src/agents/agent-pulse/request/parser.ts new file mode 100644 index 00000000..91c5b254 --- /dev/null +++ b/agent-docs/src/agents/agent-pulse/request/parser.ts @@ -0,0 +1,49 @@ +import type { AgentContext } from "@agentuity/sdk"; +import type { ParsedAgentRequest } from "./types"; + +export function parseAgentRequest( + jsonData: any, + ctx: AgentContext +): ParsedAgentRequest { + try { + let message: string = ""; + let conversationHistory: any[] = []; + let tutorialData: any = undefined; + let useDirectLLM = false; + + if (jsonData && typeof jsonData === "object" && !Array.isArray(jsonData)) { + const body = jsonData as any; + message = body.message || ""; + useDirectLLM = body.use_direct_llm || false; + // Process conversation history + if (Array.isArray(body.conversationHistory)) { + conversationHistory = body.conversationHistory.map((msg: any) => { + // Extract only role and content + return { + role: msg.role || (msg.author ? msg.author.toUpperCase() : "USER"), + content: msg.content || "", + }; + }); + } + + tutorialData = body.tutorialData || undefined; + } else { + // Fallback for non-object data + message = String(jsonData || ""); + } + + return { + message, + conversationHistory, + tutorialData, + useDirectLLM, + }; + } catch (error) { + ctx.logger.error( + "Failed to parse agent request: %s", + error instanceof Error ? error.message : String(error) + ); + ctx.logger.debug("Raw request data: %s", JSON.stringify(jsonData)); + throw error; // Re-throw for centralized handling + } +} diff --git a/agent-docs/src/agents/agent-pulse/request/types.ts b/agent-docs/src/agents/agent-pulse/request/types.ts new file mode 100644 index 00000000..3f14e830 --- /dev/null +++ b/agent-docs/src/agents/agent-pulse/request/types.ts @@ -0,0 +1,16 @@ +export interface ConversationMessage { + author: "USER" | "ASSISTANT"; + content: string; +} + +export interface TutorialState { + tutorialId: string; + currentStep: number; +} + +export interface ParsedAgentRequest { + message: string; + conversationHistory: ConversationMessage[]; + tutorialData?: TutorialState; + useDirectLLM?: boolean; +} \ No newline at end of file diff --git a/agent-docs/src/agents/agent-pulse/state.ts b/agent-docs/src/agents/agent-pulse/state.ts new file mode 100644 index 00000000..2cc4bc83 --- /dev/null +++ b/agent-docs/src/agents/agent-pulse/state.ts @@ -0,0 +1,46 @@ +enum ActionType { + START_TUTORIAL_STEP = "start_tutorial_step" +} + +interface Action { + type: ActionType; + tutorialId: string; + currentStep: number; + totalSteps: number; +} + +interface AgentState { + action: Action | null; + + setAction(action: Action): void; + getAction(): Action | null; + clearAction(): void; + hasAction(): boolean; +} + +class SimpleAgentState implements AgentState { + public action: Action | null = null; + + setAction(action: Action): void { + this.action = action; + } + + getAction(): Action | null { + return this.action; + } + + clearAction(): void { + this.action = null; + } + + hasAction(): boolean { + return this.action !== null; + } +} + +export function createAgentState(): AgentState { + return new SimpleAgentState(); +} + +export type { Action, AgentState }; +export { ActionType }; \ No newline at end of file diff --git a/agent-docs/src/agents/agent-pulse/state/manager.ts b/agent-docs/src/agents/agent-pulse/state/manager.ts new file mode 100644 index 00000000..00c1c570 --- /dev/null +++ b/agent-docs/src/agents/agent-pulse/state/manager.ts @@ -0,0 +1,54 @@ +import type { AgentContext } from "@agentuity/sdk"; +import { ActionType, type AgentState } from "../state"; +import { getTutorialStep } from "../tutorial"; +import type { TutorialData } from "../streaming/types"; + +export async function handleTutorialState( + state: AgentState, + ctx: AgentContext +): Promise { + try { + if (!state.hasAction()) { + return null; + } + + const action = state.getAction(); + if (!action) { + ctx.logger.warn("No action found in state"); + return null; + } + + ctx.logger.info("Processing action: %s", JSON.stringify(action, null, 2)); + + switch (action.type) { + case ActionType.START_TUTORIAL_STEP: + if (action.tutorialId) { + const tutorialStep = await getTutorialStep(action.tutorialId, action.currentStep, ctx); + if (tutorialStep.success && tutorialStep.data) { + const tutorialData: TutorialData = { + tutorialId: action.tutorialId, + totalSteps: action.totalSteps, + currentStep: action.currentStep, + tutorialStep: { + title: (tutorialStep.data.meta?.title as string) || tutorialStep.data.slug, + mdx: tutorialStep.data.mdx, + snippets: tutorialStep.data.snippets, + totalSteps: action.totalSteps + } + }; + state.clearAction(); + ctx.logger.info("Tutorial state processed successfully"); + return tutorialData; + } + } + break; + default: + ctx.logger.warn("Unknown action type: %s", action.type); + } + + return null; + } catch (error) { + ctx.logger.error("Failed to handle tutorial state: %s", error instanceof Error ? error.message : String(error)); + throw error; // Re-throw for centralized handling + } +} \ No newline at end of file diff --git a/agent-docs/src/agents/agent-pulse/streaming/processor.ts b/agent-docs/src/agents/agent-pulse/streaming/processor.ts new file mode 100644 index 00000000..af9b2409 --- /dev/null +++ b/agent-docs/src/agents/agent-pulse/streaming/processor.ts @@ -0,0 +1,115 @@ +import type { AgentContext } from "@agentuity/sdk"; +import type { AgentState } from "../state"; +import type { StreamingChunk } from "./types"; +import { handleTutorialState } from "../state/manager"; + +export function createStreamingProcessor( + result: any, + state: AgentState, + ctx: AgentContext +): ReadableStream { + return new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + let accumulatedContent = ""; + + try { + // Stream only safe, user-facing content + for await (const chunk of result.fullStream) { + await processChunk( + chunk, + controller, + encoder, + ctx, + accumulatedContent + ); + } + + // Process tutorial state after streaming text + const finalTutorialData = await handleTutorialState(state, ctx); + + // Send tutorial data if available + if (finalTutorialData) { + sendChunk(controller, encoder, { + type: "tutorial-data", + tutorialData: finalTutorialData, + }); + } + + // Send finish signal + sendChunk(controller, encoder, { type: "finish" }); + + controller.close(); + } catch (error) { + ctx.logger.error( + "Error in streaming response: %s", + error instanceof Error ? error.message : String(error) + ); + sendChunk(controller, encoder, { + type: "error", + error: "Sorry, I encountered an error while processing your request.", + details: error instanceof Error ? error.message : String(error), + }); + controller.close(); + } + }, + }); +} + +async function processChunk( + chunk: any, + controller: ReadableStreamDefaultController, + encoder: TextEncoder, + ctx: AgentContext, + accumulatedContent: string +): Promise { + try { + if (chunk.type === "text-delta") { + accumulatedContent += chunk.textDelta; + sendChunk(controller, encoder, { + type: "text-delta", + textDelta: chunk.textDelta, + }); + } else if (chunk.type === "tool-call") { + const toolName = chunk.toolName || "tool"; + const userFriendlyMessage = getToolStatusMessage(toolName); + sendChunk(controller, encoder, { + type: "status", + message: userFriendlyMessage, + category: "tool", + }); + ctx.logger.debug("Tool called: %s", toolName); + } else if (chunk.type === "reasoning") { + ctx.logger.debug("REASONING: %s", chunk); + } else { + ctx.logger.debug("Skipping chunk type: %s", chunk.type); + ctx.logger.debug(chunk); + } + } catch (error) { + ctx.logger.error( + "Failed to process chunk: %s", + error instanceof Error ? error.message : String(error) + ); + ctx.logger.debug("Chunk data: %s", JSON.stringify(chunk)); + throw error; // Re-throw for centralized handling + } +} + +function sendChunk( + controller: ReadableStreamDefaultController, + encoder: TextEncoder, + chunk: StreamingChunk +): void { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); +} + +function getToolStatusMessage(toolName: string): string { + switch (toolName) { + case "startTutorialById": + return "Starting tutorial..."; + case "queryOtherAgent": + return "Searching documentation..."; + default: + return "Processing your request..."; + } +} diff --git a/agent-docs/src/agents/agent-pulse/streaming/types.ts b/agent-docs/src/agents/agent-pulse/streaming/types.ts new file mode 100644 index 00000000..5fcaad52 --- /dev/null +++ b/agent-docs/src/agents/agent-pulse/streaming/types.ts @@ -0,0 +1,48 @@ +export interface TextDeltaChunk { + type: 'text-delta'; + textDelta: string; +} + +export interface StatusChunk { + type: 'status'; + message: string; + category?: 'tool' | 'search' | 'processing'; +} + +export interface TutorialSnippet { + path: string; + lang?: string; + from?: number; + to?: number; + title?: string; + content: string; +} + +export interface TutorialData { + tutorialId: string; + totalSteps: number; + currentStep: number; + tutorialStep: { + title: string; + mdx: string; + snippets: TutorialSnippet[]; + totalSteps: number; + }; +} + +export interface TutorialDataChunk { + type: 'tutorial-data'; + tutorialData: TutorialData; +} + +export interface ErrorChunk { + type: 'error'; + error: string; + details?: string; +} + +export interface FinishChunk { + type: 'finish'; +} + +export type StreamingChunk = TextDeltaChunk | StatusChunk | TutorialDataChunk | ErrorChunk | FinishChunk; \ No newline at end of file diff --git a/agent-docs/src/agents/agent-pulse/tools.ts b/agent-docs/src/agents/agent-pulse/tools.ts new file mode 100644 index 00000000..a53b20d5 --- /dev/null +++ b/agent-docs/src/agents/agent-pulse/tools.ts @@ -0,0 +1,103 @@ +import { tool } from "ai"; +import { z } from "zod"; +import { ActionType } from "./state"; +import type { AgentState } from "./state"; +import type { AgentContext } from "@agentuity/sdk"; +import { getTutorialMeta } from "./tutorial"; + +/** + * Context passed to tools for state management and logging + */ +interface ToolContext { + state: AgentState; + agentContext: AgentContext; +} + +/** + * Factory function that creates tools with state management context + */ +export async function createTools(context: ToolContext) { + const { state, agentContext } = context; + const DOC_QA_AGENT_NAME = "doc-qa"; + const docQaAgent = await agentContext.getAgent({ name: DOC_QA_AGENT_NAME }); + /** + * Tool for starting a tutorial - adds action to state queue + */ + const startTutorialAtStep = tool({ + description: "Start a specific tutorial for the user. You must call this function in order for the user to see the tutorial step content. The step number should be between 1 and the total number of steps in the tutorial.", + parameters: z.object({ + tutorialId: z.string().describe("The exact ID of the tutorial to start"), + stepNumber: z.number().describe("The step number of the tutorial to start (1 to total available steps in the tutorial)") + }), + execute: async ({ tutorialId, stepNumber }) => { + // Validate tutorial exists before starting + const tutorialResponse = await getTutorialMeta(tutorialId, agentContext); + if (!tutorialResponse.success || !tutorialResponse.data) { + return `Error fetching tutorial information`; + } + + const data = tutorialResponse.data + const totalSteps = tutorialResponse.data.totalSteps; + if (stepNumber > totalSteps) { + return `This tutorial only has ${totalSteps} steps. You either reached the end of the tutorial or selected an incorrect step number.`; + } + state.setAction({ + type: ActionType.START_TUTORIAL_STEP, + tutorialId: tutorialId, + currentStep: stepNumber, + totalSteps: tutorialResponse.data.totalSteps + }); + agentContext.logger.info("Added start_tutorial action to state for: %s at step %d", tutorialId, stepNumber); + return `Starting "${data.title}". Total steps: ${data.totalSteps} \n\n Description: ${data.description}`; + }, + }); + + /** + * Tool for talking to other agents (nong-tutorial functionality) + * This tool doesn't use state - it returns data directly + */ + const askDocsAgentTool = tool({ + description: "Query the Agentuity Development Documentation agent using RAG (Retrieval Augmented Generation) to get relevant documentation and answers about the Agentuity platform, APIs, and development concepts", + parameters: z.object({ + query: z.string().describe("The question or query to send to the query function"), + }), + execute: async ({ query }) => { + agentContext.logger.info("Querying agent %s with: %s", DOC_QA_AGENT_NAME, query); + const agentPayload = { + message: query, + + } + const response = await docQaAgent.run({ + data: agentPayload, + contentType: 'application/json' + }) + // TODO: handle the docs referencing and inject it to the frontend response + const responseData = await response.data.json(); + return responseData; + }, + }); + + /** + * TODO: This tool allow the agent to get details information about the code execution that the user performed. + */ + const fetchCodeExecutionResultTool = tool({ + description: "Fetch code execution results from the frontend", + parameters: z.object({ + executionId: z.string().describe("The ID of the code execution"), + }), + execute: async ({ executionId }) => { + agentContext.logger.info("Fetching execution result for: %s", executionId); + // This would actually fetch execution results + // For now, just return a mock response + return `Result for execution ${executionId}: console.log('Hello, World!');\n// Output: Hello, World!`; + }, + }); + + // Return tools object + return { + startTutorialById: startTutorialAtStep, + queryOtherAgent: askDocsAgentTool, + }; +} + +export type { ToolContext }; \ No newline at end of file diff --git a/agent-docs/src/agents/agent-pulse/tutorial.ts b/agent-docs/src/agents/agent-pulse/tutorial.ts new file mode 100644 index 00000000..c5a00ed1 --- /dev/null +++ b/agent-docs/src/agents/agent-pulse/tutorial.ts @@ -0,0 +1,98 @@ +import type { AgentContext } from '@agentuity/sdk'; + +const TUTORIAL_API_BASE_URL = process.env.TUTORIAL_API_URL; + +export interface Tutorial { + id: string; + title: string; + description: string; + totalSteps: number; +} + +interface ApiResponse { + success: boolean; + data?: T; + error?: string; + status?: number; +} + +export interface TutorialSnippet { + path: string; + lang?: string; + from?: number; + to?: number; + title?: string; + content: string; +} + +interface TutorialStepResponseData { + tutorialId: string; + stepNumber: number; + slug: string; + meta: Record; + mdx: string; + snippets: TutorialSnippet[]; +} + +export async function getTutorialList(ctx: AgentContext): Promise> { + try { + const response = await fetch(`${TUTORIAL_API_BASE_URL}/api/tutorials`); + + if (!response.ok) { + const error = await response.json(); + ctx.logger.error('Tutorial API error: %s', error); + throw new Error(`Tutorial API error: ${JSON.stringify(error)}`); + } + + const tutorials = await response.json() as Tutorial[]; + ctx.logger.info('Fetched %d tutorials', tutorials.length); + + return { + success: true, + data: tutorials + }; + } catch (error) { + ctx.logger.error('Error fetching tutorial list: %s', error); + throw error; + } +} + +export async function getTutorialMeta(tutorialId: string, ctx: AgentContext): Promise> { + try { + // New behavior: fetch all, then find by id + const response = await fetch(`${TUTORIAL_API_BASE_URL}/api/tutorials`); + if (!response.ok) { + const error = `Failed to fetch tutorial list: ${response.statusText}`; + ctx.logger.error(error); + return { success: false, status: response.status, error: response.statusText }; + } + const tutorials = (await response.json()) as Tutorial[]; + const found = tutorials.find(t => t.id === tutorialId); + if (!found) { + return { success: false, status: 404, error: 'Tutorial not found' }; + } + return { success: true, data: found }; + } catch (error) { + ctx.logger.error('Error fetching tutorial metadata for %s: %s', tutorialId, error); + throw error; + } +} + +export async function getTutorialStep(tutorialId: string, stepNumber: number, ctx: AgentContext): Promise> { + try { + const response = await fetch(`${TUTORIAL_API_BASE_URL}/api/tutorials/${tutorialId}/steps/${stepNumber}`); + + if (!response.ok) { + ctx.logger.error('Failed to fetch tutorial step %d for tutorial %s: %s', stepNumber, tutorialId, response.statusText); + return { success: false, status: response.status, error: response.statusText }; + } + + const responseData = await response.json(); + ctx.logger.info('Fetched step %d for tutorial %s', stepNumber, tutorialId); + + return responseData as ApiResponse; + } catch (error) { + ctx.logger.error('Error fetching tutorial step %d for tutorial %s: %s', stepNumber, tutorialId, error); + throw error; + } +} \ No newline at end of file