diff --git a/.github/instructions/agent-skills.instructions.md b/.github/instructions/agent-skills.instructions.md new file mode 100644 index 00000000..d0de7307 --- /dev/null +++ b/.github/instructions/agent-skills.instructions.md @@ -0,0 +1,261 @@ +--- +description: 'Guidelines for creating high-quality Agent Skills for GitHub Copilot' +applyTo: '**/.github/skills/**/SKILL.md, **/.claude/skills/**/SKILL.md' +--- + +# Agent Skills File Guidelines + +Instructions for creating effective and portable Agent Skills that enhance GitHub Copilot with specialized capabilities, workflows, and bundled resources. + +## What Are Agent Skills? + +Agent Skills are self-contained folders with instructions and bundled resources that teach AI agents specialized capabilities. Unlike custom instructions (which define coding standards), skills enable task-specific workflows that can include scripts, examples, templates, and reference data. + +Key characteristics: +- **Portable**: Works across VS Code, Copilot CLI, and Copilot coding agent +- **Progressive loading**: Only loaded when relevant to the user's request +- **Resource-bundled**: Can include scripts, templates, examples alongside instructions +- **On-demand**: Activated automatically based on prompt relevance + +## Directory Structure + +Skills are stored in specific locations: + +| Location | Scope | Recommendation | +|----------|-------|----------------| +| `.github/skills//` | Project/repository | Recommended for project skills | +| `.claude/skills//` | Project/repository | Legacy, for backward compatibility | +| `~/.github/skills//` | Personal (user-wide) | Recommended for personal skills | +| `~/.claude/skills//` | Personal (user-wide) | Legacy, for backward compatibility | + +Each skill **must** have its own subdirectory containing at minimum a `SKILL.md` file. + +## Required SKILL.md Format + +### Frontmatter (Required) + +```yaml +--- +name: webapp-testing +description: Toolkit for testing local web applications using Playwright. Use when asked to verify frontend functionality, debug UI behavior, capture browser screenshots, check for visual regressions, or view browser console logs. Supports Chrome, Firefox, and WebKit browsers. +license: Complete terms in LICENSE.txt +--- +``` + +| Field | Required | Constraints | +|-------|----------|-------------| +| `name` | Yes | Lowercase, hyphens for spaces, max 64 characters (e.g., `webapp-testing`) | +| `description` | Yes | Clear description of capabilities AND use cases, max 1024 characters | +| `license` | No | Reference to LICENSE.txt (e.g., `Complete terms in LICENSE.txt`) or SPDX identifier | + +### Description Best Practices + +**CRITICAL**: The `description` field is the PRIMARY mechanism for automatic skill discovery. Copilot reads ONLY the `name` and `description` to decide whether to load a skill. If your description is vague, the skill will never be activated. + +**What to include in description:** +1. **WHAT** the skill does (capabilities) +2. **WHEN** to use it (specific triggers, scenarios, file types, or user requests) +3. **Keywords** that users might mention in their prompts + +**Good description:** +```yaml +description: Toolkit for testing local web applications using Playwright. Use when asked to verify frontend functionality, debug UI behavior, capture browser screenshots, check for visual regressions, or view browser console logs. Supports Chrome, Firefox, and WebKit browsers. +``` + +**Poor description:** +```yaml +description: Web testing helpers +``` + +The poor description fails because: +- No specific triggers (when should Copilot load this?) +- No keywords (what user prompts would match?) +- No capabilities (what can it actually do?) + +### Body Content + +The body contains detailed instructions that Copilot loads AFTER the skill is activated. Recommended sections: + +| Section | Purpose | +|---------|---------| +| `# Title` | Brief overview of what this skill enables | +| `## When to Use This Skill` | List of scenarios (reinforces description triggers) | +| `## Prerequisites` | Required tools, dependencies, environment setup | +| `## Step-by-Step Workflows` | Numbered steps for common tasks | +| `## Troubleshooting` | Common issues and solutions table | +| `## References` | Links to bundled docs or external resources | + +## Bundling Resources + +Skills can include additional files that Copilot accesses on-demand: + +### Supported Resource Types + +| Folder | Purpose | Loaded into Context? | Example Files | +|--------|---------|---------------------|---------------| +| `scripts/` | Executable automation that performs specific operations | When executed | `helper.py`, `validate.sh`, `build.ts` | +| `references/` | Documentation the AI agent reads to inform decisions | Yes, when referenced | `api_reference.md`, `schema.md`, `workflow_guide.md` | +| `assets/` | **Static files used AS-IS** in output (not modified by the AI agent) | No | `logo.png`, `brand-template.pptx`, `custom-font.ttf` | +| `templates/` | **Starter code/scaffolds that the AI agent MODIFIES** and builds upon | Yes, when referenced | `viewer.html` (insert algorithm), `hello-world/` (extend) | + +### Directory Structure Example + +``` +.github/skills/my-skill/ +├── SKILL.md # Required: Main instructions +├── LICENSE.txt # Recommended: License terms (Apache 2.0 typical) +├── scripts/ # Optional: Executable automation +│ ├── helper.py # Python script +│ └── helper.ps1 # PowerShell script +├── references/ # Optional: Documentation loaded into context +│ ├── api_reference.md +│ ├── workflow-setup.md # Detailed workflow (>5 steps) +│ └── workflow-deployment.md +├── assets/ # Optional: Static files used AS-IS in output +│ ├── baseline.png # Reference image for comparison +│ └── report-template.html +└── templates/ # Optional: Starter code the AI agent modifies + ├── scaffold.py # Code scaffold the AI agent customizes + └── config.template # Config template the AI agent fills in +``` + +> **LICENSE.txt**: When creating a skill, download the Apache 2.0 license text from https://www.apache.org/licenses/LICENSE-2.0.txt and save as `LICENSE.txt`. Update the copyright year and owner in the appendix section. + +### Assets vs Templates: Key Distinction + +**Assets** are static resources **consumed unchanged** in the output: +- A `logo.png` that gets embedded into a generated document +- A `report-template.html` copied as output format +- A `custom-font.ttf` applied to text rendering + +**Templates** are starter code/scaffolds that **the AI agent actively modifies**: +- A `scaffold.py` where the AI agent inserts logic +- A `config.template` where the AI agent fills in values based on user requirements +- A `hello-world/` project directory that the AI agent extends with new features + +**Rule of thumb**: If the AI agent reads and builds upon the file content → `templates/`. If the file is used as-is in output → `assets/`. + +### Referencing Resources in SKILL.md + +Use relative paths to reference files within the skill directory: + +```markdown +## Available Scripts + +Run the [helper script](./scripts/helper.py) to automate common tasks. + +See [API reference](./references/api_reference.md) for detailed documentation. + +Use the [scaffold](./templates/scaffold.py) as a starting point. +``` + +## Progressive Loading Architecture + +Skills use three-level loading for efficiency: + +| Level | What Loads | When | +|-------|------------|------| +| 1. Discovery | `name` and `description` only | Always (lightweight metadata) | +| 2. Instructions | Full `SKILL.md` body | When request matches description | +| 3. Resources | Scripts, examples, docs | Only when Copilot references them | + +This means: +- Install many skills without consuming context +- Only relevant content loads per task +- Resources don't load until explicitly needed + +## Content Guidelines + +### Writing Style + +- Use imperative mood: "Run", "Create", "Configure" (not "You should run") +- Be specific and actionable +- Include exact commands with parameters +- Show expected outputs where helpful +- Keep sections focused and scannable + +### Script Requirements + +When including scripts, prefer cross-platform languages: + +| Language | Use Case | +|----------|----------| +| Python | Complex automation, data processing | +| pwsh | PowerShell Core scripting | +| Node.js | JavaScript-based tooling | +| Bash/Shell | Simple automation tasks | + +Best practices: +- Include help/usage documentation (`--help` flag) +- Handle errors gracefully with clear messages +- Avoid storing credentials or secrets +- Use relative paths where possible + +### When to Bundle Scripts + +Include scripts in your skill when: +- The same code would be rewritten repeatedly by the agent +- Deterministic reliability is critical (e.g., file manipulation, API calls) +- Complex logic benefits from being pre-tested rather than generated each time +- The operation has a self-contained purpose that can evolve independently +- Testability matters — scripts can be unit tested and validated +- Predictable behavior is preferred over dynamic generation + +Scripts enable evolution: even simple operations benefit from being implemented as scripts when they may grow in complexity, need consistent behavior across invocations, or require future extensibility. + +### Security Considerations + +- Scripts rely on existing credential helpers (no credential storage) +- Include `--force` flags only for destructive operations +- Warn users before irreversible actions +- Document any network operations or external calls + +## Common Patterns + +### Parameter Table Pattern + +Document parameters clearly: + +```markdown +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `--input` | Yes | - | Input file or URL to process | +| `--action` | Yes | - | Action to perform | +| `--verbose` | No | `false` | Enable verbose output | +``` + +## Validation Checklist + +Before publishing a skill: + +- [ ] `SKILL.md` has valid frontmatter with `name` and `description` +- [ ] `name` is lowercase with hyphens, ≤64 characters +- [ ] `description` clearly states **WHAT** it does, **WHEN** to use it, and relevant **KEYWORDS** +- [ ] Body includes when to use, prerequisites, and step-by-step workflows +- [ ] SKILL.md body kept under 500 lines (split large content into `references/` folder) +- [ ] Large workflows (>5 steps) split into `references/` folder with clear links from SKILL.md +- [ ] Scripts include help documentation and error handling +- [ ] Relative paths used for all resource references +- [ ] No hardcoded credentials or secrets + +## Workflow Execution Pattern + +When executing multi-step workflows, create a TODO list where each step references the relevant documentation: + +```markdown +## TODO +- [ ] Step 1: Configure environment - see [workflow-setup.md](./references/workflow-setup.md#environment) +- [ ] Step 2: Build project - see [workflow-setup.md](./references/workflow-setup.md#build) +- [ ] Step 3: Deploy to staging - see [workflow-deployment.md](./references/workflow-deployment.md#staging) +- [ ] Step 4: Run validation - see [workflow-deployment.md](./references/workflow-deployment.md#validation) +- [ ] Step 5: Deploy to production - see [workflow-deployment.md](./references/workflow-deployment.md#production) +``` + +This ensures traceability and allows resuming workflows if interrupted. + +## Related Resources + +- [Agent Skills Specification](https://agentskills.io/) +- [VS Code Agent Skills Documentation](https://code.visualstudio.com/docs/copilot/customization/agent-skills) +- [Reference Skills Repository](https://github.com/anthropics/skills) +- [Awesome Copilot Skills](https://github.com/github/awesome-copilot/blob/main/docs/README.skills.md) diff --git a/convex/mastra/storage.ts b/convex/mastra/storage.ts new file mode 100644 index 00000000..f5d334a1 --- /dev/null +++ b/convex/mastra/storage.ts @@ -0,0 +1,3 @@ +import { mastraStorage } from '@mastra/convex/server'; + +export const handle = mastraStorage; diff --git a/convex/schema.ts b/convex/schema.ts new file mode 100644 index 00000000..ba6923e4 --- /dev/null +++ b/convex/schema.ts @@ -0,0 +1,22 @@ +import { defineSchema } from 'convex/server'; +import { + mastraThreadsTable, + mastraMessagesTable, + mastraResourcesTable, + mastraWorkflowSnapshotsTable, + mastraScoresTable, + mastraVectorIndexesTable, + mastraVectorsTable, + mastraDocumentsTable, +} from '@mastra/convex/schema'; + +export default defineSchema({ + mastra_threads: mastraThreadsTable, + mastra_messages: mastraMessagesTable, + mastra_resources: mastraResourcesTable, + mastra_workflow_snapshots: mastraWorkflowSnapshotsTable, + mastra_scorers: mastraScoresTable, + mastra_vector_indexes: mastraVectorIndexesTable, + mastra_vectors: mastraVectorsTable, + mastra_documents: mastraDocumentsTable, +}); diff --git a/docs/Tool-Lifecycle-Hooks.md b/docs/Tool-Lifecycle-Hooks.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/tools-worklows/Reference ChunkType Streaming Mastra Docs v1 Beta.md b/docs/tools-worklows/Reference ChunkType Streaming Mastra Docs v1 Beta.md new file mode 100644 index 00000000..7441e020 --- /dev/null +++ b/docs/tools-worklows/Reference ChunkType Streaming Mastra Docs v1 Beta.md @@ -0,0 +1,251 @@ +--- +title: "Reference: ChunkType | Streaming | Mastra Docs v1 Beta" +source: "https://mastra.ai/reference/v1/streaming/ChunkType" +author: +published: +created: 2026-01-13 +description: "Mastra v1 Beta: Documentation for the ChunkType type used in Mastra streaming responses, defining all possible chunk types and their payloads." +tags: + - "clippings" +--- +The `ChunkType` type defines the mastra format of stream chunks that can be emitted during streaming responses from agents. + +## Base Properties + +All chunks include these base properties: + +### type: + +string + +### runId: + +string + +### from: + +ChunkFrom + +enum + +### AGENT: + +'AGENT' + +### USER: + +'USER' + +### SYSTEM: + +'SYSTEM' + +### WORKFLOW: + +'WORKFLOW' + +## Text Chunks + +### text-start + +Signals the beginning of text generation. + +### text-delta + +Incremental text content during generation. + +### text-end + +Signals the end of text generation. + +## Reasoning Chunks + +### reasoning-start + +Signals the beginning of reasoning generation (for models that support reasoning). + +### reasoning-delta + +Incremental reasoning text during generation. + +### reasoning-end + +Signals the end of reasoning generation. + +### reasoning-signature + +Contains the reasoning signature from models that support advanced reasoning (like OpenAI's o1 series). The signature represents metadata about the model's internal reasoning process, such as effort level or reasoning approach, but not the actual reasoning content itself. + +## Tool Chunks + +### tool-call + +A tool is being called. + +### tool-result + +Result from a tool execution. + +### tool-call-input-streaming-start + +Signals the start of streaming tool call arguments. + +### tool-call-delta + +Incremental tool call arguments during streaming. + +### tool-call-input-streaming-end + +Signals the end of streaming tool call arguments. + +An error occurred during tool execution. + +## Source and File Chunks + +### source + +Contains source information for content. + +### file + +Contains file data. + +## Control Chunks + +### start + +Signals the start of streaming. + +### type: + +"start" + +### step-start + +Signals the start of a processing step. + +### type: + +"step-start" + +### step-finish + +Signals the completion of a processing step. + +### raw + +Contains raw data from the provider. + +### type: + +"raw" + +### finish + +Stream has completed successfully. + +### type: + +"finish" + +### error + +An error occurred during streaming. + +### type: + +"error" + +### abort + +Stream was aborted. + +### type: + +"abort" + +## Object and Output Chunks + +### object + +Emitted when using output generation with defined schemas. Contains partial or complete structured data that conforms to the specified Zod or JSON schema. This chunk is typically skipped in some execution contexts and used for streaming structured object generation. + +### type: + +"object" + +### object: + +PartialSchemaOutput + +### tool-output + +Contains output from agent or workflow execution, particularly used for tracking usage statistics and completion events. Often wraps other chunk types (like finish chunks) to provide nested execution context. + +### type: + +"tool-output" + +### step-output + +Contains output from workflow step execution, used primarily for usage tracking and step completion events. Similar to tool-output but specifically for individual workflow steps. + +### type: + +"step-output" + +Contains metadata about the LLM provider's response. Emitted by some providers after text generation to provide additional context like model ID, timestamps, and response headers. This chunk is used internally for state tracking and doesn't affect message assembly. + +### watch + +Contains monitoring and observability data from agent execution. Can include workflow state information, execution progress, or other runtime details depending on the context where `stream()` is used. + +### type: + +"watch" + +### tripwire + +Emitted when the stream is forcibly terminated due to content being blocked by a processor. This acts as a safety mechanism to prevent harmful or inappropriate content from being streamed. The payload includes information about why the content was blocked and whether a retry was requested. + +### type: + +"tripwire" + +## Usage Example + +```typescript +const stream = await agent.stream("Hello"); + +for await (const chunk of stream.fullStream) { + switch (chunk.type) { + case "text-delta": + console.log("Text:", chunk.payload.text); + break; + + case "tool-call": + console.log("Calling tool:", chunk.payload.toolName); + break; + + case "tool-result": + console.log("Tool result:", chunk.payload.result); + break; + + case "reasoning-delta": + console.log("Reasoning:", chunk.payload.text); + break; + + case "finish": + console.log("Finished:", chunk.payload.stepResult.reason); + console.log("Usage:", chunk.payload.output.usage); + break; + + case "error": + console.error("Error:", chunk.payload.error); + break; + } +} +``` +- [.stream()](https://mastra.ai/reference/v1/streaming/agents/stream) - Method that returns streams emitting these chunks +- [MastraModelOutput](https://mastra.ai/reference/v1/streaming/agents/MastraModelOutput) - The stream object that emits these chunks +- [workflow.stream()](https://mastra.ai/reference/v1/streaming/workflows/stream) - Method that returns streams emitting these chunks for workflows \ No newline at end of file diff --git a/docs/tools-worklows/Reference Run.observeStream() Streaming Mastra Docs v1 Beta.md b/docs/tools-worklows/Reference Run.observeStream() Streaming Mastra Docs v1 Beta.md new file mode 100644 index 00000000..3b43bd49 --- /dev/null +++ b/docs/tools-worklows/Reference Run.observeStream() Streaming Mastra Docs v1 Beta.md @@ -0,0 +1,50 @@ +--- +title: "Reference: Run.observeStream() | Streaming | Mastra Docs v1 Beta" +source: "https://mastra.ai/reference/v1/streaming/workflows/observeStream" +author: +published: +created: 2026-01-13 +description: "Mastra v1 Beta: Documentation for the `Run.observeStream()` method in workflows, which enables reopening the stream of an already active workflow run." +tags: + - "clippings" +--- +The `.observeStream()` method opens a new `ReadableStream` to a workflow run that is currently running, allowing you to observe the stream of events if the original stream is no longer available. + +## Usage example + +```typescript +const run = await workflow.createRun(); + +run.stream({ + inputData: { + value: "initial data", + }, +}); + +const stream = await run.observeStream(); + +for await (const chunk of stream) { + console.log(chunk); +} +``` + +## Returns + +`ReadableStream` + +## Stream Events + +The stream emits various event types during workflow execution. Each event has a `type` field and a `payload` containing relevant data: + +- **`workflow-start`**: Workflow execution begins +- **`workflow-step-start`**: A step begins execution +- **`workflow-step-output`**: Custom output from a step +- **`workflow-step-result`**: A step completes with results +- **`workflow-finish`**: Workflow execution completes with usage statistics + +## Related + +- [Workflows overview](https://mastra.ai/docs/v1/workflows/overview#running-workflows) +- [Workflow.createRun()](https://mastra.ai/reference/v1/workflows/workflow-methods/create-run) +- [Run.stream()](https://mastra.ai/reference/v1/streaming/workflows/stream) +- [Run.resumeStream()](https://mastra.ai/reference/v1/streaming/workflows/resumeStream) \ No newline at end of file diff --git a/docs/tools-worklows/Reference Run.resumeStream() Streaming Mastra Docs v1 Beta.md b/docs/tools-worklows/Reference Run.resumeStream() Streaming Mastra Docs v1 Beta.md new file mode 100644 index 00000000..9eaf9372 --- /dev/null +++ b/docs/tools-worklows/Reference Run.resumeStream() Streaming Mastra Docs v1 Beta.md @@ -0,0 +1,105 @@ +--- +title: "Reference: Run.resumeStream() | Streaming | Mastra Docs v1 Beta" +source: "https://mastra.ai/reference/v1/streaming/workflows/resumeStream" +author: +published: +created: 2026-01-13 +description: "Mastra v1 Beta: Documentation for the `Run.resumeStream()` method in workflows, which enables real-time resumption and streaming of suspended workflow runs." +tags: + - "clippings" +--- +The `.resumeStream()` method resumes a suspended workflow run with new data, allowing you to continue execution from a specific step and to observe the stream of events. + +## Usage example + +```typescript +const run = await workflow.createRun(); + +const stream = run.stream({ + inputData: { + value: "initial data", + }, +}); + +const result = await stream.result; + +if (result!.status === "suspended") { + const resumedStream = await run.resumeStream({ + resumeData: { + value: "resume data", + }, + }); +} +``` + +## Parameters + +### resumeData?: + +z.infer + +### requestContext?: + +RequestContext + +### step?: + +Step + +### tracingOptions?: + +TracingOptions + +### metadata?: + +Record + +### requestContextKeys?: + +string\[\] + +### traceId?: + +string + +### parentSpanId?: + +string + +### tags?: + +string\[\] + +## Returns + +### stream: + +MastraWorkflowStream + +### stream.status: + +Promise + +### stream.result: + +Promise> + +### stream.usage: + +Promise<{ inputTokens: number; outputTokens: number; totalTokens: number, reasoningTokens?: number, cacheInputTokens?: number }> + +## Stream Events + +The stream emits various event types during workflow execution. Each event has a `type` field and a `payload` containing relevant data: + +- **`workflow-start`**: Workflow execution begins +- **`workflow-step-start`**: A step begins execution +- **`workflow-step-output`**: Custom output from a step +- **`workflow-step-result`**: A step completes with results +- **`workflow-finish`**: Workflow execution completes with usage statistics + +## Related + +- [Workflows overview](https://mastra.ai/docs/v1/workflows/overview#running-workflows) +- [Workflow.createRun()](https://mastra.ai/reference/v1/workflows/workflow-methods/create-run) +- [Run.stream()](https://mastra.ai/reference/v1/streaming/workflows/stream) \ No newline at end of file diff --git a/docs/tools-worklows/Reference Run.stream() Streaming Mastra Docs v1 Beta.md b/docs/tools-worklows/Reference Run.stream() Streaming Mastra Docs v1 Beta.md new file mode 100644 index 00000000..aef50248 --- /dev/null +++ b/docs/tools-worklows/Reference Run.stream() Streaming Mastra Docs v1 Beta.md @@ -0,0 +1,134 @@ +--- +title: "Reference: Run.stream() | Streaming | Mastra Docs v1 Beta" +source: "https://mastra.ai/reference/v1/streaming/workflows/stream" +author: +published: +created: 2026-01-13 +description: "Mastra v1 Beta: Documentation for the `Run.stream()` method in workflows, which enables real-time streaming of responses." +tags: + - "clippings" +--- +The `.stream()` method enables real-time streaming of responses from a workflow. It returns a `ReadableStream` of events directly. + +## Usage example + +```typescript +const run = await workflow.createRun(); + +const stream = await run.stream({ + inputData: { + value: "initial data", + }, +}); + +for await (const chunk of stream) { + console.log(chunk); +} +``` + +## Parameters + +### inputData?: + +z.infer + +### requestContext?: + +RequestContext + +### tracingContext?: + +TracingContext + +### currentSpan?: + +Span + +### tracingOptions?: + +TracingOptions + +### metadata?: + +Record + +### requestContextKeys?: + +string\[\] + +### traceId?: + +string + +### parentSpanId?: + +string + +### tags?: + +string\[\] + +### closeOnSuspend?: + +boolean + +## Returns + +Returns a `WorkflowRunOutput` object that implements the async iterable interface (can be used directly in `for await...of` loops) and provides access to the stream and workflow execution results. + +### fullStream: + +ReadableStream + +### result: + +Promise> + +### status: + +WorkflowRunStatus + +### usage: + +Promise<{ inputTokens: number; outputTokens: number; totalTokens: number, reasoningTokens?: number, cachedInputTokens?: number }> + +## Extended usage example + +```typescript +const run = await workflow.createRun(); + +const stream = run.stream({ + inputData: { + value: "initial data", + }, +}); + +// Iterate over stream events (you can iterate over stream directly or use stream.fullStream) +for await (const chunk of stream) { + console.log(chunk); +} + +// Access the final result +const result = await stream.result; +console.log("Final result:", result); + +// Access token usage +const usage = await stream.usage; +console.log("Token usage:", usage); + +// Check current status +console.log("Status:", stream.status); +``` + +## Stream Events + +The stream emits various event types during workflow execution. Each event has a `type` field and a `payload` containing relevant data: + +- **`workflow-start`**: Workflow execution begins +- **`workflow-step-start`**: A step begins execution +- **`workflow-step-output`**: Custom output from a step +- **`workflow-step-result`**: A step completes with results +- **`workflow-finish`**: Workflow execution completes with usage statistics +- [Workflows overview](https://mastra.ai/docs/v1/workflows/overview#running-workflows) +- [Workflow.createRun()](https://mastra.ai/reference/v1/workflows/workflow-methods/create-run) +- [Run.resumeStream()](https://mastra.ai/reference/v1/streaming/workflows/resumeStream) \ No newline at end of file diff --git a/docs/tools-worklows/Reference Run.timeTravelStream() Streaming Mastra Docs v1 Beta.md b/docs/tools-worklows/Reference Run.timeTravelStream() Streaming Mastra Docs v1 Beta.md new file mode 100644 index 00000000..3ded24eb --- /dev/null +++ b/docs/tools-worklows/Reference Run.timeTravelStream() Streaming Mastra Docs v1 Beta.md @@ -0,0 +1,155 @@ +--- +title: "Reference: Run.timeTravelStream() | Streaming | Mastra Docs v1 Beta" +source: "https://mastra.ai/reference/v1/streaming/workflows/timeTravelStream" +author: +published: +created: 2026-01-13 +description: "Mastra v1 Beta: Documentation for the `Run.timeTravelStream()` method for streaming workflow time travel execution." +tags: + - "clippings" +--- +The `.timeTravelStream()` method re-executes a workflow starting from any specific step with streaming events. This allows you to receive real-time updates during time travel execution while maintaining full visibility into each step's progress. + +## Usage example + +```typescript +const run = await workflow.createRun(); + +const output = run.timeTravelStream({ + step: "step2", + inputData: { value: 10 }, +}); + +// Process events as they arrive +for await (const event of output.fullStream) { + console.log(event.type, event.payload); +} + +// Get the final result +const result = await output.result; +``` + +## Parameters + +All parameters are the same as [`Run.timeTravel()`](https://mastra.ai/reference/v1/workflows/run-methods/timeTravel#parameters). See the [timeTravel reference](https://mastra.ai/reference/v1/workflows/run-methods/timeTravel#parameters) for detailed parameter documentation. + +## Returns + +### output: + +WorkflowRunOutput> + +### output.fullStream: + +ReadableStream + +### output.result: + +Promise> + +### output.traceId?: + +string + +## Stream events + +The stream emits various workflow events during execution: + +- `workflow-step-start`: Emitted when a step begins execution +- `workflow-step-finish`: Emitted when a step completes successfully +- `workflow-step-error`: Emitted when a step encounters an error +- `workflow-step-suspended`: Emitted when a step suspends +- Additional events depending on step types (agents, tools, etc.) + +## Extended usage examples + +### Processing events during time travel + +```typescript +const run = await workflow.createRun(); + +const output = run.timeTravelStream({ + step: "step2", + inputData: { value: 10 }, +}); + +for await (const event of output.fullStream) { + switch (event.type) { + case "workflow-step-start": + console.log(\`Starting step: ${event.payload.stepName}\`); + break; + case "workflow-step-finish": + console.log(\`Completed step: ${event.payload.stepName}\`); + break; + case "workflow-step-error": + console.error(\`Error in step: ${event.payload.stepName}\`, event.payload.error); + break; + } +} + +const result = await output.result; +console.log("Time travel completed:", result); +``` + +### Time travel stream with context + +```typescript +const output = run.timeTravelStream({ + step: "step2", + context: { + step1: { + status: "success", + payload: { value: 0 }, + output: { step1Result: 2 }, + startedAt: Date.now(), + endedAt: Date.now(), + }, + }, +}); + +for await (const event of output.fullStream) { + // Handle events + console.log(event); +} + +const result = await output.result; +``` + +### Time travel stream with nested workflows + +```typescript +const output = run.timeTravelStream({ + step: ["nestedWorkflow", "step3"], + inputData: { value: 10 }, + nestedStepsContext: { + nestedWorkflow: { + step2: { + status: "success", + payload: { step1Result: 2 }, + output: { step2Result: 3 }, + startedAt: Date.now(), + endedAt: Date.now(), + }, + }, + }, +}); + +for await (const event of output.fullStream) { + console.log(event.type, event.payload); +} + +const result = await output.result; +``` + +## Notes + +- The stream closes automatically when time travel execution completes or encounters an error +- You can process events from the stream while the workflow is still executing +- The `result` promise resolves only after all steps have completed +- Stream events follow the same format as regular workflow streaming +- Time travel streaming requires storage to be configured since it relies on persisted workflow snapshots +- [Run.timeTravel()](https://mastra.ai/reference/v1/workflows/run-methods/timeTravel) +- [Time Travel](https://mastra.ai/docs/v1/workflows/time-travel) +- [Workflow Streaming](https://mastra.ai/docs/v1/streaming/workflow-streaming) +- [Run.stream()](https://mastra.ai/reference/v1/streaming/workflows/stream) +- [Run.resumeStream()](https://mastra.ai/reference/v1/streaming/workflows/resumeStream) \ No newline at end of file diff --git a/docs/tools-worklows/Reference createTool() Tools & MCP Mastra Docs v1 Beta.md b/docs/tools-worklows/Reference createTool() Tools & MCP Mastra Docs v1 Beta.md new file mode 100644 index 00000000..4bfc790f --- /dev/null +++ b/docs/tools-worklows/Reference createTool() Tools & MCP Mastra Docs v1 Beta.md @@ -0,0 +1,207 @@ +--- +title: "Reference: createTool() | Tools & MCP | Mastra Docs v1 Beta" +source: "https://mastra.ai/reference/v1/tools/create-tool" +author: +published: +created: 2026-01-13 +description: "Mastra v1 Beta: Documentation for the `createTool()` function in Mastra, used to define custom tools for agents." +tags: + - "clippings" +--- +The `createTool()` function is used to define custom tools that your Mastra agents can execute. Tools extend an agent's capabilities by allowing it to interact with external systems, perform calculations, or access specific data. + +## Usage example + +```typescript +src/mastra/tools/reverse-tool.tsimport { createTool } from "@mastra/core/tools"; +import { z } from "zod"; + +export const tool = createTool({ + id: "test-tool", + description: "Reverse the input string", + inputSchema: z.object({ + input: z.string(), + }), + outputSchema: z.object({ + output: z.string(), + }), + execute: async (inputData) => { + const reversed = inputData.input.split("").reverse().join(""); + + return { + output: reversed, + }; + }, +}); +``` + +## Parameters + +### id: + +string + +### description: + +string + +### inputSchema?: + +Zod schema + +### outputSchema?: + +Zod schema + +### suspendSchema?: + +Zod schema + +### resumeSchema?: + +Zod schema + +### requireApproval?: + +boolean + +### execute: + +function + +### input: + +z.infer + +### context?: + +ToolExecutionContext + +### onInputStart?: + +function + +### onInputDelta?: + +function + +### onInputAvailable?: + +function + +### onOutput?: + +function + +## Returns + +The `createTool()` function returns a `Tool` object. + +### Tool: + +object + +## Tool Lifecycle Hooks + +Tools support lifecycle hooks that allow you to monitor and react to different stages of tool execution. These hooks are particularly useful for logging, analytics, validation, and real-time updates during streaming. + +### Available Hooks + +#### onInputStart + +Called when tool call input streaming begins, before any input data is received. + +```typescript +export const tool = createTool({ + id: "example-tool", + description: "Example tool with hooks", + onInputStart: ({ toolCallId, messages, abortSignal }) => { + console.log(\`Tool ${toolCallId} input streaming started\`); + }, +}); +``` + +#### onInputDelta + +Called for each incremental chunk of input text as it streams in. Useful for showing real-time progress or parsing partial JSON. + +```typescript +export const tool = createTool({ + id: "example-tool", + description: "Example tool with hooks", + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + console.log(\`Received input chunk: ${inputTextDelta}\`); + }, +}); +``` + +#### onInputAvailable + +Called when the complete tool input is available and has been parsed and validated against the `inputSchema`. + +```typescript +export const tool = createTool({ + id: "example-tool", + description: "Example tool with hooks", + inputSchema: z.object({ + city: z.string(), + }), + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + console.log(\`Tool received complete input:\`, input); + // input is fully typed based on inputSchema + }, +}); +``` + +#### onOutput + +Called after the tool has successfully executed and returned output. Useful for logging results, triggering follow-up actions, or analytics. + +```typescript +export const tool = createTool({ + id: "example-tool", + description: "Example tool with hooks", + outputSchema: z.object({ + result: z.string(), + }), + execute: async (input) => { + return { result: "Success" }; + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + console.log(\`${toolName} execution completed:\`, output); + // output is fully typed based on outputSchema + }, +}); +``` + +### Hook Execution Order + +For a typical streaming tool call, the hooks are invoked in this order: + +1. **onInputStart** - Input streaming begins +2. **onInputDelta** - Called multiple times as chunks arrive +3. **onInputAvailable** - Complete input is parsed and validated +4. Tool's **execute** function runs +5. **onOutput** - Tool has completed successfully + +### Hook Parameters + +All hooks receive a parameter object with these common properties: + +- `toolCallId` (string): Unique identifier for this specific tool call +- `abortSignal` (AbortSignal): Signal for detecting if the operation should be cancelled + +Additionally: + +- `onInputStart`, `onInputDelta`, and `onInputAvailable` receive `messages` (array): The conversation messages at the time of the tool call +- `onInputDelta` receives `inputTextDelta` (string): The incremental text chunk +- `onInputAvailable` receives `input`: The validated input data (typed according to `inputSchema`) +- `onOutput` receives `output`: The tool's return value (typed according to `outputSchema`) and `toolName` (string): The name of the tool + +Hook errors are caught and logged automatically, but do not prevent tool execution from continuing. If a hook throws an error, it will be logged to the console but will not fail the tool call. + +- [MCP Overview](https://mastra.ai/docs/v1/mcp/overview) +- [Using Tools with Agents](https://mastra.ai/docs/v1/agents/using-tools) +- [Agent Approval](https://mastra.ai/docs/v1/agents/agent-approval) +- [Tool Streaming](https://mastra.ai/docs/v1/streaming/tool-streaming) +- [Request Context](https://mastra.ai/docs/v1/server/request-context#accessing-values-with-tools) \ No newline at end of file diff --git a/docs/tools-worklows/Streaming Events Streaming Mastra Docs v1 Beta.md b/docs/tools-worklows/Streaming Events Streaming Mastra Docs v1 Beta.md new file mode 100644 index 00000000..7dae28e1 --- /dev/null +++ b/docs/tools-worklows/Streaming Events Streaming Mastra Docs v1 Beta.md @@ -0,0 +1,236 @@ +--- +title: "Streaming Events | Streaming | Mastra Docs v1 Beta" +source: "https://mastra.ai/docs/v1/streaming/events" +author: +published: +created: 2026-01-13 +description: "Mastra v1 Beta: Learn about the different types of streaming events in Mastra, including text deltas, tool calls, step events, and how to handle them in your applications." +tags: + - "clippings" +--- +Streaming from agents or workflows provides real-time visibility into either the LLM’s output or the status of a workflow run. This feedback can be passed directly to the user, or used within applications to handle workflow status more effectively, creating a smoother and more responsive experience. + +Events emitted from agents or workflows represent different stages of generation and execution, such as when a run starts, when text is produced, or when a tool is invoked. + +## Event types + +Below is a complete list of events emitted from `.stream()`. Depending on whether you’re streaming from an **agent** or a **workflow**, only a subset of these events will occur: + +- **start**: Marks the beginning of an agent or workflow run. +- **step-start**: Indicates a workflow step has begun execution. +- **text-delta**: Incremental text chunks as they're generated by the LLM. +- **tool-call**: When the agent decides to use a tool, including the tool name and arguments. +- **tool-result**: The result returned from tool execution. +- **step-finish**: Confirms that a specific step has fully finalized, and may include metadata like the finish reason for that step. +- **finish**: When the agent or workflow completes, including usage statistics. + +## Network event types + +When using `agent.network()` for multi-agent collaboration, additional event types are emitted to track the orchestration flow: + +- **routing-agent-start**: The routing agent begins analyzing the task to decide which primitive (agent/workflow/tool) to delegate to. +- **routing-agent-text-delta**: Incremental text as the routing agent processes the response from the selected primitive. +- **routing-agent-end**: The routing agent completes its selection, including the selected primitive and reason for selection. +- **agent-execution-start**: A delegated agent begins execution. +- **agent-execution-end**: A delegated agent completes execution. +- **agent-execution-event-\***: Events from the delegated agent's execution (e.g., `agent-execution-event-text-delta`). +- **workflow-execution-start**: A delegated workflow begins execution. +- **workflow-execution-end**: A delegated workflow completes execution. +- **workflow-execution-event-\***: Events from the delegated workflow's execution. +- **tool-execution-start**: A delegated tool begins execution. +- **tool-execution-end**: A delegated tool completes execution. +- **network-execution-event-step-finish**: A network iteration step completes. +- **network-execution-event-finish**: The entire network execution completes. + +## Inspecting agent streams + +Iterate over the `stream` with a `for await` loop to inspect all emitted event chunks. + +```typescript +const testAgent = mastra.getAgent("testAgent"); + +const stream = await testAgent.stream([ + { role: "user", content: "Help me organize my day" }, +]); + +for await (const chunk of stream) { + console.log(chunk); +} +``` + +info + +Visit [Agent.stream()](https://mastra.ai/reference/v1/streaming/agents/stream) for more information. + +### Example agent output + +Below is an example of events that may be emitted. Each event always includes a `type` and can include additional fields like `from` and `payload`. + +```typescript +{ + type: 'start', + from: 'AGENT', + // .. +} +{ + type: 'step-start', + from: 'AGENT', + payload: { + messageId: 'msg-cdUrkirvXw8A6oE4t5lzDuxi', + // ... + } +} +{ + type: 'tool-call', + from: 'AGENT', + payload: { + toolCallId: 'call_jbhi3s1qvR6Aqt9axCfTBMsA', + toolName: 'testTool' + // .. + } +} +``` + +## Inspecting workflow streams + +Iterate over the `stream` with a `for await` loop to inspect all emitted event chunks. + +```typescript +const testWorkflow = mastra.getWorkflow("testWorkflow"); + +const run = await testWorkflow.createRun(); + +const stream = await run.stream({ + inputData: { + value: "initial data", + }, +}); + +for await (const chunk of stream) { + console.log(chunk); +} +``` + +### Example workflow output + +Below is an example of events that may be emitted. Each event always includes a `type` and can include additional fields like `from` and `payload`. + +```typescript +{ + type: 'workflow-start', + runId: '221333ed-d9ee-4737-922b-4ab4d9de73e6', + from: 'WORKFLOW', + // ... +} +{ + type: 'workflow-step-start', + runId: '221333ed-d9ee-4737-922b-4ab4d9de73e6', + from: 'WORKFLOW', + payload: { + stepName: 'step-1', + args: { value: 'initial data' }, + stepCallId: '9e8c5217-490b-4fe7-8c31-6e2353a3fc98', + startedAt: 1755269732792, + status: 'running' + } +} +``` + +## Inspecting agent networks + +When using multi-agent collaboration with `agent.network()`, iterate over the stream to track how tasks are delegated and executed across agents, workflows, and tools. + +```typescript +const networkAgent = mastra.getAgent("networkAgent"); + +const networkStream = await networkAgent.network( + "Research dolphins then write a report", +); + +for await (const chunk of networkStream) { + console.log(chunk); +} +``` + +info + +Visit [Agent.network()](https://mastra.ai/reference/v1/agents/network) for more information. + +### Example network output + +Network streams emit events that track the orchestration flow. Each iteration begins with routing, followed by execution of the selected primitive. + +```typescript +// Routing agent decides what to do +{ + type: 'routing-agent-start', + from: 'NETWORK', + runId: '7a3b9c2d-1e4f-5a6b-8c9d-0e1f2a3b4c5d', + payload: { + agentId: 'routing-agent', + // ... + } +} +// Routing agent makes a selection +{ + type: 'routing-agent-end', + from: 'NETWORK', + runId: '7a3b9c2d-1e4f-5a6b-8c9d-0e1f2a3b4c5d', + payload: { + // ... + } +} +// Delegated agent begins execution +{ + type: 'agent-execution-start', + from: 'NETWORK', + runId: '8b4c0d3e-2f5a-6b7c-9d0e-1f2a3b4c5d6e', + payload: { + // ... + } +} +// Events from the delegated agent's execution +{ + type: 'agent-execution-event-text-delta', + from: 'NETWORK', + runId: '8b4c0d3e-2f5a-6b7c-9d0e-1f2a3b4c5d6e', + payload: { + type: 'text-delta', + payload: { + // ... + } + } +} +``` + +### Filtering network events + +You can filter events by type to track specific aspects of the network execution: + +```typescript +const networkStream = await networkAgent.network( + "Analyze data and create visualization", +); + +for await (const chunk of networkStream) { + // Track routing decisions + if (chunk.type === "routing-agent-end") { + console.log( + "Selected:", + chunk.payload.resourceType, + chunk.payload.resourceId, + ); + console.log("Reason:", chunk.payload.selectionReason); + } + + // Track agent delegations + if (chunk.type === "agent-execution-start") { + console.log("Delegating to agent:", chunk.payload.agentId); + } + + // Track workflow delegations + if (chunk.type === "workflow-execution-start") { + console.log("Executing workflow:", chunk.payload.name); + } +} +``` \ No newline at end of file diff --git a/docs/tools-worklows/Streaming Overview Streaming Mastra Docs v1 Beta.md b/docs/tools-worklows/Streaming Overview Streaming Mastra Docs v1 Beta.md new file mode 100644 index 00000000..684674a7 --- /dev/null +++ b/docs/tools-worklows/Streaming Overview Streaming Mastra Docs v1 Beta.md @@ -0,0 +1,191 @@ +--- +title: "Streaming Overview | Streaming | Mastra Docs v1 Beta" +source: "https://mastra.ai/docs/v1/streaming/overview" +author: +published: +created: 2026-01-13 +description: "Mastra v1 Beta: Streaming in Mastra enables real-time, incremental responses from both agents and workflows, providing immediate feedback as AI-generated content is produced." +tags: + - "clippings" +--- +Mastra supports real-time, incremental responses from agents and workflows, allowing users to see output as it’s generated instead of waiting for completion. This is useful for chat, long-form content, multi-step workflows, or any scenario where immediate feedback matters. + +## Getting started + +Mastra's streaming API adapts based on your model version: + +- **`.stream()`**: For V2 models, supports **AI SDK v5** (`LanguageModelV2`). +- **`.streamLegacy()`**: For V1 models, supports **AI SDK v4** (`LanguageModelV1`). + +## Streaming with agents + +You can pass a single string for simple prompts, an array of strings when providing multiple pieces of context, or an array of message objects with `role` and `content` for precise control over roles and conversational flows. + +### Using Agent.stream() + +A `textStream` breaks the response into chunks as it's generated, allowing output to stream progressively instead of arriving all at once. Iterate over the `textStream` using a `for await` loop to inspect each stream chunk. + +```typescript +const testAgent = mastra.getAgent("testAgent"); + +const stream = await testAgent.stream([ + { role: "user", content: "Help me organize my day" }, +]); + +for await (const chunk of stream.textStream) { + process.stdout.write(chunk); +} +``` + +info + +Visit [Agent.stream()](https://mastra.ai/reference/v1/streaming/agents/stream) for more information. + +### Output from Agent.stream() + +The output streams the generated response from the agent. + +```markdown +Of course! +To help you organize your day effectively, I need a bit more information. +Here are some questions to consider: +... +``` + +### Agent stream properties + +An agent stream provides access to various response properties: + +- **`stream.textStream`**: A readable stream that emits text chunks. +- **`stream.text`**: Promise that resolves to the full text response. +- **`stream.finishReason`**: The reason the agent stopped streaming. +- **`stream.usage`**: Token usage information. + +### AI SDK v5 Compatibility + +AI SDK v5 uses `LanguageModelV2` for the model providers. If you are getting an error that you are using an AI SDK v4 model you will need to upgrade your model package to the next major version. + +For integration with AI SDK v5, use the `toAISdkV5Stream()` utility from `@mastra/ai-sdk` to convert Mastra streams to AI SDK-compatible format: + +```typescript +import { toAISdkV5Stream } from "@mastra/ai-sdk"; + +const testAgent = mastra.getAgent("testAgent"); + +const stream = await testAgent.stream([ + { role: "user", content: "Help me organize my day" }, +]); + +// Convert to AI SDK v5 compatible stream +const aiSDKStream = toAISdkV5Stream(stream, { from: "agent" }); + +// Use with AI SDK v5 methods +``` + +For converting messages to AI SDK v5 format, use the `toAISdkV5Messages()` utility from `@mastra/ai-sdk/ui`: + +```typescript +import { toAISdkV5Messages } from "@mastra/ai-sdk/ui"; + +const messages = [{ role: "user", content: "Hello" }]; +const aiSDKMessages = toAISdkV5Messages(messages); +``` + +### Using Agent.network() + +The `network()` method enables multi-agent collaboration by executing a network loop where multiple agents can work together to handle complex tasks. The routing agent delegates tasks to appropriate sub-agents, workflows, and tools based on the conversation context. + +> **Note**: This method is experimental and requires memory to be configured on the agent. + +```typescript +const testAgent = mastra.getAgent("testAgent"); + +const networkStream = await testAgent.network("Help me organize my day"); + +for await (const chunk of networkStream) { + console.log(chunk); +} +``` + +info + +Visit [Agent.network()](https://mastra.ai/reference/v1/agents/network) for more information. + +#### Network stream properties + +The network stream provides access to execution information: + +- **`networkStream.status`**: Promise resolving to the workflow execution status +- **`networkStream.result`**: Promise resolving to the complete execution results +- **`networkStream.usage`**: Promise resolving to token usage information +```typescript +const testAgent = mastra.getAgent("testAgent"); + +const networkStream = await testAgent.network( + "Research dolphins then write a report", +); + +for await (const chunk of networkStream) { + console.log(chunk); +} + +console.log("Final status:", await networkStream.status); +console.log("Final result:", await networkStream.result); +console.log("Token usage:", await networkStream.usage); +``` + +## Streaming with workflows + +Streaming from a workflow returns a sequence of structured events describing the run lifecycle, rather than incremental text chunks. This event-based format makes it possible to track and respond to workflow progress in real time once a run is created using `.createRun()`. + +### Using Run.stream() + +The `stream()` method returns a `ReadableStream` of events directly. + +```typescript +const run = await testWorkflow.createRun(); + +const stream = await run.stream({ + inputData: { + value: "initial data", + }, +}); + +for await (const chunk of stream) { + console.log(chunk); +} +``` + +info + +Visit [Run.stream()](https://mastra.ai/reference/v1/streaming/workflows/stream) for more information. + +### Output from Run.stream() + +The event structure includes `runId` and `from` at the top level, making it easier to identify and track workflow runs without digging into the payload. + +```typescript +{ + type: 'workflow-start', + runId: '1eeaf01a-d2bf-4e3f-8d1b-027795ccd3df', + from: 'WORKFLOW', + payload: { + stepName: 'step-1', + args: { value: 'initial data' }, + stepCallId: '8e15e618-be0e-4215-a5d6-08e58c152068', + startedAt: 1755121710066, + status: 'running' + } +} +``` + +## Workflow stream properties + +A workflow stream provides access to various response properties: + +- **`stream.status`**: The status of the workflow run. +- **`stream.result`**: The result of the workflow run. +- **`stream.usage`**: The total token usage of the workflow run. +- [Streaming events](https://mastra.ai/docs/v1/streaming/events) +- [Using Agents](https://mastra.ai/docs/v1/agents/overview) +- [Workflows overview](https://mastra.ai/docs/v1/workflows/overview) \ No newline at end of file diff --git a/docs/tools-worklows/Tool streaming Streaming Mastra Docs v1 Beta.md b/docs/tools-worklows/Tool streaming Streaming Mastra Docs v1 Beta.md new file mode 100644 index 00000000..54e9f18b --- /dev/null +++ b/docs/tools-worklows/Tool streaming Streaming Mastra Docs v1 Beta.md @@ -0,0 +1,182 @@ +--- +title: "Tool streaming | Streaming | Mastra Docs v1 Beta" +source: "https://mastra.ai/docs/v1/streaming/tool-streaming" +author: +published: +created: 2026-01-13 +description: "Mastra v1 Beta: Learn how to use tool streaming in Mastra, including handling tool calls, tool results, and tool execution events during streaming." +tags: + - "clippings" +--- +Tool streaming in Mastra enables tools to send incremental results while they run, rather than waiting until execution finishes. This allows you to surface partial progress, intermediate states, or progressive data directly to users or upstream agents and workflows. + +Streams can be written to in two main ways: + +- **From within a tool**: every tool receives a `context.writer` object, which is a writable stream you can use to push updates as execution progresses. +- **From an agent stream**: you can also pipe an agent's `stream` output directly into a tool's writer, making it easy to chain agent responses into tool results without extra glue code. + +By combining writable tool streams with agent streaming, you gain fine grained control over how intermediate results flow through your system and into the user experience. + +## Agent using tool + +Agent streaming can be combined with tool calls, allowing tool outputs to be written directly into the agent’s streaming response. This makes it possible to surface tool activity as part of the overall interaction. + +```typescript +import { Agent } from "@mastra/core/agent"; +import { testTool } from "../tools/test-tool"; + +export const testAgent = new Agent({ + id: "test-agent", + name: "Test Agent", + instructions: "You are a weather agent.", + model: "openai/gpt-5.1", + tools: { testTool }, +}); +``` + +### Using context.writer + +The `context.writer` object is available in a tool's `execute()` function and can be used to emit custom events, data, or values into the active stream. This enables tools to provide intermediate results or status updates while execution is still in progress. + +warning + +You must `await` the call to `writer.write()` or else you will lock the stream and get a `WritableStream is locked` error. + +```typescript +import { createTool } from "@mastra/core/tools"; + +export const testTool = createTool({ + execute: async (inputData, context) => { + const { value } = inputData; + + await context?.writer?.write({ + type: "custom-event", + status: "pending" + }); + + const response = await fetch(...); + + await context?.writer?.write({ + type: "custom-event", + status: "success" + }); + + return { + value: "" + }; + } +}); +``` + +You can also use `writer.custom()` if you want to emit top level stream chunks, This useful and relevant when integrating with UI Frameworks + +```typescript +import { createTool } from "@mastra/core/tools"; + +export const testTool = createTool({ + execute: async (inputData, context) => { + const { value } = inputData; + + await context?.writer?.custom({ + type: "data-tool-progress", + status: "pending" + }); + + const response = await fetch(...); + + await context?.writer?.custom({ + type: "data-tool-progress", + status: "success" + }); + + return { + value: "" + }; + } +}); +``` + +### Inspecting stream payloads + +Events written to the stream are included in the emitted chunks. These chunks can be inspected to access any custom fields, such as event types, intermediate values, or tool-specific data. + +```typescript +const stream = await testAgent.stream([ + "What is the weather in London?", + "Use the testTool", +]); + +for await (const chunk of stream) { + if (chunk.payload.output?.type === "custom-event") { + console.log(JSON.stringify(chunk, null, 2)); + } +} +``` + +## Tool Lifecycle Hooks + +Tools support lifecycle hooks that allow you to monitor different stages of tool execution during streaming. These hooks are particularly useful for logging or analytics. + +### Example: Using onInputAvailable and onOutput + +```typescript +import { createTool } from "@mastra/core/tools"; +import { z } from "zod"; + +export const weatherTool = createTool({ + id: "weather-tool", + description: "Get weather information", + inputSchema: z.object({ + city: z.string(), + }), + outputSchema: z.object({ + temperature: z.number(), + conditions: z.string(), + }), + // Called when the complete input is available + onInputAvailable: ({ input, toolCallId }) => { + console.log(\`Weather requested for: ${input.city}\`); + }, + execute: async (input) => { + const weather = await fetchWeather(input.city); + return weather; + }, + // Called after successful execution + onOutput: ({ output, toolName }) => { + console.log(\`${toolName} result: ${output.temperature}°F, ${output.conditions}\`); + }, +}); +``` + +### Available Hooks + +- **onInputStart**: Called when tool call input streaming begins +- **onInputDelta**: Called for each chunk of input as it streams in +- **onInputAvailable**: Called when complete input is parsed and validated +- **onOutput**: Called after the tool successfully executes with the output + +For detailed documentation on all lifecycle hooks, see the [createTool() reference](https://mastra.ai/reference/v1/tools/create-tool#tool-lifecycle-hooks). + +## Tool using an agent + +Pipe an agent's `fullStream` to the tool's `writer`. This streams partial output, and Mastra automatically aggregates the agent's usage into the tool run. + +```typescript +import { createTool } from "@mastra/core/tools"; +import { z } from "zod"; + +export const testTool = createTool({ + execute: async (inputData, context) => { + const { city } = inputData; + + const agent = context?.mastra?.getAgent("testAgent"); + const stream = await agent?.stream(\`What is the weather in ${city}?\`); + + await stream!.fullStream.pipeTo(context?.writer!); + + return { + value: await stream!.text, + }; + }, +}); +``` \ No newline at end of file diff --git a/docs/tools-worklows/Workflow streaming Streaming Mastra Docs v1 Beta.md b/docs/tools-worklows/Workflow streaming Streaming Mastra Docs v1 Beta.md new file mode 100644 index 00000000..509d0361 --- /dev/null +++ b/docs/tools-worklows/Workflow streaming Streaming Mastra Docs v1 Beta.md @@ -0,0 +1,119 @@ +--- +title: "Workflow streaming | Streaming | Mastra Docs v1 Beta" +source: "https://mastra.ai/docs/v1/streaming/workflow-streaming" +author: +published: +created: 2026-01-13 +description: "Mastra v1 Beta: Learn how to use workflow streaming in Mastra, including handling workflow execution events, step streaming, and workflow integration with agents and tools." +tags: + - "clippings" +--- +Workflow streaming in Mastra enables workflows to send incremental results while they execute, rather than waiting until completion. This allows you to surface partial progress, intermediate states, or progressive data directly to users or upstream agents and workflows. + +Streams can be written to in two main ways: + +- **From within a workflow step**: every workflow step receives a `writer` argument, which is a writable stream you can use to push updates as execution progresses. +- **From an agent stream**: you can also pipe an agent's `stream` output directly into a workflow step's writer, making it easy to chain agent responses into workflow results without extra glue code. + +By combining writable workflow streams with agent streaming, you gain fine-grained control over how intermediate results flow through your system and into the user experience. + +### Using the writer argument + +The `writer` argument is passed to a workflow step's `execute` function and can be used to emit custom events, data, or values into the active stream. This enables workflow steps to provide intermediate results or status updates while execution is still in progress. + +warning + +You must `await` the call to `writer.write(...)` or else you will lock the stream and get a `WritableStream is locked` error. + +```typescript +import { createStep } from "@mastra/core/workflows"; + +export const testStep = createStep({ + execute: async ({ inputData, writer }) => { + const { value } = inputData; + + await writer?.write({ + type: "custom-event", + status: "pending" + }); + + const response = await fetch(...); + + await writer?.write({ + type: "custom-event", + status: "success" + }); + + return { + value: "" + }; + }, +}); +``` + +### Inspecting workflow stream payloads + +Events written to the stream are included in the emitted chunks. These chunks can be inspected to access any custom fields, such as event types, intermediate values, or step-specific data. + +```typescript +const testWorkflow = mastra.getWorkflow("testWorkflow"); + +const run = await testWorkflow.createRun(); + +const stream = await run.stream({ + inputData: { + value: "initial data", + }, +}); + +for await (const chunk of stream) { + console.log(chunk); +} + +if (result!.status === "suspended") { + // if the workflow is suspended, we can resume it with the resumeStream method + const resumedStream = await run.resumeStream({ + resumeData: { value: "resume data" }, + }); + + for await (const chunk of resumedStream) { + console.log(chunk); + } +} +``` + +### Resuming an interrupted workflow stream + +If a workflow stream is closed or interrupted for any reason, you can resume it with the `resumeStream` method. This will return a new `ReadableStream` that you can use to observe the workflow events. + +```typescript +const newStream = await run.resumeStream(); + +for await (const chunk of newStream) { + console.log(chunk); +} +``` + +## Workflow using an agent + +Pipe an agent's `textStream` to the workflow step's `writer`. This streams partial output, and Mastra automatically aggregates the agent's usage into the workflow run. + +```typescript +import { createStep } from "@mastra/core/workflows"; +import { z } from "zod"; + +export const testStep = createStep({ + execute: async ({ inputData, mastra, writer }) => { + const { city } = inputData; + + const testAgent = mastra?.getAgent("testAgent"); + const stream = await testAgent?.stream(\`What is the weather in ${city}$?\`); + + await stream!.textStream.pipeTo(writer!); + + return { + value: await stream!.text, + }; + }, +}); +``` \ No newline at end of file diff --git "a/docs/ui-dojo/Tools_-agent-stream-tool.ts at main \302\267 mastra-aiui-dojo.md" "b/docs/ui-dojo/Tools_-agent-stream-tool.ts at main \302\267 mastra-aiui-dojo.md" new file mode 100644 index 00000000..f3945d5a --- /dev/null +++ "b/docs/ui-dojo/Tools_-agent-stream-tool.ts at main \302\267 mastra-aiui-dojo.md" @@ -0,0 +1,66 @@ +--- +title: "ui-dojo/src/mastra/tools/nested-agent-stream-tool.ts at main · mastra-ai/ui-dojo" +source: "https://github.com/mastra-ai/ui-dojo/blob/main/src/mastra/tools/report-generation-tool.ts" +author: + - "[[LekoArts]]" +published: +created: 2026-01-13 +description: "Mastra + UI Frameworks. Contribute to mastra-ai/ui-dojo development by creating an account on GitHub." +tags: + - "clippings" +--- +[Skip to content](https://github.com/mastra-ai/ui-dojo/blob/main/src/mastra/tools/#start-of-content) + +```ts +import { createTool } from "@mastra/core/tools"; +import { z } from "zod"; + +export const reportGenerationTool = createTool({ + id: "generate-report", + description: "Generate a report with progress updates", + inputSchema: z.object({ + topic: z.string().describe("The topic for the report"), + }), + outputSchema: z.object({ + report: z.string(), + }), + execute: async (inputData, context) => { + const { topic } = inputData; + + await context?.writer?.custom({ + type: "data-tool-progress", + data: { + status: "in-progress", + message: `Generating report on: ${topic}`, + stage: "report-generation", + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + await context?.writer?.custom({ + type: "data-tool-progress", + data: { + status: "in-progress", + message: `Writing report sections...`, + stage: "report-generation", + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 1500)); + + await context?.writer?.custom({ + type: "data-tool-progress", + data: { + status: "done", + message: `Report generated for: ${topic}`, + stage: "report-generation", + }, + }); + + return { + report: `Report on ${topic}: This comprehensive report covers all aspects of the topic with detailed analysis and recommendations.`, + }; + }, +}); +``` \ No newline at end of file diff --git "a/docs/ui-dojo/Tools_nested-agent-stream-tool.ts at main \302\267 mastra-aiui-dojo 6.md" "b/docs/ui-dojo/Tools_nested-agent-stream-tool.ts at main \302\267 mastra-aiui-dojo 6.md" new file mode 100644 index 00000000..6a286351 --- /dev/null +++ "b/docs/ui-dojo/Tools_nested-agent-stream-tool.ts at main \302\267 mastra-aiui-dojo 6.md" @@ -0,0 +1,149 @@ +--- +title: "ui-dojo/src/mastra/tools/nested-agent-stream-tool.ts at main · mastra-ai/ui-dojo" +source: "https://github.com/mastra-ai/ui-dojo/blob/main/src/pages/ai-sdk/generative-user-interfaces-with-custom-events.tsx" +author: + - "[[LekoArts]]" +published: +created: 2026-01-13 +description: "Mastra + UI Frameworks. Contribute to mastra-ai/ui-dojo development by creating an account on GitHub." +tags: + - "clippings" +--- + +src/pages/ai-sdk/generative-user-interfaces-with-custom-events.tsx + +```ts +import { Message, MessageContent } from "@/components/ai-elements/message"; +import { Response } from "@/components/ai-elements/response"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useChat } from "@ai-sdk/react"; +import { DefaultChatTransport } from "ai"; +import { useMemo, useState } from "react"; +import { MASTRA_BASE_URL } from "@/constants"; +import { Badge } from "@/components/ui/badge"; +import { CheckCircle2, Loader2 } from "lucide-react"; + +type ProgressData = + | { + status: "in-progress" | "done"; + message: string; + } + | undefined; + +const ProgressIndicator = ({ progress }: { progress: ProgressData }) => { + if (!progress) return null; + + return ( +
+ {progress.status === "in-progress" ? ( + + ) : ( + + )} +
+ + {progress.status === "in-progress" ? "In Progress" : "Done"} + +
{progress.message}
+
+
+ ); +}; + +const GenerativeUserInterfacesCustomEventsDemo = () => { + const [input, setInput] = useState(""); + const { messages, sendMessage, status } = useChat({ + transport: new DefaultChatTransport({ + api: `${MASTRA_BASE_URL}/chat/taskAgent`, + }), + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + sendMessage({ text: input }); + setInput(""); + }; + + const toolProgress = useMemo(() => { + // Find all progress parts across all messages + const allProgressParts: Array<{ data?: ProgressData }> = []; + messages.forEach((message) => { + message.parts.forEach((part) => { + if (part.type === "data-tool-progress") { + allProgressParts.push(part as { data?: ProgressData }); + } + }); + }); + // Get the last progress event and extract its data + if (allProgressParts.length > 0) { + const lastPart = allProgressParts[allProgressParts.length - 1]; + return lastPart.data; + } + return undefined; + }, [messages]); + + return ( +
+
+
+ setInput(e.target.value)} + placeholder={`Enter a task to process, e.g. "Generate report" or "Process data"`} + /> + +
+
+
+ {messages.map((message, idx) => ( +
+ {toolProgress && idx === messages.length - 1 ? ( +
+ +
+ ) : null} +
+ {message.parts.map((part, index) => { + if (part.type === "text" && message.role === "user") { + return ( + + + {part.text} + + + ); + } + + if (part.type === "text" && message.role === "assistant") { + return ( + + + {part.text} + + + ); + } + + return null; + })} +
+
+ ))} +
+
+ ); +}; + +export default GenerativeUserInterfacesCustomEventsDemo; +``` \ No newline at end of file diff --git a/docs/ui-dojo/Workflow Agent Text Streaming.md b/docs/ui-dojo/Workflow Agent Text Streaming.md new file mode 100644 index 00000000..8439b616 --- /dev/null +++ b/docs/ui-dojo/Workflow Agent Text Streaming.md @@ -0,0 +1,209 @@ +--- +title: "ui-dojo/src/mastra/tools/nested-agent-stream-tool.ts at main · mastra-ai/ui-dojo" +source: "https://github.com/mastra-ai/ui-dojo/blob/main/src/pages/ai-sdk/workflow-agent-text-stream.tsx" +author: + - "[[LekoArts]]" +published: +created: 2026-01-13 +description: "Mastra + UI Frameworks. Contribute to mastra-ai/ui-dojo development by creating an account on GitHub." +tags: + - "clippings" +--- +Workflow Agent Text Streaming Demo +```ts +import { Loader } from "@/components/ai-elements/loader"; +import { Message, MessageContent } from "@/components/ai-elements/message"; +import { Response } from "@/components/ai-elements/response"; +import { + Tool, + ToolContent, + ToolHeader, + ToolOutput, +} from "@/components/ai-elements/tool"; +import { Button } from "@/components/ui/button"; +import { MASTRA_BASE_URL } from "@/constants"; +import { Input } from "@/components/ui/input"; +import { useChat } from "@ai-sdk/react"; +import { DefaultChatTransport, type ToolUIPart } from "ai"; +import { Fragment, useState } from "react"; + +type DisplayStepProps = { + type: `tool-${string}`; + title: string; + status: "running" | "success" | "failed" | "suspended" | "waiting"; + text: unknown; +}; + +const STATUS_MAP: Record = { + running: "input-available", + waiting: "input-available", + suspended: "output-error", + success: "output-available", + failed: "output-error", +}; + +const DisplayStep = ({ type, status, text, title }: DisplayStepProps) => { + const errorText = `Could not process ${title}`; + + return ( + + + + + + + ); +}; + +type StepResult = { + name: "agent-text-stream-workflow"; + steps: { + "analyze-weather": { + name: "analyze-weather"; + status: DisplayStepProps["status"]; + output: { + analysis: string; + location: string; + }; + }; + "calculate-comfort": { + name: "calculate-comfort"; + status: DisplayStepProps["status"]; + output: { + comfortScore: number; + summary: string; + location: string; + }; + }; + }; +}; + +const WorkflowAgentTextStreamingDemo = () => { + const [input, setInput] = useState(""); + const { messages, sendMessage, status } = useChat({ + transport: new DefaultChatTransport({ + api: `${MASTRA_BASE_URL}/workflow-agent-text-stream`, + prepareSendMessagesRequest: ({ messages }) => { + return { + body: { + inputData: { + location: + messages[messages.length - 1].parts.find( + (part) => part.type === "text", + )?.text || "", + }, + }, + }; + }, + }), + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + sendMessage({ text: input }); + setInput(""); + }; + + return ( +
+
+
+ setInput(e.target.value)} + placeholder="Enter a city name" + /> + +
+
+ {messages.map((message) => ( +
+ {message.parts.map((part, index) => { + // Display user input + if (part.type === "text" && message.role === "user") { + return ( + + + {part.text} + + + ); + } + + // Display agent's streamed text chunks + if (part.type === "text" && message.role === "assistant") { + return ( + + +
+ {status === "streaming" ? ( +

+ Agent analyzing... +

+ ) : null} + {part.text} +
+
+
+ ); + } + + // Display workflow data and steps + if (part.type === "data-workflow") { + const type = `tool-${part.type}` as const; + const steps = Object.values((part.data as StepResult).steps); + const lastStep = steps.find( + (step) => step.name === "calculate-comfort", + ); + + return ( + + {steps.map((step) => ( + + ))} + {status === "ready" && lastStep && ( + + +
+
+
+ {lastStep.output.comfortScore} +
+
+ / 100 +
+
+ {lastStep.output.summary} +
+
+
+ )} +
+ ); + } + + return null; + })} +
+ ))} + {status === "submitted" && } +
+ ); +}; + +export default WorkflowAgentTextStreamingDemo; +``` \ No newline at end of file diff --git a/docs/ui-dojo/ai-sdk_agent-network-custom-events.tsx.md b/docs/ui-dojo/ai-sdk_agent-network-custom-events.tsx.md new file mode 100644 index 00000000..f2f1836b --- /dev/null +++ b/docs/ui-dojo/ai-sdk_agent-network-custom-events.tsx.md @@ -0,0 +1,245 @@ +--- +title: "ui-dojo/src/mastra/tools/nested-agent-stream-tool.ts at main · mastra-ai/ui-dojo" +source: "https://github.com/mastra-ai/ui-dojo/blob/main/src/pages/ai-sdk/agent-network-custom-events.tsx" +author: + - "[[LekoArts]]" +published: +created: 2026-01-13 +description: "Mastra + UI Frameworks. Contribute to mastra-ai/ui-dojo development by creating an account on GitHub." +tags: + - "clippings" +--- +src/pages/ai-sdk/agent-network-custom-events.tsx + +```ts +import { Message, MessageContent } from "@/components/ai-elements/message"; +import { Response } from "@/components/ai-elements/response"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { useChat } from "@ai-sdk/react"; +import { DefaultChatTransport } from "ai"; +import { useMemo, useState } from "react"; +import { MASTRA_BASE_URL } from "@/constants"; +import { Badge } from "@/components/ui/badge"; +import { CheckCircle2, Loader2, FileEdit } from "lucide-react"; + +type ProgressData = { + status: "in-progress" | "done"; + message: string; + stage?: "report-generation" | "report-review"; +}; + +const ProgressIndicator = ({ + progress, +}: { + progress: ProgressData & { stage?: string }; + agentName?: string; +}) => { + if (!progress) return null; + + const getStageName = () => { + switch (progress.stage) { + case "report-generation": + return "Report Generation"; + case "report-review": + return "Report Review"; + default: + return "Processing"; + } + }; + + return ( +
+
+ + {progress.status === "in-progress" ? ( + <> + In + Progress + + ) : ( + <> + Done + + )} + +
+ {getStageName()} +
+
+
{progress.message}
+
+ ); +}; + +const AgentNetworkCustomEventsDemo = () => { + const [topic, setTopic] = useState(""); + const { messages, sendMessage, status, setMessages } = useChat({ + transport: new DefaultChatTransport({ + api: `${MASTRA_BASE_URL}/network-custom-events`, + }), + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!topic.trim()) return; + sendMessage({ text: `Generate a report on ${topic}` }); + setTopic(""); + setMessages([]); + }; + + // Collect all progress events + const progressEvents = useMemo(() => { + const events: Array = + []; + messages.forEach((message) => { + message.parts.forEach((part) => { + if (part.type === "data-tool-progress") { + const data = (part.data || {}) as ProgressData; + if (data) { + let agentName: string | undefined; + if (data.stage === "report-generation") { + agentName = "Report Generation Agent"; + } else if (data.stage === "report-review") { + agentName = "Report Review Agent"; + } + events.push({ ...data, agentName }); + } + } + }); + }); + return events; + }, [messages]); + + // Get the latest event for each stage + const latestByStage = useMemo(() => { + const byStage: Record< + string, + ProgressData & { stage?: string; agentName?: string } + > = {}; + progressEvents.forEach((event) => { + if (event.stage) { + if (!byStage[event.stage] || event.status === "done") { + byStage[event.stage] = event; + } + } + }); + return byStage; + }, [progressEvents]); + + return ( +
+
+ + +
+ + Report Generator +
+ + Generate or review reports on any topic. The Report Agent Network + will route to the appropriate specialized agent. + +
+ +
+
+ + setTopic(e.target.value)} + placeholder="Q4 Performance, Sales Analysis, Market Trends" + required + /> +
+ +
+
+
+
+
+ {messages.map((message, idx) => { + // Check if message has any renderable text parts + const hasTextParts = message.parts.some( + (part) => part.type === "text" && part.text?.trim(), + ); + const isLatestMessage = idx === messages.length - 1; + const hasProgress = + isLatestMessage && Object.keys(latestByStage).length > 0; + + // Only render if there's content to show + if (!hasTextParts && !hasProgress) { + return null; + } + + return ( +
+
+ {/* Show progress indicators for the latest message */} + {hasProgress && ( +
+ {Object.entries(latestByStage).map(([stage, event]) => ( + + ))} +
+ )} + {message.parts.map((part, index) => { + if ( + part.type === "text" && + message.role === "user" && + part.text?.trim() + ) { + return ( + + + {part.text} + + + ); + } + + if ( + part.type === "text" && + message.role === "assistant" && + part.text?.trim() + ) { + return ( + + + {part.text} + + + ); + } + + return null; + })} +
+
+ ); + })} +
+
+ ); +}; + +export default AgentNetworkCustomEventsDemo; +``` \ No newline at end of file diff --git a/docs/ui-dojo/ai-sdk_tool-nested-streams.tsx.md b/docs/ui-dojo/ai-sdk_tool-nested-streams.tsx.md new file mode 100644 index 00000000..1c31bde5 --- /dev/null +++ b/docs/ui-dojo/ai-sdk_tool-nested-streams.tsx.md @@ -0,0 +1,134 @@ +--- +title: "ui-dojo/src/mastra/tools/nested-agent-stream-tool.ts at main · mastra-ai/ui-dojo" +source: "https://github.com/mastra-ai/ui-dojo/blob/main/src/pages/ai-sdk/tool-nested-streams.tsx" +author: + - "[[LekoArts]]" +published: +created: 2026-01-13 +description: "Mastra + UI Frameworks. Contribute to mastra-ai/ui-dojo development by creating an account on GitHub." +tags: + - "clippings" +--- +src/pages/ai-sdk/tool-nested-streams.tsx + +```ts +import { Message, MessageContent } from "@/components/ai-elements/message"; +import { Response } from "@/components/ai-elements/response"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useChat } from "@ai-sdk/react"; +import { DefaultChatTransport } from "ai"; +import { useState } from "react"; +import { MASTRA_BASE_URL } from "@/constants"; +import { Badge } from "@/components/ui/badge"; +import { CodeBlock } from "@/components/ai-elements/code-block"; +import { Loader } from "@/components/ai-elements/loader"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { ChevronDownIcon } from "lucide-react"; +import type { AgentDataPart, WorkflowDataPart } from "@mastra/ai-sdk"; + +type NestedPart = AgentDataPart | WorkflowDataPart; + +const ToolNestedStreamsDemo = () => { + const [question, setQuestion] = useState(""); + const { messages, sendMessage, status } = useChat({ + transport: new DefaultChatTransport({ + api: `${MASTRA_BASE_URL}/chat/weatherForecastAgent`, + }), + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!question.trim()) return; + sendMessage({ text: question }); + setQuestion(""); + }; + + return ( +
+
+
+ setQuestion(e.target.value)} + placeholder="Enter a city name" + /> + +
+
+ +
+ {messages.map((message) => { + return ( +
+ {message.parts.map((part, index) => { + switch (part.type) { + case "text": { + return ( + + + {part.text} + + + ); + } + case "data-tool-agent": + case "data-tool-workflow": { + const nestedPart = part as NestedPart; + return ( + + + + {nestedPart.type === "data-tool-agent" + ? "Weather Agent Stream" + : "Weather Workflow Stream"} + +
+ + Tool Nested Stream + + +
+
+ + + +
+ ); + } + default: + return null; + } + })} +
+ ); + })} + {status === "submitted" && } +
+
+ ); +}; + +export default ToolNestedStreamsDemo; +``` \ No newline at end of file diff --git a/docs/ui-dojo/mastra-aiui-dojo Mastra + UI Frameworks.md b/docs/ui-dojo/mastra-aiui-dojo Mastra + UI Frameworks.md new file mode 100644 index 00000000..9e1891fc --- /dev/null +++ b/docs/ui-dojo/mastra-aiui-dojo Mastra + UI Frameworks.md @@ -0,0 +1,62 @@ +--- +title: "mastra-ai/ui-dojo: Mastra + UI Frameworks" +source: "https://github.com/mastra-ai/ui-dojo/blob/main/src/mastra/tools/nested-agent-stream-tool.ts" +author: + - "[[LekoArts]]" +published: +created: 2026-01-13 +description: "Mastra + UI Frameworks. Contribute to mastra-ai/ui-dojo development by creating an account on GitHub." +tags: + - "clippings" +--- +[Skip to content](https://github.com/mastra-ai/ui-dojo/blob/main/src/mastra/tools/#start-of-content) + +```ts +import { createTool } from "@mastra/core/tools"; +import { z } from "zod"; + +export const nestedWeatherAgentStreamTool = createTool({ + id: "nested-weather-agent-stream", + description: + "Use the weather agent inside tool to get the weather for a city", + inputSchema: z.object({ + city: z.string().describe("The city to analyze weather for"), + }), + outputSchema: z.object({ + summary: z + .string() + .describe("A concise weather summary generated by the nested agent"), + }), + // See https://mastra.ai/docs/streaming/tool-streaming#tool-using-an-agent + execute: async (inputData, context) => { + const { city } = inputData; + + const weatherAgent = context?.mastra?.getAgent("weatherAgent"); + + if (!weatherAgent) { + return { + summary: `Unable to run nested agent stream – weather agent is not available for "${city}".`, + }; + } + + // Stream the nested agent from inside this tool. + // Mastra will emit nested data-tool-agent and data-tool-workflow parts that can + // be rendered by the UI as part of the same tool run. + const stream = await weatherAgent.stream( + `Analyze the current weather in ${city} and return a short, user-facing summary.`, + ); + + // Pipe the agent's textStream into the tool writer so the UI receives + // nested and merged chunks while the tool is running. + await stream!.fullStream.pipeTo(context!.writer!); + + const text = await stream.text; + + return { + summary: + text ?? + `Finished nested agent run for ${city}, but no summary text was returned.`, + }; + }, +}); +``` diff --git a/docs/ui-dojo/ui-dojo_ai-sdk_sub-agents-and-workflows-custom-events.tsx.md b/docs/ui-dojo/ui-dojo_ai-sdk_sub-agents-and-workflows-custom-events.tsx.md new file mode 100644 index 00000000..9e59aab5 --- /dev/null +++ b/docs/ui-dojo/ui-dojo_ai-sdk_sub-agents-and-workflows-custom-events.tsx.md @@ -0,0 +1,279 @@ +--- +title: "ui-dojo/src/mastra/tools/nested-agent-stream-tool.ts at main · mastra-ai/ui-dojo" +source: "https://github.com/mastra-ai/ui-dojo/blob/main/src/pages/ai-sdk/sub-agents-and-workflows-custom-events.tsx" +author: + - "[[LekoArts]]" +published: +created: 2026-01-13 +description: "Mastra + UI Frameworks. Contribute to mastra-ai/ui-dojo development by creating an account on GitHub." +tags: + - "clippings" +--- +src/pages/ai-sdk/sub-agents-and-workflows-custom-events.tsx + +```ts +import { Message, MessageContent } from "@/components/ai-elements/message"; +import { Response } from "@/components/ai-elements/response"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { useChat } from "@ai-sdk/react"; +import { DefaultChatTransport } from "ai"; +import { useMemo, useState } from "react"; +import { MASTRA_BASE_URL } from "@/constants"; +import { Badge } from "@/components/ui/badge"; +import { CheckCircle2, Loader2, ShoppingCart } from "lucide-react"; + +type ProgressData = { + status: "in-progress" | "done"; + message: string; + stage?: "inventory" | "payment" | "shipping"; +}; + +const ProgressIndicator = ({ + progress, +}: { + progress: ProgressData & { stage?: string }; +}) => { + if (!progress) return null; + + const getStageName = () => { + switch (progress.stage) { + case "inventory": + return "Inventory Check"; + case "payment": + return "Payment Processing"; + case "shipping": + return "Shipping Preparation"; + default: + return "Processing"; + } + }; + + return ( +
+
+ + {progress.status === "in-progress" ? ( + <> + In + Progress + + ) : ( + <> + Done + + )} + +
+
+
+
+ {getStageName()} +
+
{progress.message}
+
+
+
+ ); +}; + +const SubAgentsAndWorkflowsCustomEventsDemo = () => { + const [productId, setProductId] = useState(""); + const [quantity, setQuantity] = useState(""); + const [amount, setAmount] = useState(""); + const { messages, sendMessage, status } = useChat({ + transport: new DefaultChatTransport({ + api: `${MASTRA_BASE_URL}/chat/orderProcessingAgent`, + }), + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!productId || !quantity || !amount) return; + + const orderMessage = `Place an order for product ${productId}, quantity ${quantity}, amount ${amount}`; + sendMessage({ text: orderMessage }); + setProductId(""); + setQuantity(""); + setAmount(""); + }; + + // Collect all progress events grouped by stage + const progressEvents = useMemo(() => { + const events: Array = []; + messages.forEach((message) => { + message.parts.forEach((part) => { + if (part.type === "data-tool-progress") { + const data = (part.data || {}) as ProgressData; + if (data) { + events.push(data); + } + } + }); + }); + return events; + }, [messages]); + + // Get the latest event for each stage + const latestByStage = useMemo(() => { + const byStage: Record = {}; + progressEvents.forEach((event) => { + if (event.stage) { + // Only keep the latest event for each stage + if (!byStage[event.stage] || event.status === "done") { + byStage[event.stage] = event; + } + } + }); + return byStage; + }, [progressEvents]); + + const hasProgress = Object.keys(latestByStage).length > 0; + + return ( +
+ + +
+ + Place an Order +
+ + Enter the product details to process your order. The agent network + will coordinate inventory check, payment, and shipping, while + emitting structured progress events. + +
+ +
+
+
+ + setProductId(e.target.value)} + placeholder="ABC123" + required + /> +
+
+ + setQuantity(e.target.value)} + placeholder="2" + required + /> +
+
+ + setAmount(e.target.value)} + placeholder="29.99" + required + /> +
+
+ +
+
+
+ + {/* Progress events container, separate from text streaming */} + {hasProgress && ( + + + Order Processing Status + + Structured progress events emitted by the sub-agents and + workflows. This is separate from the natural-language responses in + the chat below. + + + + {Object.entries(latestByStage) + .sort(([a], [b]) => { + // Order: inventory -> payment -> shipping + const order = { inventory: 0, payment: 1, shipping: 2 }; + return ( + (order[a as keyof typeof order] ?? 99) - + (order[b as keyof typeof order] ?? 99) + ); + }) + .map(([stage, event]) => ( + + ))} + + + )} + +
+ {messages.map((message) => ( +
+
+ {message.parts.map((part, index) => { + if (part.type === "text" && message.role === "user") { + return ( + + + {part.text} + + + ); + } + + if (part.type === "text" && message.role === "assistant") { + return ( + + + {part.text} + + + ); + } + + return null; + })} +
+
+ ))} +
+
+ ); +}; + +export default SubAgentsAndWorkflowsCustomEventsDemo; +``` + diff --git a/docs/ui-dojo/workflows_.md b/docs/ui-dojo/workflows_.md new file mode 100644 index 00000000..c1f2595e --- /dev/null +++ b/docs/ui-dojo/workflows_.md @@ -0,0 +1,16 @@ +--- +title: "ui-dojo/src/mastra/tools/nested-agent-stream-tool.ts at main · mastra-ai/ui-dojo" +source: "https://github.com/mastra-ai/ui-dojo/blob/main/src/mastra/workflows/agent-text-stream-workflow.ts" +author: + - "[[LekoArts]]" +published: +created: 2026-01-13 +description: "Mastra + UI Frameworks. Contribute to mastra-ai/ui-dojo development by creating an account on GitHub." +tags: + - "clippings" +--- +[Skip to content](https://github.com/mastra-ai/ui-dojo/blob/main/src/mastra/workflows/#start-of-content) + +```ts + +``` \ No newline at end of file diff --git a/docs/ui-dojo/workflows_branching-workflow.ts.md b/docs/ui-dojo/workflows_branching-workflow.ts.md new file mode 100644 index 00000000..28e0d113 --- /dev/null +++ b/docs/ui-dojo/workflows_branching-workflow.ts.md @@ -0,0 +1,247 @@ +--- +title: "ui-dojo/src/mastra/tools/nested-agent-stream-tool.ts at main · mastra-ai/ui-dojo" +source: "https://github.com/mastra-ai/ui-dojo/blob/main/src/mastra/workflows/branching-workflow.ts" +author: + - "[[LekoArts]]" +published: +created: 2026-01-13 +description: "Mastra + UI Frameworks. Contribute to mastra-ai/ui-dojo development by creating an account on GitHub." +tags: + - "clippings" +--- +```ts +import { createStep, createWorkflow } from "@mastra/core/workflows"; +import { z } from "zod"; + +// Step 1: Validate order +const validateOrder = createStep({ + id: "validate-order", + description: "Validate the order details", + inputSchema: z.object({ + orderId: z.string(), + orderType: z.enum(["standard", "express"]), + amount: z.number(), + }), + outputSchema: z.object({ + orderId: z.string(), + orderType: z.enum(["standard", "express"]), + amount: z.number(), + isValid: z.boolean(), + }), + execute: async ({ inputData, writer }) => { + await writer?.custom({ + type: "data-tool-progress", + data: { + status: "in-progress", + message: `Validating order ${inputData.orderId}...`, + stage: "validation", + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + await writer?.custom({ + type: "data-tool-progress", + data: { + status: "done", + message: `Order validated successfully`, + stage: "validation", + }, + }); + + return { + ...inputData, + isValid: true, + }; + }, +}); + +// Nested Workflow 1: Standard Processing +const standardProcessStep = createStep({ + id: "standard-process", + description: "Process standard order", + inputSchema: z.object({ + orderId: z.string(), + orderType: z.enum(["standard", "express"]), + amount: z.number(), + isValid: z.boolean(), + }), + outputSchema: z.object({ + orderId: z.string(), + processingTime: z.string(), + shippingMethod: z.string(), + }), + execute: async ({ inputData, writer }) => { + await writer?.custom({ + type: "data-tool-progress", + data: { + status: "in-progress", + message: `Processing standard order ${inputData.orderId}...`, + stage: "standard-processing", + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 1500)); + + await writer?.custom({ + type: "data-tool-progress", + data: { + status: "in-progress", + message: `Preparing standard shipping (5-7 business days)...`, + stage: "standard-processing", + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 1500)); + + await writer?.custom({ + type: "data-tool-progress", + data: { + status: "done", + message: `Standard order processed successfully`, + stage: "standard-processing", + }, + }); + + return { + orderId: inputData.orderId, + processingTime: "5-7 business days", + shippingMethod: "Standard Ground", + }; + }, +}); + +const standardWorkflow = createWorkflow({ + id: "standard-shipping-workflow", + description: "Workflow for standard shipping orders", + inputSchema: z.object({ + orderId: z.string(), + orderType: z.enum(["standard", "express"]), + amount: z.number(), + isValid: z.boolean(), + }), + outputSchema: z.object({ + orderId: z.string(), + processingTime: z.string(), + shippingMethod: z.string(), + }), +}).then(standardProcessStep); + +standardWorkflow.commit(); + +// Nested Workflow 2: Express Processing +const expressProcessStep = createStep({ + id: "express-process", + description: "Process express order", + inputSchema: z.object({ + orderId: z.string(), + orderType: z.enum(["standard", "express"]), + amount: z.number(), + isValid: z.boolean(), + }), + outputSchema: z.object({ + orderId: z.string(), + processingTime: z.string(), + shippingMethod: z.string(), + }), + execute: async ({ inputData, writer }) => { + await writer?.custom({ + type: "data-tool-progress", + data: { + status: "in-progress", + message: `Processing express order ${inputData.orderId}...`, + stage: "express-processing", + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + await writer?.custom({ + type: "data-tool-progress", + data: { + status: "in-progress", + message: `Priority handling activated...`, + stage: "express-processing", + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + await writer?.custom({ + type: "data-tool-progress", + data: { + status: "in-progress", + message: `Preparing express shipping (1-2 business days)...`, + stage: "express-processing", + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + await writer?.custom({ + type: "data-tool-progress", + data: { + status: "done", + message: `Express order processed successfully`, + stage: "express-processing", + }, + }); + + return { + orderId: inputData.orderId, + processingTime: "1-2 business days", + shippingMethod: "Express Overnight", + }; + }, +}); + +const expressWorkflow = createWorkflow({ + id: "express-shipping-workflow", + description: "Workflow for express shipping orders", + inputSchema: z.object({ + orderId: z.string(), + orderType: z.enum(["standard", "express"]), + amount: z.number(), + isValid: z.boolean(), + }), + outputSchema: z.object({ + orderId: z.string(), + processingTime: z.string(), + shippingMethod: z.string(), + }), +}).then(expressProcessStep); + +expressWorkflow.commit(); + +// Main branching workflow +export const branchingWorkflow = createWorkflow({ + id: "branching-workflow", + description: + "A workflow that branches based on order type to different processing workflows", + inputSchema: z.object({ + orderId: z.string().describe("The order ID"), + orderType: z + .enum(["standard", "express"]) + .describe("Type of shipping: standard or express"), + amount: z.number().describe("The order amount"), + }), + outputSchema: z.object({ + orderId: z.string(), + processingTime: z.string(), + shippingMethod: z.string(), + }), +}) + .then(validateOrder) + .branch([ + [ + async ({ inputData }) => inputData.orderType === "standard", + standardWorkflow, + ], + [ + async ({ inputData }) => inputData.orderType === "express", + expressWorkflow, + ], + ]); + +branchingWorkflow.commit(); +``` \ No newline at end of file diff --git a/docs/ui-dojo/workflows_order-fulfillment-workflow.ts.md b/docs/ui-dojo/workflows_order-fulfillment-workflow.ts.md new file mode 100644 index 00000000..32213620 --- /dev/null +++ b/docs/ui-dojo/workflows_order-fulfillment-workflow.ts.md @@ -0,0 +1,162 @@ +--- +title: "ui-dojo/src/mastra/tools/nested-agent-stream-tool.ts at main · mastra-ai/ui-dojo" +source: "https://github.com/mastra-ai/ui-dojo/blob/main/src/mastra/workflows/order-fulfillment-workflow.ts" +author: + - "[[LekoArts]]" +published: +created: 2026-01-13 +description: "Mastra + UI Frameworks. Contribute to mastra-ai/ui-dojo development by creating an account on GitHub." +tags: + - "clippings" +--- +workflows/#start-of-content) + +```ts +import { createStep, createWorkflow } from "@mastra/core/workflows"; +import { z } from "zod"; + +const processPayment = createStep({ + id: "process-payment", + description: "Process payment for the order", + inputSchema: z.object({ + orderId: z.string(), + amount: z.number(), + paymentMethod: z.string(), + productName: z.string(), + }), + outputSchema: z.object({ + paymentId: z.string(), + status: z.string(), + orderId: z.string(), + productName: z.string(), + }), + execute: async ({ inputData, writer }) => { + const { amount, orderId, productName } = inputData!; + + // Emit custom event for payment processing start + await writer?.custom({ + type: "data-tool-progress", + data: { + status: "in-progress", + message: `Processing payment of $${amount.toFixed(2)}...`, + stage: "payment", + }, + }); + + // Simulate payment processing + await new Promise((resolve) => setTimeout(resolve, 2000)); + + await writer?.custom({ + type: "data-tool-progress", + data: { + status: "in-progress", + message: `Verifying payment method...`, + stage: "payment", + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 1500)); + + // Emit done event + await writer?.custom({ + type: "data-tool-progress", + data: { + status: "done", + message: `Payment processed successfully`, + stage: "payment", + }, + }); + + const paymentId = `PAY-${Date.now()}`; + + return { + paymentId, + status: "completed", + orderId, + productName, + }; + }, +}); + +const prepareShipping = createStep({ + id: "prepare-shipping", + description: "Prepare the order for shipping", + inputSchema: z.object({ + orderId: z.string(), + paymentId: z.string(), + productName: z.string(), + status: z.string(), + }), + outputSchema: z.object({ + trackingNumber: z.string(), + estimatedDelivery: z.string(), + }), + execute: async ({ inputData, writer }) => { + const { orderId, productName } = inputData!; + + // Emit custom event for shipping preparation start + await writer?.custom({ + type: "data-tool-progress", + data: { + status: "in-progress", + message: `Preparing order ${orderId} for shipping...`, + stage: "shipping", + }, + }); + + // Simulate shipping preparation + await new Promise((resolve) => setTimeout(resolve, 1500)); + + await writer?.custom({ + type: "data-tool-progress", + data: { + status: "in-progress", + message: `Labeling package for ${productName}...`, + stage: "shipping", + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 1500)); + + // Emit done event + await writer?.custom({ + type: "data-tool-progress", + data: { + status: "done", + message: `Order ready for shipment`, + stage: "shipping", + }, + }); + + const trackingNumber = `TRK-${Date.now()}`; + const estimatedDelivery = new Date( + Date.now() + 3 * 24 * 60 * 60 * 1000, + ).toLocaleDateString(); + + return { + trackingNumber, + estimatedDelivery, + }; + }, +}); + +export const orderFulfillmentWorkflow = createWorkflow({ + id: "order-fulfillment-workflow", + description: + "This workflow processes an order by first handling payment, then preparing it for shipping", + inputSchema: z.object({ + orderId: z.string().describe("The order ID"), + amount: z.number().describe("The order amount"), + paymentMethod: z.string().describe("The payment method"), + productName: z.string().describe("The product name"), + }), + outputSchema: z.object({ + trackingNumber: z.string(), + estimatedDelivery: z.string(), + }), +}) + .then(processPayment) + .then(prepareShipping); + +orderFulfillmentWorkflow.commit(); +``` \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 021e089a..196061fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@mastra/auth-supabase": "^1.0.0-beta.1", "@mastra/client-js": "^1.0.0-beta.21", "@mastra/cloudflare-d1": "^1.0.0-beta.9", + "@mastra/convex": "^0.1.0-beta.8", "@mastra/core": "^1.0.0-beta.21", "@mastra/deployer": "^1.0.0-beta.21", "@mastra/evals": "^1.0.0-beta.4", @@ -6180,6 +6181,21 @@ "@mastra/core": ">=1.0.0-0 <2.0.0-0" } }, + "node_modules/@mastra/convex": { + "version": "0.1.0-beta.8", + "resolved": "https://registry.npmjs.org/@mastra/convex/-/convex-0.1.0-beta.8.tgz", + "integrity": "sha512-KZMRDpEnEA/dhEW4ak3sBDwzheckvevVI9grvWlxIcEIwRiCZVdpFX3SSVQttjFEp417yqEIblww6RIAOuAG5Q==", + "license": "Apache-2.0", + "dependencies": { + "convex": "^1.29.3" + }, + "engines": { + "node": ">=22.13.0" + }, + "peerDependencies": { + "@mastra/core": ">=1.0.0-0 <2.0.0-0" + } + }, "node_modules/@mastra/core": { "version": "1.0.0-beta.21", "resolved": "https://registry.npmjs.org/@mastra/core/-/core-1.0.0-beta.21.tgz", @@ -18948,6 +18964,496 @@ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "license": "MIT" }, + "node_modules/convex": { + "version": "1.31.4", + "resolved": "https://registry.npmjs.org/convex/-/convex-1.31.4.tgz", + "integrity": "sha512-iDm283Gb/CFRb30cvhH6Z9qlYof6dhtin415FarKUKB3K7gumO0rn8snY0CTvUrThV3UnCtttbuL/1oY7LscyA==", + "license": "Apache-2.0", + "dependencies": { + "esbuild": "0.27.0", + "prettier": "^3.0.0" + }, + "bin": { + "convex": "bin/main.js" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=7.0.0" + }, + "peerDependencies": { + "@auth0/auth0-react": "^2.0.1", + "@clerk/clerk-react": "^4.12.8 || ^5.0.0", + "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@auth0/auth0-react": { + "optional": true + }, + "@clerk/clerk-react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/convex/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", + "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/android-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", + "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/android-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", + "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/android-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", + "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", + "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/darwin-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", + "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", + "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", + "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/linux-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", + "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/linux-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", + "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/linux-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", + "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/linux-loong64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", + "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", + "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", + "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", + "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/linux-s390x": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", + "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/linux-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", + "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", + "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", + "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", + "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", + "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", + "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/sunos-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", + "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/win32-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", + "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/win32-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", + "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/win32-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", + "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/esbuild": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", + "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.0", + "@esbuild/android-arm": "0.27.0", + "@esbuild/android-arm64": "0.27.0", + "@esbuild/android-x64": "0.27.0", + "@esbuild/darwin-arm64": "0.27.0", + "@esbuild/darwin-x64": "0.27.0", + "@esbuild/freebsd-arm64": "0.27.0", + "@esbuild/freebsd-x64": "0.27.0", + "@esbuild/linux-arm": "0.27.0", + "@esbuild/linux-arm64": "0.27.0", + "@esbuild/linux-ia32": "0.27.0", + "@esbuild/linux-loong64": "0.27.0", + "@esbuild/linux-mips64el": "0.27.0", + "@esbuild/linux-ppc64": "0.27.0", + "@esbuild/linux-riscv64": "0.27.0", + "@esbuild/linux-s390x": "0.27.0", + "@esbuild/linux-x64": "0.27.0", + "@esbuild/netbsd-arm64": "0.27.0", + "@esbuild/netbsd-x64": "0.27.0", + "@esbuild/openbsd-arm64": "0.27.0", + "@esbuild/openbsd-x64": "0.27.0", + "@esbuild/openharmony-arm64": "0.27.0", + "@esbuild/sunos-x64": "0.27.0", + "@esbuild/win32-arm64": "0.27.0", + "@esbuild/win32-ia32": "0.27.0", + "@esbuild/win32-x64": "0.27.0" + } + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -29937,7 +30443,6 @@ "version": "3.7.4", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", - "dev": true, "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" diff --git a/package.json b/package.json index 6545b4e6..c436860e 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@mastra/auth-supabase": "^1.0.0-beta.1", "@mastra/client-js": "^1.0.0-beta.21", "@mastra/cloudflare-d1": "^1.0.0-beta.9", + "@mastra/convex": "^0.1.0-beta.8", "@mastra/core": "^1.0.0-beta.21", "@mastra/deployer": "^1.0.0-beta.21", "@mastra/evals": "^1.0.0-beta.4", @@ -256,4 +257,4 @@ "typescript-eslint": "^8.52.0", "@typescript-eslint/parser": "^8.52.0" } -} \ No newline at end of file +} diff --git a/src/mastra/agents/acpAgent.ts b/src/mastra/agents/acpAgent.ts index e178d094..dfc5c580 100644 --- a/src/mastra/agents/acpAgent.ts +++ b/src/mastra/agents/acpAgent.ts @@ -16,6 +16,7 @@ import type { GoogleGenerativeAIProviderOptions } from '@ai-sdk/google'; import type { RequestContext } from '@mastra/core/request-context'; import { PGVECTOR_PROMPT } from '@mastra/pg'; import { TokenLimiterProcessor } from '@mastra/core/processors'; +import { InternalSpans } from '@mastra/core/observability'; export interface ACPContext { userId?: string @@ -100,6 +101,9 @@ User: ${userId} | Role: ${roleConstraint} workflows: {}, scorers: { + }, + options: { + tracingPolicy: InternalSpans.ALL as any, }, defaultOptions: { autoResumeSuspendedTools: true, diff --git a/src/mastra/agents/researchAgent.ts b/src/mastra/agents/researchAgent.ts index 28c38efc..d7c9889b 100644 --- a/src/mastra/agents/researchAgent.ts +++ b/src/mastra/agents/researchAgent.ts @@ -9,27 +9,15 @@ import type { RequestContext } from '@mastra/core/request-context' import { google } from '../config/google' import { log } from '../config/logger' import { pgMemory } from '../config/pg-storage' -import { - alphaVantageCryptoTool, - alphaVantageStockTool, -} from '../tools/alpha-vantage.tool' import { mdocumentChunker } from '../tools/document-chunking.tool' import { evaluateResultTool } from '../tools/evaluateResultTool' import { extractLearningsTool } from '../tools/extractLearningsTool' import { - finnhubAnalysisTool, - finnhubCompanyTool, - finnhubFinancialsTool, finnhubQuotesTool, - finnhubTechnicalTool, } from '../tools/finnhub-tools' import { pdfToMarkdownTool } from '../tools/pdf-data-conversion.tool' import { - polygonCryptoAggregatesTool, - polygonCryptoQuotesTool, - polygonCryptoSnapshotsTool, - polygonStockAggregatesTool, - polygonStockFundamentalsTool, + polygonStockQuotesTool, } from '../tools/polygon-tools' import { @@ -41,12 +29,7 @@ import { googleTrendsTool, } from '../tools/serpapi-news-trends.tool' import { - batchWebScraperTool, - contentCleanerTool, htmlToMarkdownTool, - linkExtractorTool, - scrapingSchedulerTool, - siteMapExtractorTool, webScraperTool, } from '../tools/web-scraper-tool' diff --git a/src/mastra/config/pg-storage.ts b/src/mastra/config/pg-storage.ts index bc1624f1..5a6f1778 100644 --- a/src/mastra/config/pg-storage.ts +++ b/src/mastra/config/pg-storage.ts @@ -9,6 +9,7 @@ import { log } from './logger' import { ModelRouterEmbeddingModel } from "@mastra/core/llm"; import { SpanType } from '@mastra/core/observability' +import { google } from '@ai-sdk/google' // Utility function to create a masked stream for sensitive data @@ -199,6 +200,17 @@ export const pgQueryTool = createVectorQueryTool({ // Advanced filtering enableFilter: true, includeSources: true, + // reranker: { + // model: google.chat('gemini-3-flash-preview'), + // options: { + // weights: { + // semantic: 0.5, // Semantic relevance weight + // vector: 0.3, // Vector similarity weight + // position: 0.2, // Original position weight + // }, + // topK: 5, + // }, + // }, }) // Production-grade embedding generation with tracing diff --git a/src/mastra/tools/AGENTS.md b/src/mastra/tools/AGENTS.md index 0be8ad83..4b3241e6 100644 --- a/src/mastra/tools/AGENTS.md +++ b/src/mastra/tools/AGENTS.md @@ -78,24 +78,42 @@ I noticed that the file references '@mastra/core/observability' for SpanType kee ```ts import { SpanType } from '@mastra/core/observability' ``` - -However, the other correct import path should be '@mastra/observability' to align with the package structure - ```ts -import { BaseSpan } from '@mastra/observability'; +import type { TracingContext } from '@mastra/core/observability' ``` - -All tools should import `SpanType` from `@mastra/core/observability` and `BaseSpan` from `@mastra/observability`. This is so we can maintain consistency & compatibility across the codebase. +All tools should import `SpanType` from `@mastra/core/observability` and `TracingContext` from `@mastra/core/observability`. This is so we can maintain consistency & compatibility across the codebase. ### Required Imports > IMPORTANT: Tools MUST use Mastra's tracing helpers and the execution-provided `tracingContext`. Do NOT import or use OpenTelemetry APIs (for example, `@opentelemetry/api`) directly inside tools — instrumentation and exporter configuration belong at the platform / runtime startup layer, not inside individual tools. +> `TracingContext`: Context for tracing that flows through workflow and agent execution + +```ts +(alias) interface TracingContext { + currentSpan?: { + isInternal: boolean; + parent?: AnySpan | undefined; + observabilityInstance: ObservabilityInstance; + traceState?: TraceState | undefined; + end(options?: EndSpanOptions | undefined): void; + error(options: ErrorSpanOptions): void; + update(options: UpdateSpanOptions): void; + createChildSpan(options: ChildSpanOptions): AIModelGenerationSpan; + createChildSpan(options: ChildSpanOptions<...>): Span<...>; + ... 24 more ...; + isEvent: boolean; + } | undefined; +} +import TracingContext +Context for tracing that flows through workflow and agent execution +``` ```typescript import type { RequestContext } from '@mastra/core/request-context' import type { InferUITool } from '@mastra/core/tools' import { createTool } from '@mastra/core/tools' import { SpanType } from '@mastra/core/observability' +import type { TracingContext } from '@mastra/core/observability' import { z } from 'zod' import { log } from '../config/logger' ``` diff --git a/src/mastra/tools/arxiv.tool.ts b/src/mastra/tools/arxiv.tool.ts index ae8371f9..7c5f05c0 100644 --- a/src/mastra/tools/arxiv.tool.ts +++ b/src/mastra/tools/arxiv.tool.ts @@ -10,8 +10,8 @@ import type { RequestContext } from '@mastra/core/request-context' type UserTier = 'free' | 'pro' | 'enterprise' export interface ArxivRequestContext extends RequestContext { - 'user-tier': UserTier - userId?: string + 'user-tier': UserTier + userId?: string } // In-memory counter to track tool calls per request @@ -39,446 +39,468 @@ import { extractText, getDocumentProxy } from 'unpdf' * Extract text content from PDF buffer using `unpdf` */ async function extractPdfText( - pdfBuffer: Buffer, - maxPages = 1000 + pdfBuffer: Buffer, + maxPages = 1000 ): Promise<{ - text: string - numpages: number - metadata?: Record + text: string + numpages: number + metadata?: Record }> { - try { - const pdf = await getDocumentProxy(new Uint8Array(pdfBuffer)) - const { totalPages, text } = await extractText(pdf, { - mergePages: true, - }) - - return { - text: text ?? '', - numpages: Math.min(totalPages ?? 1, maxPages), - metadata: undefined, + try { + const pdf = await getDocumentProxy(new Uint8Array(pdfBuffer)) + const { totalPages, text } = await extractText(pdf, { + mergePages: true, + }) + + return { + text: text ?? '', + numpages: Math.min(totalPages ?? 1, maxPages), + metadata: undefined, + } + } catch (error) { + throw new Error( + `PDF parsing failed: ${error instanceof Error ? error.message : 'Unknown error'}` + ) } - } catch (error) { - throw new Error( - `PDF parsing failed: ${error instanceof Error ? error.message : 'Unknown error'}` - ) - } } /** * Convert extracted PDF text to markdown format */ function convertPdfTextToMarkdown( - text: string, - metadata?: { title?: string; authors?: string[] } + text: string, + metadata?: { title?: string; authors?: string[] } ): string { - let markdown = text - - // Basic text normalization - markdown = markdown - .replace(/\f/g, '\n\n') // Form feeds to double newlines - .replace(/\r\n/g, '\n') // Normalize line endings - .replace(/\n\n\n+/g, '\n\n') // Multiple newlines to double - .replace(/\t/g, ' ') // Tabs to spaces - .replace(/(\w+)-\n(\w+)/g, '$1$2') // Fix hyphenated words - .replace(/\s+([.,!?;:])/g, '$1') // Clean punctuation spacing - .trim() - - // Add frontmatter if metadata available - const hasTitle = - typeof metadata?.title === 'string' && metadata.title.trim() !== '' - const hasAuthors = - Array.isArray(metadata?.authors) && metadata.authors.length > 0 - - if (hasTitle || hasAuthors) { - const frontmatter = [ - '---', - hasTitle - ? `title: "${String(metadata?.title).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` - : null, - hasAuthors ? `authors: ${JSON.stringify(metadata.authors)}` : null, - 'source: "arxiv-pdf"', - 'extracted_at: "' + new Date().toISOString() + '"', - '---', - '', - ] - .filter(Boolean) - .join('\n') - - markdown = frontmatter + markdown - } - - return markdown + let markdown = text + + // Basic text normalization + markdown = markdown + .replace(/\f/g, '\n\n') // Form feeds to double newlines + .replace(/\r\n/g, '\n') // Normalize line endings + .replace(/\n\n\n+/g, '\n\n') // Multiple newlines to double + .replace(/\t/g, ' ') // Tabs to spaces + .replace(/(\w+)-\n(\w+)/g, '$1$2') // Fix hyphenated words + .replace(/\s+([.,!?;:])/g, '$1') // Clean punctuation spacing + .trim() + + // Add frontmatter if metadata available + const hasTitle = + typeof metadata?.title === 'string' && metadata.title.trim() !== '' + const hasAuthors = + Array.isArray(metadata?.authors) && metadata.authors.length > 0 + + if (hasTitle || hasAuthors) { + const frontmatter = [ + '---', + hasTitle + ? `title: "${String(metadata?.title).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` + : null, + hasAuthors ? `authors: ${JSON.stringify(metadata.authors)}` : null, + 'source: "arxiv-pdf"', + 'extracted_at: "' + new Date().toISOString() + '"', + '---', + '', + ] + .filter(Boolean) + .join('\n') + + markdown = frontmatter + markdown + } + + return markdown } export const arxivTool = createTool({ - id: 'arxiv', - description: - 'Search and retrieve academic papers from arXiv.org including metadata, abstracts, and download links', - inputSchema: z.object({ - query: z - .string() - .optional() - .describe('Search query for papers (keywords, titles, authors)'), - id: z - .string() - .optional() - .describe( - "Specific arXiv paper ID (e.g., '2103.12345' or '2103.12345v1')" - ), - author: z.string().optional().describe('Search by author name'), - title: z.string().optional().describe('Search by paper title'), - category: z - .enum([ - 'cs.AI', - 'cs.CL', - 'cs.CV', - 'cs.LG', - 'cs.NE', - 'cs.RO', - 'math.AG', - 'math.AP', - 'math.CO', - 'math.CT', - 'math.DS', - 'physics.optics', - 'physics.comp-ph', - 'physics.gen-ph', - 'q-bio.BM', - 'q-bio.GN', - 'q-bio.MN', - 'q-bio.NC', - 'q-bio.OT', - 'q-bio.PE', - 'q-bio.QM', - 'stat.AP', - 'stat.CO', - 'stat.ME', - 'stat.ML', - 'stat.TH', - 'astro-ph.CO', - 'astro-ph.EP', - 'astro-ph.GA', - 'astro-ph.HE', - 'astro-ph.IM', - 'astro-ph.SR', - 'cond-mat.dis-nn', - 'cond-mat.mes-hall', - 'cond-mat.mtrl-sci', - 'cond-mat.other', - 'cond-mat.quant-gas', - 'cond-mat.soft', - 'cond-mat.stat-mech', - 'cond-mat.str-el', - 'cond-mat.supr-con', - 'hep-ex', - 'hep-lat', - 'hep-ph', - 'hep-th', - 'nucl-ex', - 'nucl-th', - 'quant-ph', - ]) - .optional() - .describe('ArXiv category/subject area'), - max_results: z - .number() - .min(1) - .max(100) - .optional() - .default(10) - .describe('Maximum number of results to return (1-100)'), - sort_by: z - .enum(['relevance', 'lastUpdatedDate', 'submittedDate']) - .optional() - .default('relevance') - .describe('Sort results by criteria'), - sort_order: z - .enum(['ascending', 'descending']) - .optional() - .default('descending') - .describe('Sort order'), - start: z - .number() - .min(0) - .optional() - .default(0) - .describe('Starting index for results (0-based)'), - include_abstract: z - .boolean() - .optional() - .default(true) - .describe('Whether to include paper abstracts in results'), - }), - outputSchema: z.object({ - papers: z.array( - z.object({ - id: z.string(), - title: z.string(), - authors: z.array(z.string()), - abstract: z.string().optional(), - categories: z.array(z.string()), - published: z.string(), - updated: z.string().optional(), - pdf_url: z.string(), - doi: z.string().optional(), - journal_ref: z.string().optional(), - comments: z.string().optional(), - }) - ), - total_results: z.number(), - start_index: z.number(), - max_results: z.number(), - }), - execute: async (inputData, context) => { - const abortSignal = context?.abortSignal - const writer = context?.writer - const requestCtx = - context?.requestContext as ArxivRequestContext | undefined - - // Check if operation was already cancelled - if (abortSignal?.aborted === true) { - throw new Error('ArXiv search cancelled') - } + id: 'arxiv', + description: + 'Search and retrieve academic papers from arXiv.org including metadata, abstracts, and download links', + inputSchema: z.object({ + query: z + .string() + .optional() + .describe('Search query for papers (keywords, titles, authors)'), + id: z + .string() + .optional() + .describe( + "Specific arXiv paper ID (e.g., '2103.12345' or '2103.12345v1')" + ), + author: z.string().optional().describe('Search by author name'), + title: z.string().optional().describe('Search by paper title'), + category: z + .enum([ + 'cs.AI', + 'cs.CL', + 'cs.CV', + 'cs.LG', + 'cs.NE', + 'cs.RO', + 'math.AG', + 'math.AP', + 'math.CO', + 'math.CT', + 'math.DS', + 'physics.optics', + 'physics.comp-ph', + 'physics.gen-ph', + 'q-bio.BM', + 'q-bio.GN', + 'q-bio.MN', + 'q-bio.NC', + 'q-bio.OT', + 'q-bio.PE', + 'q-bio.QM', + 'stat.AP', + 'stat.CO', + 'stat.ME', + 'stat.ML', + 'stat.TH', + 'astro-ph.CO', + 'astro-ph.EP', + 'astro-ph.GA', + 'astro-ph.HE', + 'astro-ph.IM', + 'astro-ph.SR', + 'cond-mat.dis-nn', + 'cond-mat.mes-hall', + 'cond-mat.mtrl-sci', + 'cond-mat.other', + 'cond-mat.quant-gas', + 'cond-mat.soft', + 'cond-mat.stat-mech', + 'cond-mat.str-el', + 'cond-mat.supr-con', + 'hep-ex', + 'hep-lat', + 'hep-ph', + 'hep-th', + 'nucl-ex', + 'nucl-th', + 'quant-ph', + ]) + .optional() + .describe('ArXiv category/subject area'), + max_results: z + .number() + .min(1) + .max(100) + .optional() + .default(10) + .describe('Maximum number of results to return (1-100)'), + sort_by: z + .enum(['relevance', 'lastUpdatedDate', 'submittedDate']) + .optional() + .default('relevance') + .describe('Sort results by criteria'), + sort_order: z + .enum(['ascending', 'descending']) + .optional() + .default('descending') + .describe('Sort order'), + start: z + .number() + .min(0) + .optional() + .default(0) + .describe('Starting index for results (0-based)'), + include_abstract: z + .boolean() + .optional() + .default(true) + .describe('Whether to include paper abstracts in results'), + }), + outputSchema: z.object({ + papers: z.array( + z.object({ + id: z.string(), + title: z.string(), + authors: z.array(z.string()), + abstract: z.string().optional(), + categories: z.array(z.string()), + published: z.string(), + updated: z.string().optional(), + pdf_url: z.string(), + doi: z.string().optional(), + journal_ref: z.string().optional(), + comments: z.string().optional(), + }) + ), + total_results: z.number(), + start_index: z.number(), + max_results: z.number(), + }), + execute: async (inputData, context) => { + const abortSignal = context?.abortSignal + const writer = context?.writer + const requestCtx = context?.requestContext as + | ArxivRequestContext + | undefined + + // Check if operation was already cancelled + if (abortSignal?.aborted === true) { + throw new Error('ArXiv search cancelled') + } - if (requestCtx) { - log.debug('ArXiv search request context available', { - tool: 'arxiv', - }) - } + if (requestCtx) { + log.debug('ArXiv search request context available', { + tool: 'arxiv', + }) + } - const tracingContext: TracingContext | undefined = context?.tracingContext - const span = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'arxiv-search', - input: inputData, - requestContext: context?.requestContext, - metadata: { - 'tool.id': 'arxiv', - 'tool.input.query': inputData.query, - 'tool.input.id': inputData.id, - 'tool.input.category': inputData.category, - 'tool.input.maxResults': inputData.max_results, - }, - }) - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `📚 Searching arXiv for "${inputData.query ?? inputData.id ?? 'papers'}"`, - stage: 'arxiv', - }, - id: 'arxiv', - }) - toolCallCounters.set('arxiv', (toolCallCounters.get('arxiv') ?? 0) + 1) - try { - const params = new URLSearchParams() + const tracingContext: TracingContext | undefined = + context?.tracingContext + const span = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'arxiv-search', + input: inputData, + requestContext: context?.requestContext, + metadata: { + 'tool.id': 'arxiv', + 'tool.input.query': inputData.query, + 'tool.input.id': inputData.id, + 'tool.input.category': inputData.category, + 'tool.input.maxResults': inputData.max_results, + }, + }) + + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `📚 Searching arXiv for "${inputData.query ?? inputData.id ?? 'papers'}"`, + stage: 'arxiv', + }, + id: 'arxiv', + }) + toolCallCounters.set('arxiv', (toolCallCounters.get('arxiv') ?? 0) + 1) + try { + const params = new URLSearchParams() - // Build search query - const searchTerms: string[] = [] + // Build search query + const searchTerms: string[] = [] - if (inputData.query !== undefined && inputData.query !== null) { - searchTerms.push(inputData.query) - } + if (inputData.query !== undefined && inputData.query !== null) { + searchTerms.push(inputData.query) + } - if (inputData.author !== undefined && inputData.author !== null) { - searchTerms.push(`au:${inputData.author}`) - } + if (inputData.author !== undefined && inputData.author !== null) { + searchTerms.push(`au:${inputData.author}`) + } - if (inputData.title !== undefined && inputData.title !== null) { - searchTerms.push(`ti:${inputData.title}`) - } + if (inputData.title !== undefined && inputData.title !== null) { + searchTerms.push(`ti:${inputData.title}`) + } - if (inputData.category !== undefined) { - searchTerms.push(`cat:${inputData.category}`) - } + if (inputData.category !== undefined) { + searchTerms.push(`cat:${inputData.category}`) + } - if (inputData.id !== undefined && inputData.id !== null) { - // If specific ID is provided, use it directly - params.append('id_list', inputData.id) - } else if (searchTerms.length > 0) { - params.append('search_query', searchTerms.join(' AND ')) - } else { - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: '❌ No search terms provided', - stage: 'arxiv', - }, - id: 'arxiv', - }) - throw new Error( - 'Either query, id, author, title, or category must be provided' - ) - } - - // Add other parameters - params.append('max_results', String(inputData.max_results ?? 10)) - params.append('start', String(inputData.start ?? 0)) - - if (inputData.sort_by !== undefined) { - params.append( - 'sortBy', - inputData.sort_by === 'lastUpdatedDate' - ? 'lastUpdatedDate' - : inputData.sort_by === 'submittedDate' - ? 'submittedDate' - : 'relevance' - ) - } - - if (inputData.sort_order !== undefined) { - params.append('sortOrder', inputData.sort_order) - } - - const url = `http://export.arxiv.org/api/query?${params.toString()}` - - // Check for cancellation before API call - if (abortSignal?.aborted ?? false) { - // Record cancellation and end span - span?.error({ error: new Error('Operation cancelled during API call'), endSpan: true }) - throw new Error('ArXiv search cancelled during API call') - } - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: '📡 Fetching from arXiv API...', - stage: 'arxiv', - }, - id: 'arxiv', - }) - let resp: any - try { - resp = await httpFetch(url, { method: 'GET', timeout: 30000, responseType: 'text', signal: abortSignal }) - // resp already has status/statusText/data from httpFetch - } catch (err: any) { - if (err?.code === 'ERR_CANCELED' || (err instanceof Error && err.name === 'AbortError')) { - throw new Error('ArXiv search cancelled during API call') - } - throw err - } - - if (typeof resp.status !== 'number' || resp.status < 200 || resp.status >= 300) { - throw new Error(`ArXiv API error: ${resp.status} ${resp.statusText ?? ''}`) - } - - const xmlText = resp.data as string - - // Parse XML response (simplified parsing - in production you might want a proper XML parser) - const papers = parseArxivXml( - xmlText, - Boolean(inputData.include_abstract) - ) - - // Extract total results from XML - const totalResultsRegex = - /(\d+)<\/opensearch:totalResults>/ - const totalResultsMatch = totalResultsRegex.exec(xmlText) - const totalResults = totalResultsMatch - ? parseInt(totalResultsMatch[1], 10) - : papers.length - - const startIndexRegex = - /(\d+)<\/opensearch:startIndex>/ - const startIndexMatch = startIndexRegex.exec(xmlText) - const startIndex = startIndexMatch - ? parseInt(startIndexMatch[1], 10) - : (inputData.start ?? 0) - - const result = { - papers, - total_results: totalResults, - start_index: startIndex, - max_results: inputData.max_results ?? 10, - } - // Update span with successful result and end - span?.update({ - metadata: { - 'tool.output.totalResults': totalResults, - 'tool.output.paperCount': papers.length, - }, - }) - span?.end() - return result - } catch (error) { - // Handle AbortError specifically - if (error instanceof Error && error.name === 'AbortError') { - const cancelMessage = `ArXiv search cancelled for "${inputData.query ?? inputData.id ?? 'papers'}"` - // Record cancellation on span and end it - span?.error({ error: new Error(cancelMessage), endSpan: true }) + if (inputData.id !== undefined && inputData.id !== null) { + // If specific ID is provided, use it directly + params.append('id_list', inputData.id) + } else if (searchTerms.length > 0) { + params.append('search_query', searchTerms.join(' AND ')) + } else { + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: '❌ No search terms provided', + stage: 'arxiv', + }, + id: 'arxiv', + }) + throw new Error( + 'Either query, id, author, title, or category must be provided' + ) + } - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: `🛑 ${cancelMessage}`, - stage: 'arxiv', - }, - id: 'arxiv', - }) + // Add other parameters + params.append('max_results', String(inputData.max_results ?? 10)) + params.append('start', String(inputData.start ?? 0)) + + if (inputData.sort_by !== undefined) { + params.append( + 'sortBy', + inputData.sort_by === 'lastUpdatedDate' + ? 'lastUpdatedDate' + : inputData.sort_by === 'submittedDate' + ? 'submittedDate' + : 'relevance' + ) + } - log.warn(cancelMessage) - throw new Error(cancelMessage) - } - - const errorMessage = - error instanceof Error - ? error.message - : 'Unknown error occurred' - // Record error on span and end it - span?.error({ error: error instanceof Error ? error : new Error(errorMessage), endSpan: true }) - throw error instanceof Error ? error : new Error(errorMessage) - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('ArXiv tool input streaming started', { - toolCallId, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputStart', - }) - }, - onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { - log.info('ArXiv tool received input chunk', { - toolCallId, - inputTextDelta, - abortSignal: abortSignal?.aborted, - messageCount: messages.length, - hook: 'onInputDelta', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - const searchType = input.id - ? 'by-id' - : input.query - ? 'by-query' - : 'general' - log.info('ArXiv tool received input', { - toolCallId, - messageCount: messages.length, - searchType, - query: input.query, - id: input.id, - author: input.author, - category: input.category, - maxResults: input.max_results, - abortSignal: abortSignal?.aborted, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('ArXiv search completed', { - toolCallId, - toolName, - abortSignal: abortSignal?.aborted, - papersFound: output.papers.length, - totalResults: output.total_results, - hook: 'onOutput', - }) - }, + if (inputData.sort_order !== undefined) { + params.append('sortOrder', inputData.sort_order) + } + + const url = `http://export.arxiv.org/api/query?${params.toString()}` + + // Check for cancellation before API call + if (abortSignal?.aborted ?? false) { + // Record cancellation and end span + span?.error({ + error: new Error('Operation cancelled during API call'), + endSpan: true, + }) + throw new Error('ArXiv search cancelled during API call') + } + + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: '📡 Fetching from arXiv API...', + stage: 'arxiv', + }, + id: 'arxiv', + }) + let resp: any + try { + resp = await httpFetch(url, { + method: 'GET', + timeout: 30000, + responseType: 'text', + signal: abortSignal, + }) + // resp already has status/statusText/data from httpFetch + } catch (err: any) { + if ( + err?.code === 'ERR_CANCELED' || + (err instanceof Error && err.name === 'AbortError') + ) { + throw new Error('ArXiv search cancelled during API call') + } + throw err + } + + if ( + typeof resp.status !== 'number' || + resp.status < 200 || + resp.status >= 300 + ) { + throw new Error( + `ArXiv API error: ${resp.status} ${resp.statusText ?? ''}` + ) + } + + const xmlText = resp.data as string + + // Parse XML response (simplified parsing - in production you might want a proper XML parser) + const papers = parseArxivXml( + xmlText, + Boolean(inputData.include_abstract) + ) + + // Extract total results from XML + const totalResultsRegex = + /(\d+)<\/opensearch:totalResults>/ + const totalResultsMatch = totalResultsRegex.exec(xmlText) + const totalResults = totalResultsMatch + ? parseInt(totalResultsMatch[1], 10) + : papers.length + + const startIndexRegex = + /(\d+)<\/opensearch:startIndex>/ + const startIndexMatch = startIndexRegex.exec(xmlText) + const startIndex = startIndexMatch + ? parseInt(startIndexMatch[1], 10) + : (inputData.start ?? 0) + + const result = { + papers, + total_results: totalResults, + start_index: startIndex, + max_results: inputData.max_results ?? 10, + } + // Update span with successful result and end + span?.update({ + metadata: { + 'tool.output.totalResults': totalResults, + 'tool.output.paperCount': papers.length, + }, + }) + span?.end() + return result + } catch (error) { + // Handle AbortError specifically + if (error instanceof Error && error.name === 'AbortError') { + const cancelMessage = `ArXiv search cancelled for "${inputData.query ?? inputData.id ?? 'papers'}"` + // Record cancellation on span and end it + span?.error({ error: new Error(cancelMessage), endSpan: true }) + + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `🛑 ${cancelMessage}`, + stage: 'arxiv', + }, + id: 'arxiv', + }) + + log.warn(cancelMessage) + throw new Error(cancelMessage) + } + + const errorMessage = + error instanceof Error + ? error.message + : 'Unknown error occurred' + // Record error on span and end it + span?.error({ + error: error instanceof Error ? error : new Error(errorMessage), + endSpan: true, + }) + throw error instanceof Error ? error : new Error(errorMessage) + } + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('ArXiv tool input streaming started', { + toolCallId, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputStart', + }) + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('ArXiv tool received input chunk', { + toolCallId, + inputTextDelta, + abortSignal: abortSignal?.aborted, + messageCount: messages.length, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + const searchType = input.id + ? 'by-id' + : input.query + ? 'by-query' + : 'general' + log.info('ArXiv tool received input', { + toolCallId, + messageCount: messages.length, + searchType, + query: input.query, + id: input.id, + author: input.author, + category: input.category, + maxResults: input.max_results, + abortSignal: abortSignal?.aborted, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('ArXiv search completed', { + toolCallId, + toolName, + abortSignal: abortSignal?.aborted, + papersFound: output.papers.length, + totalResults: output.total_results, + hook: 'onOutput', + }) + }, }) export type ArxivUITool = InferUITool @@ -491,375 +513,427 @@ export type ArxivUITool = InferUITool */ export const arxivPdfParserTool = createTool({ - id: 'arxiv-pdf-parser', - description: - 'Download and parse arXiv PDF papers to markdown format using unpdf', - inputSchema: z.object({ - arxivId: z - .string() - .describe("ArXiv paper ID (e.g., '2103.12345' or '2103.12345v1')"), - maxPages: z - .number() - .min(1) - .max(1000) - .optional() - .default(100) - .describe('Maximum number of pages to parse (default: 100)'), - includeMetadata: z - .boolean() - .optional() - .default(true) - .describe('Include paper metadata in markdown frontmatter'), - normalizeText: z - .boolean() - .optional() - .default(true) - .describe('Apply text normalization and cleanup'), - }), - outputSchema: z.object({ - success: z.boolean(), - arxivId: z.string(), - markdown: z.string(), - metadata: z - .object({ - title: z.string().optional(), - authors: z.array(z.string()).optional(), - pageCount: z.number(), - extractedAt: z.string(), - }) - .optional(), - statistics: z.object({ - pageCount: z.number(), - textLength: z.number(), - processingTimeMs: z.number(), + id: 'arxiv-pdf-parser', + description: + 'Download and parse arXiv PDF papers to markdown format using unpdf', + inputSchema: z.object({ + arxivId: z + .string() + .describe("ArXiv paper ID (e.g., '2103.12345' or '2103.12345v1')"), + maxPages: z + .number() + .min(1) + .max(1000) + .optional() + .default(100) + .describe('Maximum number of pages to parse (default: 100)'), + includeMetadata: z + .boolean() + .optional() + .default(true) + .describe('Include paper metadata in markdown frontmatter'), + normalizeText: z + .boolean() + .optional() + .default(true) + .describe('Apply text normalization and cleanup'), }), - }), - execute: async (inputData, context) => { - const writer = context?.writer - const requestCtx = - context?.requestContext as ArxivRequestContext | undefined - const tracingContext: TracingContext | undefined = context?.tracingContext - const span = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'arxiv-pdf-parser', - input: inputData, - metadata: { - 'tool.id': 'arxiv-pdf-parser', - 'tool.input.arxivId': inputData.arxivId, - 'tool.input.maxPages': inputData.maxPages, - 'user.id': requestCtx?.userId, - }, - }) - const abortSignal = context?.abortSignal - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: - '🚀 Starting arXiv PDF parser for ' + inputData.arxivId, - stage: 'arxiv-pdf-parser', - }, - id: 'arxiv-pdf-parser', - }) - toolCallCounters.set( - 'arxiv-pdf-parser', - (toolCallCounters.get('arxiv-pdf-parser') ?? 0) + 1 - ) - const startTime = Date.now() - - try { - if (requestCtx) { - log.debug('Arxiv PDF parser request context available', { - tool: 'arxiv-pdf-parser', + outputSchema: z.object({ + success: z.boolean(), + arxivId: z.string(), + markdown: z.string(), + metadata: z + .object({ + title: z.string().optional(), + authors: z.array(z.string()).optional(), + pageCount: z.number(), + extractedAt: z.string(), + }) + .optional(), + statistics: z.object({ + pageCount: z.number(), + textLength: z.number(), + processingTimeMs: z.number(), + }), + }), + execute: async (inputData, context) => { + const writer = context?.writer + const requestCtx = context?.requestContext as + | ArxivRequestContext + | undefined + const tracingContext: TracingContext | undefined = + context?.tracingContext + const span = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'arxiv-pdf-parser', + input: inputData, + requestContext: context?.requestContext, + metadata: { + 'tool.id': 'arxiv-pdf-parser', + 'tool.input.arxivId': inputData.arxivId, + 'tool.input.maxPages': inputData.maxPages, + 'user.id': requestCtx?.userId, + }, }) - } - - // Construct PDF URL - const pdfUrl = `https://arxiv.org/pdf/${inputData.arxivId}` + const abortSignal = context?.abortSignal - // Abort early if requested - if (abortSignal?.aborted ?? false) { - const cancelMessage = `ArXiv PDF parsing cancelled for ${inputData.arxivId}` - span?.error({ error: new Error(cancelMessage), endSpan: true }) await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: `🛑 ${cancelMessage}`, - stage: 'arxiv-pdf-parser', - }, - id: 'arxiv-pdf-parser', - }) - log.warn(cancelMessage) - throw new Error(cancelMessage) - } - - // Download PDF - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: '🔄 Extracting text from PDF...', - stage: 'arxiv-pdf-parser', - }, - id: 'arxiv-pdf-parser', - }) - - let pdfResp: any - try { - pdfResp = await httpFetch(pdfUrl, { method: 'GET', timeout: 300000, responseType: 'arraybuffer', signal: abortSignal }) - } catch (err: any) { - if (err?.code === 'ERR_CANCELED' || (err instanceof Error && err.name === 'AbortError')) { - const cancelMessage = `ArXiv PDF parsing cancelled for ${inputData.arxivId}` - await writer?.custom({ type: 'data-tool-progress', - data: { status: 'done', message: `🛑 ${cancelMessage}`, stage: 'arxiv-pdf-parser' }, + data: { + status: 'in-progress', + message: + '🚀 Starting arXiv PDF parser for ' + inputData.arxivId, + stage: 'arxiv-pdf-parser', + }, id: 'arxiv-pdf-parser', - }) - log.warn(cancelMessage) - throw new Error(cancelMessage) - } - throw err - } - - if (typeof pdfResp.status !== 'number' || pdfResp.status < 200 || pdfResp.status >= 300) { - await writer?.custom({ - type: 'data-tool-progress', - data: { status: 'done', message: '❌ PDF download failed', stage: 'arxiv-pdf-parser' }, - id: 'arxiv-pdf-parser', }) - if (pdfResp.status === 404) { - throw new Error(`Paper not found: ${inputData.arxivId}`) - } - throw new Error(`Failed to download PDF: ${pdfResp.status} ${pdfResp.statusText ?? ''}`) - } - - const pdfBuffer = Buffer.from(pdfResp.data) - - // Extract text from PDF - const pdfContent = await extractPdfText( - pdfBuffer, - inputData.maxPages ?? 100 - ) - - // Try to get metadata from arXiv API - let paperMetadata: - | { title?: string; authors?: string[] } - | undefined - if (inputData.includeMetadata) { + toolCallCounters.set( + 'arxiv-pdf-parser', + (toolCallCounters.get('arxiv-pdf-parser') ?? 0) + 1 + ) + const startTime = Date.now() + try { - const apiUrl = `http://export.arxiv.org/api/query?id_list=${inputData.arxivId}&max_results=1` - let apiResp: any - try { - apiResp = await httpFetch(apiUrl, { method: 'GET', responseType: 'text', signal: abortSignal, timeout: 30000 }) - } catch (err: unknown) { - if (err && typeof err === 'object' && (('code' in err && (err as any).code === 'ERR_CANCELED') || (err instanceof Error && err.name === 'AbortError'))) { - // metadata fetch aborted - continue without it - apiResp = undefined - } else { - throw err + if (requestCtx) { + log.debug('Arxiv PDF parser request context available', { + tool: 'arxiv-pdf-parser', + }) } - } - if (apiResp && typeof apiResp.status === 'number' && apiResp.status >= 200 && apiResp.status < 300) { - const xmlText = apiResp.data as string - const titleRegex = /(.*?)<\/title>/ - const titleMatch = titleRegex.exec(xmlText) - const title = titleMatch - ? titleMatch[1] - .replace(/<!\[CDATA\[|\]\]>/g, '') - .trim() - : undefined - - const authors: string[] = [] - const authorRegex = - /<author><name>(.*?)<\/name><\/author>/g - let authorMatch - while ( - (authorMatch = authorRegex.exec(xmlText)) !== null - ) { - authors.push(authorMatch[1]) + // Construct PDF URL + const pdfUrl = `https://arxiv.org/pdf/${inputData.arxivId}` + + // Abort early if requested + if (abortSignal?.aborted ?? false) { + const cancelMessage = `ArXiv PDF parsing cancelled for ${inputData.arxivId}` + span?.error({ error: new Error(cancelMessage), endSpan: true }) + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `🛑 ${cancelMessage}`, + stage: 'arxiv-pdf-parser', + }, + id: 'arxiv-pdf-parser', + }) + log.warn(cancelMessage) + throw new Error(cancelMessage) + } + + // Download PDF + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: '🔄 Extracting text from PDF...', + stage: 'arxiv-pdf-parser', + }, + id: 'arxiv-pdf-parser', + }) + + let pdfResp: any + try { + pdfResp = await httpFetch(pdfUrl, { + method: 'GET', + timeout: 300000, + responseType: 'arraybuffer', + signal: abortSignal, + }) + } catch (err: any) { + if ( + err?.code === 'ERR_CANCELED' || + (err instanceof Error && err.name === 'AbortError') + ) { + const cancelMessage = `ArXiv PDF parsing cancelled for ${inputData.arxivId}` + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `🛑 ${cancelMessage}`, + stage: 'arxiv-pdf-parser', + }, + id: 'arxiv-pdf-parser', + }) + log.warn(cancelMessage) + throw new Error(cancelMessage) + } + throw err } if ( - (title !== undefined && - title !== null && - title.length > 0) || - authors.length > 0 + typeof pdfResp.status !== 'number' || + pdfResp.status < 200 || + pdfResp.status >= 300 ) { - paperMetadata = { - title, - authors: - authors.length > 0 ? authors : undefined, - } + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: '❌ PDF download failed', + stage: 'arxiv-pdf-parser', + }, + id: 'arxiv-pdf-parser', + }) + if (pdfResp.status === 404) { + throw new Error(`Paper not found: ${inputData.arxivId}`) + } + throw new Error( + `Failed to download PDF: ${pdfResp.status} ${pdfResp.statusText ?? ''}` + ) } - } - } catch { - // Metadata fetch failed, continue without it - } - } - // Convert to markdown - let markdown = pdfContent.text + const pdfBuffer = Buffer.from(pdfResp.data) - if (inputData.normalizeText) { - markdown = convertPdfTextToMarkdown(markdown, paperMetadata) - } else if (paperMetadata && inputData.includeMetadata) { - // Add basic frontmatter even without normalization - const frontmatter = [ - '---', - typeof paperMetadata.title === 'string' && - paperMetadata.title.trim() !== '' - ? `title: "${paperMetadata.title.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` - : null, - paperMetadata.authors - ? `authors: ${JSON.stringify(paperMetadata.authors)}` - : null, - 'source: "arxiv-pdf"', - 'extracted_at: "' + new Date().toISOString() + '"', - '---', - '', - ] - .filter(Boolean) - .join('\n') + // Extract text from PDF + const pdfContent = await extractPdfText( + pdfBuffer, + inputData.maxPages ?? 100 + ) - markdown = frontmatter + markdown - } - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: - '✅ PDF parsing complete: ' + - pdfContent.numpages + - ' pages', - stage: 'arxiv-pdf-parser', - }, - id: 'arxiv-pdf-parser', - }) - const processingTime = Date.now() - startTime - - const result = { - success: true, - arxivId: inputData.arxivId, - markdown, - metadata: inputData.includeMetadata - ? { - title: paperMetadata?.title, - authors: paperMetadata?.authors, - pageCount: pdfContent.numpages, - extractedAt: new Date().toISOString(), - } - : undefined, - statistics: { - pageCount: pdfContent.numpages, - textLength: markdown.length, - processingTimeMs: processingTime, - }, - } - - if (span) { - // Prefer using setAttribute if available; otherwise safely populate the attributes bag - const spanAny = span as any - if (typeof spanAny.setAttribute === 'function') { - spanAny.setAttribute('tool.output.success', true) - spanAny.setAttribute('tool.output.pageCount', pdfContent.numpages) - } else { - spanAny.attributes = spanAny.attributes ?? {} - spanAny.attributes['tool.output.success'] = true - spanAny.attributes['tool.output.pageCount'] = pdfContent.numpages - } - if (typeof spanAny.end === 'function') { - spanAny.end() - } - } - return result - } catch (error) { - // Handle AbortError specifically - if (error instanceof Error && error.name === 'AbortError') { - const cancelMessage = `ArXiv PDF parsing cancelled for ${inputData.arxivId}` - if (span) { - if (typeof (span as any).setStatus === 'function') { - ; (span as any).setStatus({ code: 2, message: cancelMessage }) - } - if (typeof (span as any).end === 'function') { - ; (span as any).end() - } - } + // Try to get metadata from arXiv API + let paperMetadata: + | { title?: string; authors?: string[] } + | undefined + if (inputData.includeMetadata) { + try { + const apiUrl = `http://export.arxiv.org/api/query?id_list=${inputData.arxivId}&max_results=1` + let apiResp: any + try { + apiResp = await httpFetch(apiUrl, { + method: 'GET', + responseType: 'text', + signal: abortSignal, + timeout: 30000, + }) + } catch (err: unknown) { + if ( + err && + typeof err === 'object' && + (('code' in err && + (err as any).code === 'ERR_CANCELED') || + (err instanceof Error && + err.name === 'AbortError')) + ) { + // metadata fetch aborted - continue without it + apiResp = undefined + } else { + throw err + } + } + + if ( + apiResp && + typeof apiResp.status === 'number' && + apiResp.status >= 200 && + apiResp.status < 300 + ) { + const xmlText = apiResp.data as string + const titleRegex = /<title>(.*?)<\/title>/ + const titleMatch = titleRegex.exec(xmlText) + const title = titleMatch + ? titleMatch[1] + .replace(/<!\[CDATA\[|\]\]>/g, '') + .trim() + : undefined + + const authors: string[] = [] + const authorRegex = + /<author><name>(.*?)<\/name><\/author>/g + let authorMatch + while ( + (authorMatch = authorRegex.exec(xmlText)) !== null + ) { + authors.push(authorMatch[1]) + } + + if ( + (title !== undefined && + title !== null && + title.length > 0) || + authors.length > 0 + ) { + paperMetadata = { + title, + authors: + authors.length > 0 ? authors : undefined, + } + } + } + } catch { + // Metadata fetch failed, continue without it + } + } - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: `🛑 ${cancelMessage}`, - stage: 'arxiv-pdf-parser', - }, - id: 'arxiv-pdf-parser', - }) + // Convert to markdown + let markdown = pdfContent.text + + if (inputData.normalizeText) { + markdown = convertPdfTextToMarkdown(markdown, paperMetadata) + } else if (paperMetadata && inputData.includeMetadata) { + // Add basic frontmatter even without normalization + const frontmatter = [ + '---', + typeof paperMetadata.title === 'string' && + paperMetadata.title.trim() !== '' + ? `title: "${paperMetadata.title.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` + : null, + paperMetadata.authors + ? `authors: ${JSON.stringify(paperMetadata.authors)}` + : null, + 'source: "arxiv-pdf"', + 'extracted_at: "' + new Date().toISOString() + '"', + '---', + '', + ] + .filter(Boolean) + .join('\n') + + markdown = frontmatter + markdown + } - log.warn(cancelMessage) - throw new Error(cancelMessage) - } - - const errorMessage = - error instanceof Error - ? error.message - : 'Unknown error occurred' - if (span) { - if (error instanceof Error && typeof (span as any).recordException === 'function') { - ; (span as any).recordException(error) - } - if (typeof (span as any).setStatus === 'function') { - ; (span as any).setStatus({ code: 2, message: errorMessage }) - } - if (typeof (span as any).end === 'function') { - ; (span as any).end() - } - } + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: + '✅ PDF parsing complete: ' + + pdfContent.numpages + + ' pages', + stage: 'arxiv-pdf-parser', + }, + id: 'arxiv-pdf-parser', + }) + const processingTime = Date.now() - startTime + + const result = { + success: true, + arxivId: inputData.arxivId, + markdown, + metadata: inputData.includeMetadata + ? { + title: paperMetadata?.title, + authors: paperMetadata?.authors, + pageCount: pdfContent.numpages, + extractedAt: new Date().toISOString(), + } + : undefined, + statistics: { + pageCount: pdfContent.numpages, + textLength: markdown.length, + processingTimeMs: processingTime, + }, + } - throw error instanceof Error ? error : new Error(errorMessage) - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('ArXiv PDF parser input streaming started', { - toolCallId, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputStart', - }) - }, - onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { - log.info('ArXiv PDF parser received input chunk', { - toolCallId, - inputTextDelta, - abortSignal: abortSignal?.aborted, - messageCount: messages.length, - hook: 'onInputDelta', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('ArXiv PDF parser received input', { - toolCallId, - messageCount: messages.length, - arxivId: input.arxivId, - maxPages: input.maxPages, - abortSignal: abortSignal?.aborted, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('ArXiv PDF parsing completed', { - toolCallId, - toolName, - arxivId: output.arxivId, - success: output.success, - pageCount: output.statistics.pageCount, - abortSignal: abortSignal?.aborted, - hook: 'onOutput', - }) - }, + if (span) { + // Prefer using setAttribute if available; otherwise safely populate the attributes bag + const spanAny = span as any + if (typeof spanAny.setAttribute === 'function') { + spanAny.setAttribute('tool.output.success', true) + spanAny.setAttribute( + 'tool.output.pageCount', + pdfContent.numpages + ) + } else { + spanAny.attributes = spanAny.attributes ?? {} + spanAny.attributes['tool.output.success'] = true + spanAny.attributes['tool.output.pageCount'] = + pdfContent.numpages + } + if (typeof spanAny.end === 'function') { + spanAny.end() + } + } + return result + } catch (error) { + // Handle AbortError specifically + if (error instanceof Error && error.name === 'AbortError') { + const cancelMessage = `ArXiv PDF parsing cancelled for ${inputData.arxivId}` + if (span) { + if (typeof (span as any).setStatus === 'function') { + ;(span as any).setStatus({ + code: 2, + message: cancelMessage, + }) + } + if (typeof (span as any).end === 'function') { + ;(span as any).end() + } + } + + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `🛑 ${cancelMessage}`, + stage: 'arxiv-pdf-parser', + }, + id: 'arxiv-pdf-parser', + }) + + log.warn(cancelMessage) + throw new Error(cancelMessage) + } + + const errorMessage = + error instanceof Error + ? error.message + : 'Unknown error occurred' + if (span) { + if ( + error instanceof Error && + typeof (span as any).recordException === 'function' + ) { + ;(span as any).recordException(error) + } + if (typeof (span as any).setStatus === 'function') { + ;(span as any).setStatus({ code: 2, message: errorMessage }) + } + if (typeof (span as any).end === 'function') { + ;(span as any).end() + } + } + + throw error instanceof Error ? error : new Error(errorMessage) + } + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('ArXiv PDF parser input streaming started', { + toolCallId, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputStart', + }) + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('ArXiv PDF parser received input chunk', { + toolCallId, + inputTextDelta, + abortSignal: abortSignal?.aborted, + messageCount: messages.length, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('ArXiv PDF parser received input', { + toolCallId, + messageCount: messages.length, + arxivId: input.arxivId, + maxPages: input.maxPages, + abortSignal: abortSignal?.aborted, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('ArXiv PDF parsing completed', { + toolCallId, + toolName, + arxivId: output.arxivId, + success: output.success, + pageCount: output.statistics.pageCount, + abortSignal: abortSignal?.aborted, + hook: 'onOutput', + }) + }, }) export type ArxivPdfParserUITool = InferUITool<typeof arxivPdfParserTool> @@ -871,325 +945,349 @@ export type ArxivPdfParserUITool = InferUITool<typeof arxivPdfParserTool> */ export const arxivPaperDownloaderTool = createTool({ - id: 'arxiv-paper-downloader', - description: - 'Download complete arXiv papers with metadata and optional PDF parsing to markdown', - inputSchema: z.object({ - arxivId: z - .string() - .describe("ArXiv paper ID (e.g., '2103.12345' or '2103.12345v1')"), - includePdfContent: z - .boolean() - .optional() - .default(false) - .describe('Whether to download and parse the PDF content'), - maxPages: z - .number() - .min(1) - .max(1000) - .optional() - .default(100) - .describe('Maximum pages to parse if includePdfContent is true'), - format: z - .enum(['metadata', 'markdown', 'both']) - .optional() - .default('both') - .describe('Output format: metadata only, markdown only, or both'), - }), - outputSchema: z.object({ - success: z.boolean(), - arxivId: z.string(), - metadata: z - .object({ - id: z.string(), - title: z.string(), - authors: z.array(z.string()), - abstract: z.string().optional(), - categories: z.array(z.string()), - published: z.string(), - updated: z.string().optional(), - pdf_url: z.string(), - doi: z.string().optional(), - journal_ref: z.string().optional(), - comments: z.string().optional(), - }) - .optional(), - pdfContent: z - .object({ - markdown: z.string(), - pageCount: z.number(), - textLength: z.number(), - }) - .optional(), - }), - execute: async (inputData, context) => { - const writer = context?.writer - const abortSignal = context?.abortSignal - const requestCtx = - context?.requestContext as ArxivRequestContext | undefined - const tracingContext = context?.tracingContext - const span = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'arxiv-paper-downloader', - input: inputData, - metadata: { - 'tool.id': 'arxiv-paper-downloader', - 'tool.input.arxivId': inputData.arxivId, - 'tool.input.includePdfContent': inputData.includePdfContent, - 'tool.input.format': inputData.format, - 'user.id': requestCtx?.userId, - }, - }) - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: - '🚀 Starting arXiv paper downloader for ' + - inputData.arxivId, - stage: 'arxiv-paper-downloader', - }, - id: 'arxiv-paper-downloader', - }) - toolCallCounters.set( - 'arxiv-paper-downloader', - (toolCallCounters.get('arxiv-paper-downloader') ?? 0) + 1 - ) - try { - if (typeof requestCtx?.userId === 'string') { - log.debug('Executing arXiv paper downloader for user', { - userId: requestCtx.userId, + id: 'arxiv-paper-downloader', + description: + 'Download complete arXiv papers with metadata and optional PDF parsing to markdown', + inputSchema: z.object({ + arxivId: z + .string() + .describe("ArXiv paper ID (e.g., '2103.12345' or '2103.12345v1')"), + includePdfContent: z + .boolean() + .optional() + .default(false) + .describe('Whether to download and parse the PDF content'), + maxPages: z + .number() + .min(1) + .max(1000) + .optional() + .default(100) + .describe('Maximum pages to parse if includePdfContent is true'), + format: z + .enum(['metadata', 'markdown', 'both']) + .optional() + .default('both') + .describe('Output format: metadata only, markdown only, or both'), + }), + outputSchema: z.object({ + success: z.boolean(), + arxivId: z.string(), + metadata: z + .object({ + id: z.string(), + title: z.string(), + authors: z.array(z.string()), + abstract: z.string().optional(), + categories: z.array(z.string()), + published: z.string(), + updated: z.string().optional(), + pdf_url: z.string(), + doi: z.string().optional(), + journal_ref: z.string().optional(), + comments: z.string().optional(), + }) + .optional(), + pdfContent: z + .object({ + markdown: z.string(), + pageCount: z.number(), + textLength: z.number(), + }) + .optional(), + }), + execute: async (inputData, context) => { + const writer = context?.writer + const abortSignal = context?.abortSignal + const requestCtx = context?.requestContext as + | ArxivRequestContext + | undefined + const tracingContext: TracingContext | undefined = context?.tracingContext + const span = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'arxiv-paper-downloader', + input: inputData, + metadata: { + 'tool.id': 'arxiv-paper-downloader', + 'tool.input.arxivId': inputData.arxivId, + 'tool.input.includePdfContent': inputData.includePdfContent, + 'tool.input.format': inputData.format, + 'user.id': requestCtx?.userId, + }, }) - } - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: '📡 Fetching paper metadata from arXiv API...', - stage: 'arxiv-paper-downloader', - }, - id: 'arxiv-paper-downloader', - }) - // Get metadata from arXiv API - const apiUrl = `http://export.arxiv.org/api/query?id_list=${inputData.arxivId}&max_results=1` - let apiResp - try { - apiResp = await httpFetch(apiUrl, { method: 'GET', responseType: 'text', signal: abortSignal, timeout: 30000 }) - } catch (err: any) { - if (err?.code === 'ERR_CANCELED' || (err instanceof Error && err.name === 'AbortError')) { - throw new Error(`ArXiv metadata fetch cancelled for ${inputData.arxivId}`) - } - throw err - } - - if (typeof apiResp.status !== 'number' || apiResp.status < 200 || apiResp.status >= 300) { - throw new Error(`Failed to fetch paper metadata: ${apiResp.status} ${apiResp.statusText ?? ''}`) - } - - const xmlText = apiResp.data as string - - const papers = parseArxivXml(xmlText, true) - - if (papers?.length === 0) { - throw new Error(`Paper not found: ${inputData.arxivId}`) - } - - const paperMetadata = papers[0] - - let pdfContent: - | { markdown: string; pageCount: number; textLength: number } - | undefined - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: '🔄 Processing paper data...', - stage: 'arxiv-paper-downloader', - }, - id: 'arxiv-paper-downloader', - }) - // Download and parse PDF if requested - if (inputData.includePdfContent) { + await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: '📥 Downloading PDF content...', - stage: 'arxiv-paper-downloader', - }, - id: 'arxiv-paper-downloader', + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: + '🚀 Starting arXiv paper downloader for ' + + inputData.arxivId, + stage: 'arxiv-paper-downloader', + }, + id: 'arxiv-paper-downloader', }) + toolCallCounters.set( + 'arxiv-paper-downloader', + (toolCallCounters.get('arxiv-paper-downloader') ?? 0) + 1 + ) try { - const pdfResponse = await fetch(paperMetadata.pdf_url, { - signal: abortSignal, - }) + if (typeof requestCtx?.userId === 'string') { + log.debug('Executing arXiv paper downloader for user', { + userId: requestCtx.userId, + }) + } + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: '📡 Fetching paper metadata from arXiv API...', + stage: 'arxiv-paper-downloader', + }, + id: 'arxiv-paper-downloader', + }) + // Get metadata from arXiv API + const apiUrl = `http://export.arxiv.org/api/query?id_list=${inputData.arxivId}&max_results=1` + let apiResp + try { + apiResp = await httpFetch(apiUrl, { + method: 'GET', + responseType: 'text', + signal: abortSignal, + timeout: 30000, + }) + } catch (err: any) { + if ( + err?.code === 'ERR_CANCELED' || + (err instanceof Error && err.name === 'AbortError') + ) { + throw new Error( + `ArXiv metadata fetch cancelled for ${inputData.arxivId}` + ) + } + throw err + } - if (pdfResponse.ok) { - const pdfBuffer = Buffer.from( - await pdfResponse.arrayBuffer() - ) - const extracted = await extractPdfText( - pdfBuffer, - inputData.maxPages ?? 100 - ) + if ( + typeof apiResp.status !== 'number' || + apiResp.status < 200 || + apiResp.status >= 300 + ) { + throw new Error( + `Failed to fetch paper metadata: ${apiResp.status} ${apiResp.statusText ?? ''}` + ) + } - const markdown = convertPdfTextToMarkdown( - extracted.text, - { - title: paperMetadata.title, - authors: paperMetadata.authors, - } - ) + const xmlText = apiResp.data as string - pdfContent = { - markdown, - pageCount: extracted.numpages, - textLength: markdown.length, + const papers = parseArxivXml(xmlText, true) + + if (papers?.length === 0) { + throw new Error(`Paper not found: ${inputData.arxivId}`) } - } else { - // PDF download failed, continue without it + + const paperMetadata = papers[0] + + let pdfContent: + | { markdown: string; pageCount: number; textLength: number } + | undefined + await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: '❌ PDF download failed', - stage: 'arxiv-paper-downloader', - }, - id: 'arxiv-paper-downloader', + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: '🔄 Processing paper data...', + stage: 'arxiv-paper-downloader', + }, + id: 'arxiv-paper-downloader', }) - log.warn( - `PDF download failed for ${inputData.arxivId}: ${pdfResponse.status} ${pdfResponse.statusText}` - ) - } - } catch (err) { - // PDF parsing or network failure, continue without it but log - const errMsg = - err instanceof Error ? err.message : 'Unknown error' - log.warn( - `PDF download/parse failed for ${inputData.arxivId}: ${errMsg}` - ) - } - } - - // Return based on requested format - const result: { - success: boolean - arxivId: string - metadata?: typeof paperMetadata - pdfContent?: { - markdown: string - pageCount: number - textLength: number - } - } = { - success: true, - arxivId: inputData.arxivId, - } - - if ( - inputData.format === 'metadata' || - inputData.format === 'both' - ) { - result.metadata = paperMetadata - } - - if ( - (inputData.format === 'markdown' || - inputData.format === 'both') && - pdfContent - ) { - result.pdfContent = pdfContent - } - - if (span) { - const spanAny = span as any - if (typeof spanAny.setAttribute === 'function') { - spanAny.setAttribute('tool.output.success', true) - spanAny.setAttribute('tool.output.hasPdfContent', !!pdfContent) - } else { - spanAny.attributes = spanAny.attributes ?? {} - spanAny.attributes['tool.output.success'] = true - spanAny.attributes['tool.output.hasPdfContent'] = !!pdfContent - } - if (typeof spanAny.end === 'function') { - spanAny.end() - } - } - return result - } catch (error) { - const errorMessage = - error instanceof Error - ? error.message - : 'Unknown error occurred' - if (span) { - const spanAny = span as any - if (error instanceof Error && typeof spanAny.recordException === 'function') { - spanAny.recordException(error) - } else if (error instanceof Error) { - spanAny.attributes = spanAny.attributes ?? {} - spanAny.attributes['error.exception'] = error.message - } + // Download and parse PDF if requested + if (inputData.includePdfContent) { + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: '📥 Downloading PDF content...', + stage: 'arxiv-paper-downloader', + }, + id: 'arxiv-paper-downloader', + }) + try { + const pdfResponse = await fetch(paperMetadata.pdf_url, { + signal: abortSignal, + }) + + if (pdfResponse.ok) { + const pdfBuffer = Buffer.from( + await pdfResponse.arrayBuffer() + ) + const extracted = await extractPdfText( + pdfBuffer, + inputData.maxPages ?? 100 + ) + + const markdown = convertPdfTextToMarkdown( + extracted.text, + { + title: paperMetadata.title, + authors: paperMetadata.authors, + } + ) + + pdfContent = { + markdown, + pageCount: extracted.numpages, + textLength: markdown.length, + } + } else { + // PDF download failed, continue without it + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: '❌ PDF download failed', + stage: 'arxiv-paper-downloader', + }, + id: 'arxiv-paper-downloader', + }) + log.warn( + `PDF download failed for ${inputData.arxivId}: ${pdfResponse.status} ${pdfResponse.statusText}` + ) + } + } catch (err) { + // PDF parsing or network failure, continue without it but log + const errMsg = + err instanceof Error ? err.message : 'Unknown error' + log.warn( + `PDF download/parse failed for ${inputData.arxivId}: ${errMsg}` + ) + } + } - if (typeof spanAny.setStatus === 'function') { - spanAny.setStatus({ code: 2, message: errorMessage }) - } else { - spanAny.attributes = spanAny.attributes ?? {} - spanAny.attributes['error.code'] = 2 - spanAny.attributes['error.message'] = errorMessage - } + // Return based on requested format + const result: { + success: boolean + arxivId: string + metadata?: typeof paperMetadata + pdfContent?: { + markdown: string + pageCount: number + textLength: number + } + } = { + success: true, + arxivId: inputData.arxivId, + } + + if ( + inputData.format === 'metadata' || + inputData.format === 'both' + ) { + result.metadata = paperMetadata + } + + if ( + (inputData.format === 'markdown' || + inputData.format === 'both') && + pdfContent + ) { + result.pdfContent = pdfContent + } - if (typeof spanAny.end === 'function') { - spanAny.end() + if (span) { + const spanAny = span as any + if (typeof spanAny.setAttribute === 'function') { + spanAny.setAttribute('tool.output.success', true) + spanAny.setAttribute( + 'tool.output.hasPdfContent', + !!pdfContent + ) + } else { + spanAny.attributes = spanAny.attributes ?? {} + spanAny.attributes['tool.output.success'] = true + spanAny.attributes['tool.output.hasPdfContent'] = + !!pdfContent + } + if (typeof spanAny.end === 'function') { + spanAny.end() + } + } + return result + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : 'Unknown error occurred' + if (span) { + const spanAny = span as any + if ( + error instanceof Error && + typeof spanAny.recordException === 'function' + ) { + spanAny.recordException(error) + } else if (error instanceof Error) { + spanAny.attributes = spanAny.attributes ?? {} + spanAny.attributes['error.exception'] = error.message + } + + if (typeof spanAny.setStatus === 'function') { + spanAny.setStatus({ code: 2, message: errorMessage }) + } else { + spanAny.attributes = spanAny.attributes ?? {} + spanAny.attributes['error.code'] = 2 + spanAny.attributes['error.message'] = errorMessage + } + + if (typeof spanAny.end === 'function') { + spanAny.end() + } + } + throw error instanceof Error ? error : new Error(errorMessage) } - } - throw error instanceof Error ? error : new Error(errorMessage) - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('ArXiv paper downloader input streaming started', { - toolCallId, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputStart', - }) - }, - onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { - log.info('ArXiv paper downloader received input chunk', { - toolCallId, - inputTextDelta, - abortSignal: abortSignal?.aborted, - messageCount: messages.length, - hook: 'onInputDelta', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('ArXiv paper downloader received input', { - toolCallId, - messageCount: messages.length, - arxivId: input.arxivId, - includePdf: input.includePdfContent, - format: input.format, - abortSignal: abortSignal?.aborted, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('ArXiv paper download completed', { - toolCallId, - toolName, - arxivId: output.arxivId, - success: output.success, - hasPdf: !!output.pdfContent, - abortSignal: abortSignal?.aborted, - hook: 'onOutput', - }) - }, + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('ArXiv paper downloader input streaming started', { + toolCallId, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputStart', + }) + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('ArXiv paper downloader received input chunk', { + toolCallId, + inputTextDelta, + abortSignal: abortSignal?.aborted, + messageCount: messages.length, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('ArXiv paper downloader received input', { + toolCallId, + messageCount: messages.length, + arxivId: input.arxivId, + includePdf: input.includePdfContent, + format: input.format, + abortSignal: abortSignal?.aborted, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('ArXiv paper download completed', { + toolCallId, + toolName, + arxivId: output.arxivId, + success: output.success, + hasPdf: !!output.pdfContent, + abortSignal: abortSignal?.aborted, + hook: 'onOutput', + }) + }, }) export type ArxivPaperDownloaderUITool = InferUITool< - typeof arxivPaperDownloaderTool + typeof arxivPaperDownloaderTool > /** @@ -1198,22 +1296,9 @@ export type ArxivPaperDownloaderUITool = InferUITool< */ // FIXME: Use a proper XML parser instead of regex @copilot fast-xml-parser function parseArxivXml( - xmlText: string, - includeAbstract: boolean + xmlText: string, + includeAbstract: boolean ): Array<{ - id: string - title: string - authors: string[] - abstract?: string - categories: string[] - published: string - updated?: string - pdf_url: string - doi?: string - journal_ref?: string - comments?: string -}> { - const papers: Array<{ id: string title: string authors: string[] @@ -1225,80 +1310,93 @@ function parseArxivXml( doi?: string journal_ref?: string comments?: string - }> = [] - - // Split by entry tags - const entryRegex = /<entry>([\s\S]*?)<\/entry>/g - let match - - while ((match = entryRegex.exec(xmlText)) !== null) { - const entryXml = match[1] - - // Extract fields using regex - const idRegex = /<id>(.*?)<\/id>/ - const idMatch = idRegex.exec(entryXml) - - const titleRegex = /<title>(.*?)<\/title>/ - const titleMatch = titleRegex.exec(entryXml) - - const publishedRegex = /<published>(.*?)<\/published>/ - const publishedMatch = publishedRegex.exec(entryXml) - - const updatedRegex = /<updated>(.*?)<\/updated>/ - const updatedMatch = updatedRegex.exec(entryXml) - - const summaryRegex = /<summary>(.*?)<\/summary>/ - const summaryMatch = summaryRegex.exec(entryXml) - - const doiRegex = /<arxiv:doi>(.*?)<\/arxiv:doi>/ - const doiMatch = doiRegex.exec(entryXml) - - const journalRefRegex = /<arxiv:journal_ref>(.*?)<\/arxiv:journal_ref>/ - const journalRefMatch = journalRefRegex.exec(entryXml) - - const commentsRegex = /<arxiv:comment>(.*?)<\/arxiv:comment>/ - const commentsMatch = commentsRegex.exec(entryXml) +}> { + const papers: Array<{ + id: string + title: string + authors: string[] + abstract?: string + categories: string[] + published: string + updated?: string + pdf_url: string + doi?: string + journal_ref?: string + comments?: string + }> = [] + + // Split by entry tags + const entryRegex = /<entry>([\s\S]*?)<\/entry>/g + let match + + while ((match = entryRegex.exec(xmlText)) !== null) { + const entryXml = match[1] + + // Extract fields using regex + const idRegex = /<id>(.*?)<\/id>/ + const idMatch = idRegex.exec(entryXml) + + const titleRegex = /<title>(.*?)<\/title>/ + const titleMatch = titleRegex.exec(entryXml) + + const publishedRegex = /<published>(.*?)<\/published>/ + const publishedMatch = publishedRegex.exec(entryXml) + + const updatedRegex = /<updated>(.*?)<\/updated>/ + const updatedMatch = updatedRegex.exec(entryXml) + + const summaryRegex = /<summary>(.*?)<\/summary>/ + const summaryMatch = summaryRegex.exec(entryXml) + + const doiRegex = /<arxiv:doi>(.*?)<\/arxiv:doi>/ + const doiMatch = doiRegex.exec(entryXml) + + const journalRefRegex = /<arxiv:journal_ref>(.*?)<\/arxiv:journal_ref>/ + const journalRefMatch = journalRefRegex.exec(entryXml) + + const commentsRegex = /<arxiv:comment>(.*?)<\/arxiv:comment>/ + const commentsMatch = commentsRegex.exec(entryXml) + + if (!idMatch || !titleMatch || !publishedMatch) { + continue // Skip malformed entries + } - if (!idMatch || !titleMatch || !publishedMatch) { - continue // Skip malformed entries - } + // Extract authors + const authors: string[] = [] + const authorRegex = /<author><name>(.*?)<\/name><\/author>/g + let authorMatch + while ((authorMatch = authorRegex.exec(entryXml)) !== null) { + authors.push(authorMatch[1]) + } - // Extract authors - const authors: string[] = [] - const authorRegex = /<author><name>(.*?)<\/name><\/author>/g - let authorMatch - while ((authorMatch = authorRegex.exec(entryXml)) !== null) { - authors.push(authorMatch[1]) - } + // Extract categories + const categories: string[] = [] + const categoryRegex = /<category term="(.*?)"[^>]*\/>/g + let categoryMatch + while ((categoryMatch = categoryRegex.exec(entryXml)) !== null) { + categories.push(categoryMatch[1]) + } - // Extract categories - const categories: string[] = [] - const categoryRegex = /<category term="(.*?)"[^>]*\/>/g - let categoryMatch - while ((categoryMatch = categoryRegex.exec(entryXml)) !== null) { - categories.push(categoryMatch[1]) + // Generate PDF URL from ID + const arxivId = idMatch[1].split('/').pop() ?? '' + + papers.push({ + id: arxivId, + title: titleMatch[1].replace(/<!\[CDATA\[|\]\]>/g, '').trim(), + authors, + abstract: + includeAbstract && summaryMatch + ? summaryMatch[1].replace(/<!\[CDATA\[|\]\]>/g, '').trim() + : undefined, + categories, + published: publishedMatch[1], + updated: updatedMatch ? updatedMatch[1] : undefined, + pdf_url: `https://arxiv.org/pdf/${arxivId}`, + doi: doiMatch ? doiMatch[1] : undefined, + journal_ref: journalRefMatch ? journalRefMatch[1] : undefined, + comments: commentsMatch ? commentsMatch[1] : undefined, + }) } - // Generate PDF URL from ID - const arxivId = idMatch[1].split('/').pop() ?? '' - - papers.push({ - id: arxivId, - title: titleMatch[1].replace(/<!\[CDATA\[|\]\]>/g, '').trim(), - authors, - abstract: - includeAbstract && summaryMatch - ? summaryMatch[1].replace(/<!\[CDATA\[|\]\]>/g, '').trim() - : undefined, - categories, - published: publishedMatch[1], - updated: updatedMatch ? updatedMatch[1] : undefined, - pdf_url: `https://arxiv.org/pdf/${arxivId}`, - doi: doiMatch ? doiMatch[1] : undefined, - journal_ref: journalRefMatch ? journalRefMatch[1] : undefined, - comments: commentsMatch ? commentsMatch[1] : undefined, - }) - } - - return papers + return papers } diff --git a/src/mastra/tools/browser-tool.ts b/src/mastra/tools/browser-tool.ts index e855ac21..dd22df35 100644 --- a/src/mastra/tools/browser-tool.ts +++ b/src/mastra/tools/browser-tool.ts @@ -11,1234 +11,1261 @@ import { log } from '../config/logger' type UserTier = 'free' | 'pro' | 'enterprise' export interface BrowserRequestContext extends RequestContext { - userId?: string; - sessionId?: string; - 'user-tier': UserTier; + userId?: string + sessionId?: string + 'user-tier': UserTier } // Browser instance cache for reuse let browserInstance: Browser | null = null async function getBrowser(): Promise<Browser> { - if (!(browserInstance?.isConnected() ?? false)) { - if (browserInstance) { - await browserInstance.close().catch(() => { }) // Close the old instance if it's not connected + if (!(browserInstance?.isConnected() ?? false)) { + if (browserInstance) { + await browserInstance.close().catch(() => {}) // Close the old instance if it's not connected + } + browserInstance = await chromium.launch({ + headless: true, + chromiumSandbox: false, + }) } - browserInstance = await chromium.launch({ - headless: true, - chromiumSandbox: false, - }) - } - return browserInstance! + return browserInstance! } export const browserTool = createTool({ - id: 'browserTool', - description: - 'Browser Tool, opens a browser and navigates to a url capturing the content', - inputSchema: z.object({ - url: z.string(), - }), - outputSchema: z.object({ - message: z.string(), - }), - execute: async (inputData, context) => { - const abortSignal = context?.abortSignal - const tracingContext = context?.tracingContext - const requestCtx = context?.requestContext as BrowserRequestContext | undefined - - // Check if operation was already cancelled - if (abortSignal?.aborted === true) { - throw new Error('Browser operation cancelled') - } + id: 'browserTool', + description: + 'Browser Tool, opens a browser and navigates to a url capturing the content', + inputSchema: z.object({ + url: z.string(), + }), + outputSchema: z.object({ + message: z.string(), + }), + execute: async (inputData, context) => { + const abortSignal = context?.abortSignal + const tracingContext: TracingContext | undefined = context?.tracingContext + const requestCtx = context?.requestContext as + | BrowserRequestContext + | undefined + + // Check if operation was already cancelled + if (abortSignal?.aborted === true) { + throw new Error('Browser operation cancelled') + } - const span = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'browser-scrape', - input: inputData, - metadata: { - 'tool.id': 'browser-scrape', - 'tool.input.url': inputData.url, - 'user.id': requestCtx?.userId, - }, - }) - - await context?.writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `Input: url="${inputData.url}" - 🌐 Launching browser`, - stage: 'browserTool', - }, - id: 'browserTool', - }) - try { - if (typeof requestCtx?.userId === 'string') { - log.debug('Executing browser scrape for user', { - userId: requestCtx.userId, + const span = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'browser-scrape', + input: inputData, + metadata: { + 'tool.id': 'browser-scrape', + 'tool.input.url': inputData.url, + 'user.id': requestCtx?.userId, + }, + requestContext: context?.requestContext, }) - } - const browser = await getBrowser() - const page = await browser.newPage() - - await page.goto(inputData.url, { waitUntil: 'domcontentloaded' }) - - const docs = MDocument.fromHTML(await page.content()) + await context?.writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `Input: url="${inputData.url}" - 🌐 Launching browser`, + stage: 'browserTool', + }, + id: 'browserTool', + }) + try { + if (typeof requestCtx?.userId === 'string') { + log.debug('Executing browser scrape for user', { + userId: requestCtx.userId, + }) + } - await docs.chunk({ - strategy: 'html', - maxSize: 300, - sections: [ - ['h1', 'Header 1'], - ['h2', 'Header 2'], - ['h3', 'Header 3'], - ['h4', 'Header 4'], - ['h5', 'Header 5'], - ['h6', 'Header 6'], - ['p', 'Paragraph'], - ], - }) + const browser = await getBrowser() + const page = await browser.newPage() + + await page.goto(inputData.url, { waitUntil: 'domcontentloaded' }) + + const docs = MDocument.fromHTML(await page.content()) + + await docs.chunk({ + strategy: 'html', + maxSize: 300, + sections: [ + ['h1', 'Header 1'], + ['h2', 'Header 2'], + ['h3', 'Header 3'], + ['h4', 'Header 4'], + ['h5', 'Header 5'], + ['h6', 'Header 6'], + ['p', 'Paragraph'], + ], + }) + + await page.close() + + if (!docs.getText().length) { + await context?.writer?.custom({ + type: 'data-tool-progress', + data: { message: '⚠️ No content found' }, + }) + span?.update({ + output: { message: 'No content' }, + metadata: { + 'tool.output.success': false, + 'tool.output.reason': 'No content', + }, + }) + span?.end() + return { message: 'No content' } + } - await page.close() + const result = docs.getText().join('\n') + await context?.writer?.custom({ + type: 'data-tool-progress', + data: { message: '✅ Content extracted successfully' }, + }) + span?.update({ + output: { contentLength: result.length }, + metadata: { + 'tool.output.success': true, + 'tool.output.contentLength': result.length, + }, + }) + span?.end() + return { message: result } + } catch (e) { + // Handle AbortError specifically + if (e instanceof Error && e.name === 'AbortError') { + const cancelMessage = `Browser operation cancelled for ${inputData.url}` + span?.update({ + metadata: { + 'tool.output.success': false, + 'tool.output.reason': cancelMessage, + }, + }) + span?.end() + + await context?.writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `🛑 ${cancelMessage}`, + stage: 'browserTool', + }, + id: 'browserTool', + }) + + log.warn(cancelMessage) + return { message: `Error: ${cancelMessage}` } + } - if (!docs.getText().length) { - await context?.writer?.custom({ - type: 'data-tool-progress', - data: { message: '⚠️ No content found' }, + const errorMsg = e instanceof Error ? e.message : 'Unknown error' + log.error(`Browser scrape failed: ${errorMsg}`) + span?.error({ + error: e instanceof Error ? e : new Error(errorMsg), + endSpan: true, + }) + return { message: `Error: ${errorMsg}` } + } + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Browser tool input streaming started', { + toolCallId, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputStart', }) - span?.update({ - output: { message: 'No content' }, - metadata: { - 'tool.output.success': false, - 'tool.output.reason': 'No content', - }, + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('Browser tool received input chunk', { + toolCallId, + inputTextDelta, + abortSignal: abortSignal?.aborted, + messageCount: messages.length, + hook: 'onInputDelta', }) - span?.end() - return { message: 'No content' } - } - - const result = docs.getText().join('\n') - await context?.writer?.custom({ - type: 'data-tool-progress', - data: { message: '✅ Content extracted successfully' }, - }) - span?.update({ - output: { contentLength: result.length }, - metadata: { - 'tool.output.success': true, - 'tool.output.contentLength': result.length, - }, - }) - span?.end() - return { message: result } - } catch (e) { - // Handle AbortError specifically - if (e instanceof Error && e.name === 'AbortError') { - const cancelMessage = `Browser operation cancelled for ${inputData.url}` - span?.update({ - metadata: { - 'tool.output.success': false, - 'tool.output.reason': cancelMessage, - } + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Browser tool received input', { + toolCallId, + messageCount: messages.length, + url: input.url, + abortSignal: abortSignal?.aborted, + hook: 'onInputAvailable', }) - span?.end() - - await context?.writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: `🛑 ${cancelMessage}`, - stage: 'browserTool', - }, - id: 'browserTool', + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + const isSuccess = + !output.message.includes('Error:') && + !output.message.includes('Failed') + log[isSuccess ? 'info' : 'warn']('Browser tool completed', { + toolCallId, + toolName, + success: isSuccess, + message: output.message.substring(0, 100), + abortSignal: abortSignal?.aborted, + hook: 'onOutput', }) - - log.warn(cancelMessage) - return { message: `Error: ${cancelMessage}` } - } - - const errorMsg = e instanceof Error ? e.message : 'Unknown error' - log.error(`Browser scrape failed: ${errorMsg}`) - span?.error({ - error: e instanceof Error ? e : new Error(errorMsg), - endSpan: true, - }) - return { message: `Error: ${errorMsg}` } - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Browser tool input streaming started', { - toolCallId, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputStart', - }) - }, - onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { - log.info('Browser tool received input chunk', { - toolCallId, - inputTextDelta, - abortSignal: abortSignal?.aborted, - messageCount: messages.length, - hook: 'onInputDelta', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Browser tool received input', { - toolCallId, - messageCount: messages.length, - url: input.url, - abortSignal: abortSignal?.aborted, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - const isSuccess = - !output.message.includes('Error:') && - !output.message.includes('Failed') - log[isSuccess ? 'info' : 'warn']('Browser tool completed', { - toolCallId, - toolName, - success: isSuccess, - message: output.message.substring(0, 100), - abortSignal: abortSignal?.aborted, - hook: 'onOutput', - }) - }, + }, }) export const screenshotTool = createTool({ - id: 'screenshotTool', - description: - 'Capture a screenshot of a webpage. Returns base64-encoded PNG image.', - inputSchema: z.object({ - url: z.string().describe('URL to screenshot'), - fullPage: z - .boolean() - .optional() - .default(false) - .describe('Capture full page or viewport only'), - width: z.number().optional().default(1280).describe('Viewport width'), - height: z.number().optional().default(720).describe('Viewport height'), - }), - outputSchema: z.object({ - success: z.boolean(), - screenshot: z.string().optional(), - message: z.string().optional(), - }), - execute: async (inputData, context) => { - const abortSignal = context?.abortSignal - const tracingContext = context?.tracingContext - const requestCtx = context?.requestContext as BrowserRequestContext | undefined - const span = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'browser-screenshot', - input: inputData, - metadata: { - 'tool.id': 'browser-screenshot', - 'tool.input.url': inputData.url, - 'tool.input.fullPage': inputData.fullPage, - 'user.id': requestCtx?.userId, - }, - }) - - await context?.writer?.custom({ - type: 'data-tool-progress', - data: { message: `📸 Taking screenshot of ${inputData.url}` }, - }) - - try { - if (typeof requestCtx?.userId === 'string') { - log.debug('Executing browser screenshot for user', { - userId: requestCtx.userId, + id: 'screenshotTool', + description: + 'Capture a screenshot of a webpage. Returns base64-encoded PNG image.', + inputSchema: z.object({ + url: z.string().describe('URL to screenshot'), + fullPage: z + .boolean() + .optional() + .default(false) + .describe('Capture full page or viewport only'), + width: z.number().optional().default(1280).describe('Viewport width'), + height: z.number().optional().default(720).describe('Viewport height'), + }), + outputSchema: z.object({ + success: z.boolean(), + screenshot: z.string().optional(), + message: z.string().optional(), + }), + execute: async (inputData, context) => { + const abortSignal = context?.abortSignal + const tracingContext: TracingContext | undefined = context?.tracingContext + const requestCtx = context?.requestContext as + | BrowserRequestContext + | undefined + const span = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'browser-screenshot', + input: inputData, + metadata: { + 'tool.id': 'browser-screenshot', + 'tool.input.url': inputData.url, + 'tool.input.fullPage': inputData.fullPage, + 'user.id': requestCtx?.userId, + }, + requestContext: context?.requestContext, }) - } - if (abortSignal?.aborted === true) { - throw new Error('Screenshot operation cancelled') - } - const browser = await getBrowser() - const page = await browser.newPage() - await page.setViewportSize({ - width: inputData.width ?? 1280, - height: inputData.height ?? 720, - }) - await page.goto(inputData.url, { waitUntil: 'networkidle' }) - - const screenshot = await page.screenshot({ - fullPage: inputData.fullPage ?? false, - type: 'png', - }) - - await page.close() - - const base64 = screenshot.toString('base64') - await context?.writer?.custom({ - type: 'data-tool-progress', - data: { message: '✅ Screenshot captured' }, - }) - span?.update({ - output: { success: true }, - metadata: { - 'tool.output.success': true, - 'tool.output.size': screenshot.length, - }, - }) - span?.end() - - return { success: true, screenshot: base64 } - } catch (e) { - const errorMsg = e instanceof Error ? e.message : 'Unknown error' - log.error(`Screenshot failed: ${errorMsg}`) - span?.error({ - error: e instanceof Error ? e : new Error(errorMsg), - endSpan: true, - }) - return { success: false, message: errorMsg } - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Screenshot tool input streaming started', { - toolCallId, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputStart', - }) - }, - onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { - log.info('Screenshot tool received input chunk', { - toolCallId, - inputTextDelta, - abortSignal: abortSignal?.aborted, - messageCount: messages.length, - hook: 'onInputDelta', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Screenshot tool received input', { - toolCallId, - messageCount: messages.length, - url: input.url, - abortSignal: abortSignal?.aborted, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log[output.success ? 'info' : 'warn']('Screenshot tool completed', { - toolCallId, - toolName, - success: output.success, - abortSignal: abortSignal?.aborted, - hook: 'onOutput', - }) - }, + + await context?.writer?.custom({ + type: 'data-tool-progress', + data: { message: `📸 Taking screenshot of ${inputData.url}` }, + }) + + try { + if (typeof requestCtx?.userId === 'string') { + log.debug('Executing browser screenshot for user', { + userId: requestCtx.userId, + }) + } + if (abortSignal?.aborted === true) { + throw new Error('Screenshot operation cancelled') + } + const browser = await getBrowser() + const page = await browser.newPage() + await page.setViewportSize({ + width: inputData.width ?? 1280, + height: inputData.height ?? 720, + }) + await page.goto(inputData.url, { waitUntil: 'networkidle' }) + + const screenshot = await page.screenshot({ + fullPage: inputData.fullPage ?? false, + type: 'png', + }) + + await page.close() + + const base64 = screenshot.toString('base64') + await context?.writer?.custom({ + type: 'data-tool-progress', + data: { message: '✅ Screenshot captured' }, + }) + span?.update({ + output: { success: true }, + metadata: { + 'tool.output.success': true, + 'tool.output.size': screenshot.length, + }, + }) + span?.end() + + return { success: true, screenshot: base64 } + } catch (e) { + const errorMsg = e instanceof Error ? e.message : 'Unknown error' + log.error(`Screenshot failed: ${errorMsg}`) + span?.error({ + error: e instanceof Error ? e : new Error(errorMsg), + endSpan: true, + }) + return { success: false, message: errorMsg } + } + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Screenshot tool input streaming started', { + toolCallId, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputStart', + }) + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('Screenshot tool received input chunk', { + toolCallId, + inputTextDelta, + abortSignal: abortSignal?.aborted, + messageCount: messages.length, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Screenshot tool received input', { + toolCallId, + messageCount: messages.length, + url: input.url, + abortSignal: abortSignal?.aborted, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log[output.success ? 'info' : 'warn']('Screenshot tool completed', { + toolCallId, + toolName, + success: output.success, + abortSignal: abortSignal?.aborted, + hook: 'onOutput', + }) + }, }) export const pdfGeneratorTool = createTool({ - id: 'pdfGeneratorTool', - description: 'Generate a PDF from a webpage. Returns base64-encoded PDF.', - inputSchema: z.object({ - url: z.string().describe('URL to convert to PDF'), - format: z.enum(['A4', 'Letter', 'Legal']).optional().default('A4'), - landscape: z.boolean().optional().default(false), - printBackground: z.boolean().optional().default(true), - }), - outputSchema: z.object({ - success: z.boolean(), - pdf: z.string().optional(), - message: z.string().optional(), - }), - execute: async (inputData, context) => { - const abortSignal = context?.abortSignal - const tracingContext = context?.tracingContext - const requestCtx = context?.requestContext as BrowserRequestContext | undefined - const span = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'browser-pdf-generate', - input: inputData, - metadata: { - 'tool.id': 'browser-pdf-generate', - 'tool.input.url': inputData.url, - 'tool.input.format': inputData.format, - 'user.id': requestCtx?.userId, - }, - }) - - await context?.writer?.custom({ - type: 'data-tool-progress', - data: { message: `📄 Generating PDF from ${inputData.url}` }, - }) - - try { - if (typeof requestCtx?.userId === 'string') { - log.debug('Executing PDF generation for user', { - userId: requestCtx.userId, + id: 'pdfGeneratorTool', + description: 'Generate a PDF from a webpage. Returns base64-encoded PDF.', + inputSchema: z.object({ + url: z.string().describe('URL to convert to PDF'), + format: z.enum(['A4', 'Letter', 'Legal']).optional().default('A4'), + landscape: z.boolean().optional().default(false), + printBackground: z.boolean().optional().default(true), + }), + outputSchema: z.object({ + success: z.boolean(), + pdf: z.string().optional(), + message: z.string().optional(), + }), + execute: async (inputData, context) => { + const abortSignal = context?.abortSignal + const tracingContext: TracingContext | undefined = context?.tracingContext + const requestCtx = context?.requestContext as + | BrowserRequestContext + | undefined + const span = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'browser-pdf-generate', + input: inputData, + metadata: { + 'tool.id': 'browser-pdf-generate', + 'tool.input.url': inputData.url, + 'tool.input.format': inputData.format, + 'user.id': requestCtx?.userId, + }, + requestContext: context?.requestContext, }) - } - if (abortSignal?.aborted === true) { - throw new Error('PDF generation operation cancelled') - } - const browser = await getBrowser() - const page = await browser.newPage() - await page.goto(inputData.url, { waitUntil: 'networkidle' }) - - const pdf = await page.pdf({ - format: inputData.format ?? 'A4', - landscape: inputData.landscape ?? false, - printBackground: inputData.printBackground ?? true, - }) - - await page.close() - - const base64 = pdf.toString('base64') - await context?.writer?.custom({ - type: 'data-tool-progress', - data: { message: '✅ PDF generated' }, - }) - span?.update({ - output: { success: true }, - metadata: { - 'tool.output.success': true, - 'tool.output.size': pdf.length, - }, - }) - span?.end() - - return { success: true, pdf: base64 } - } catch (e) { - const errorMsg = e instanceof Error ? e.message : 'Unknown error' - log.error(`PDF generation failed: ${errorMsg}`) - span?.error({ - error: e instanceof Error ? e : new Error(errorMsg), - endSpan: true, - }) - return { success: false, message: errorMsg } - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('PDF generator tool input streaming started', { - toolCallId, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputStart', - }) - }, - onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { - log.info('PDF generator tool received input chunk', { - toolCallId, - inputTextDelta, - abortSignal: abortSignal?.aborted, - messageCount: messages.length, - hook: 'onInputDelta', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('PDF generator tool received input', { - toolCallId, - messageCount: messages.length, - url: input.url, - abortSignal: abortSignal?.aborted, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log[output.success ? 'info' : 'warn']('PDF generator tool completed', { - toolCallId, - toolName, - success: output.success, - abortSignal: abortSignal?.aborted, - hook: 'onOutput', - }) - }, + + await context?.writer?.custom({ + type: 'data-tool-progress', + data: { message: `📄 Generating PDF from ${inputData.url}` }, + }) + + try { + if (typeof requestCtx?.userId === 'string') { + log.debug('Executing PDF generation for user', { + userId: requestCtx.userId, + }) + } + if (abortSignal?.aborted === true) { + throw new Error('PDF generation operation cancelled') + } + const browser = await getBrowser() + const page = await browser.newPage() + await page.goto(inputData.url, { waitUntil: 'networkidle' }) + + const pdf = await page.pdf({ + format: inputData.format ?? 'A4', + landscape: inputData.landscape ?? false, + printBackground: inputData.printBackground ?? true, + }) + + await page.close() + + const base64 = pdf.toString('base64') + await context?.writer?.custom({ + type: 'data-tool-progress', + data: { message: '✅ PDF generated' }, + }) + span?.update({ + output: { success: true }, + metadata: { + 'tool.output.success': true, + 'tool.output.size': pdf.length, + }, + }) + span?.end() + + return { success: true, pdf: base64 } + } catch (e) { + const errorMsg = e instanceof Error ? e.message : 'Unknown error' + log.error(`PDF generation failed: ${errorMsg}`) + span?.error({ + error: e instanceof Error ? e : new Error(errorMsg), + endSpan: true, + }) + return { success: false, message: errorMsg } + } + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('PDF generator tool input streaming started', { + toolCallId, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputStart', + }) + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('PDF generator tool received input chunk', { + toolCallId, + inputTextDelta, + abortSignal: abortSignal?.aborted, + messageCount: messages.length, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('PDF generator tool received input', { + toolCallId, + messageCount: messages.length, + url: input.url, + abortSignal: abortSignal?.aborted, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log[output.success ? 'info' : 'warn']('PDF generator tool completed', { + toolCallId, + toolName, + success: output.success, + abortSignal: abortSignal?.aborted, + hook: 'onOutput', + }) + }, }) export const clickAndExtractTool = createTool({ - id: 'clickAndExtractTool', - description: - 'Navigate to a URL, perform click actions, and extract content. Useful for SPAs and dynamic content.', - inputSchema: z.object({ - url: z.string().describe('URL to navigate to'), - clickSelector: z - .string() - .optional() - .describe('CSS selector of element to click before extraction'), - waitForSelector: z - .string() - .optional() - .describe('CSS selector to wait for before extraction'), - extractSelector: z - .string() - .optional() - .describe( - 'CSS selector to extract content from (defaults to body)' - ), - timeout: z - .number() - .optional() - .default(10000) - .describe('Timeout in milliseconds'), - }), - outputSchema: z.object({ - success: z.boolean(), - content: z.string().optional(), - message: z.string().optional(), - }), - execute: async (inputData, context) => { - const abortSignal = context?.abortSignal - const tracingContext = context?.tracingContext - const requestCtx = context?.requestContext as BrowserRequestContext | undefined - const span = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'browser-click-extract', - input: inputData, - metadata: { - 'tool.id': 'browser-click-extract', - 'tool.input.url': inputData.url, - 'tool.input.clickSelector': inputData.clickSelector, - 'user.id': requestCtx?.userId, - }, - }) - - await context?.writer?.custom({ - type: 'data-tool-progress', - data: { message: `🖱️ Navigating to ${inputData.url}` }, - }) - - try { - if (typeof requestCtx?.userId === 'string') { - log.debug('Executing click and extract for user', { - userId: requestCtx.userId, + id: 'clickAndExtractTool', + description: + 'Navigate to a URL, perform click actions, and extract content. Useful for SPAs and dynamic content.', + inputSchema: z.object({ + url: z.string().describe('URL to navigate to'), + clickSelector: z + .string() + .optional() + .describe('CSS selector of element to click before extraction'), + waitForSelector: z + .string() + .optional() + .describe('CSS selector to wait for before extraction'), + extractSelector: z + .string() + .optional() + .describe( + 'CSS selector to extract content from (defaults to body)' + ), + timeout: z + .number() + .optional() + .default(10000) + .describe('Timeout in milliseconds'), + }), + outputSchema: z.object({ + success: z.boolean(), + content: z.string().optional(), + message: z.string().optional(), + }), + execute: async (inputData, context) => { + const abortSignal = context?.abortSignal + const tracingContext: TracingContext | undefined = context?.tracingContext + const requestCtx = context?.requestContext as + | BrowserRequestContext + | undefined + const span = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'browser-click-extract', + input: inputData, + metadata: { + 'tool.id': 'browser-click-extract', + 'tool.input.url': inputData.url, + 'tool.input.clickSelector': inputData.clickSelector, + 'user.id': requestCtx?.userId, + }, + requestContext: context?.requestContext, }) - } - if (abortSignal?.aborted === true) { - throw new Error('Click and extract operation cancelled') - } - const browser = await getBrowser() - const page = await browser.newPage() - await page.goto(inputData.url, { waitUntil: 'domcontentloaded' }) - - if ( - inputData.clickSelector !== undefined && - inputData.clickSelector !== null - ) { + await context?.writer?.custom({ - type: 'data-tool-progress', - data: { message: `🖱️ Clicking ${inputData.clickSelector}` }, - }) - await page.click(inputData.clickSelector, { - timeout: inputData.timeout, + type: 'data-tool-progress', + data: { message: `🖱️ Navigating to ${inputData.url}` }, }) - } - if ( - inputData.waitForSelector !== undefined && - inputData.waitForSelector !== null - ) { - await context?.writer?.custom({ - type: 'data-tool-progress', - data: { - message: `⏳ Waiting for ${inputData.waitForSelector}`, - }, + try { + if (typeof requestCtx?.userId === 'string') { + log.debug('Executing click and extract for user', { + userId: requestCtx.userId, + }) + } + if (abortSignal?.aborted === true) { + throw new Error('Click and extract operation cancelled') + } + const browser = await getBrowser() + const page = await browser.newPage() + await page.goto(inputData.url, { waitUntil: 'domcontentloaded' }) + + if ( + inputData.clickSelector !== undefined && + inputData.clickSelector !== null + ) { + await context?.writer?.custom({ + type: 'data-tool-progress', + data: { message: `🖱️ Clicking ${inputData.clickSelector}` }, + }) + await page.click(inputData.clickSelector, { + timeout: inputData.timeout, + }) + } + + if ( + inputData.waitForSelector !== undefined && + inputData.waitForSelector !== null + ) { + await context?.writer?.custom({ + type: 'data-tool-progress', + data: { + message: `⏳ Waiting for ${inputData.waitForSelector}`, + }, + }) + await page.waitForSelector(inputData.waitForSelector, { + timeout: inputData.timeout, + }) + } + + const selector = inputData.extractSelector ?? 'body' + const content = await page.$eval( + selector, + (el) => el.textContent ?? '' + ) + + await page.close() + + await context?.writer?.custom({ + type: 'data-tool-progress', + data: { message: '✅ Content extracted' }, + }) + span?.update({ + output: { contentLength: content.length }, + metadata: { + 'tool.output.success': true, + 'tool.output.contentLength': content.length, + }, + }) + span?.end() + + return { success: true, content: content.trim() } + } catch (e) { + const errorMsg = e instanceof Error ? e.message : 'Unknown error' + log.error(`Click and extract failed: ${errorMsg}`) + span?.error({ + error: e instanceof Error ? e : new Error(errorMsg), + endSpan: true, + }) + return { success: false, message: errorMsg } + } + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Click and extract tool input streaming started', { + toolCallId, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputStart', }) - await page.waitForSelector(inputData.waitForSelector, { - timeout: inputData.timeout, + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('Click and extract tool received input chunk', { + toolCallId, + inputTextDelta, + abortSignal: abortSignal?.aborted, + messageCount: messages.length, + hook: 'onInputDelta', }) - } - - const selector = inputData.extractSelector ?? 'body' - const content = await page.$eval( - selector, - (el) => el.textContent ?? '' - ) - - await page.close() - - await context?.writer?.custom({ - type: 'data-tool-progress', - data: { message: '✅ Content extracted' }, - }) - span?.update({ - output: { contentLength: content.length }, - metadata: { - 'tool.output.success': true, - 'tool.output.contentLength': content.length, - }, - }) - span?.end() - - return { success: true, content: content.trim() } - } catch (e) { - const errorMsg = e instanceof Error ? e.message : 'Unknown error' - log.error(`Click and extract failed: ${errorMsg}`) - span?.error({ - error: e instanceof Error ? e : new Error(errorMsg), - endSpan: true, - }) - return { success: false, message: errorMsg } - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Click and extract tool input streaming started', { - toolCallId, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputStart', - }) - }, - onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { - log.info('Click and extract tool received input chunk', { - toolCallId, - inputTextDelta, - abortSignal: abortSignal?.aborted, - messageCount: messages.length, - hook: 'onInputDelta', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Click and extract tool received input', { - toolCallId, - messageCount: messages.length, - url: input.url, - abortSignal: abortSignal?.aborted, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log[output.success ? 'info' : 'warn']('Click and extract tool completed', { - toolCallId, - toolName, - success: output.success, - abortSignal: abortSignal?.aborted, - hook: 'onOutput', - }) - }, + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Click and extract tool received input', { + toolCallId, + messageCount: messages.length, + url: input.url, + abortSignal: abortSignal?.aborted, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log[output.success ? 'info' : 'warn']( + 'Click and extract tool completed', + { + toolCallId, + toolName, + success: output.success, + abortSignal: abortSignal?.aborted, + hook: 'onOutput', + } + ) + }, }) export const fillFormTool = createTool({ - id: 'fillFormTool', - description: 'Fill out a form on a webpage and optionally submit it.', - inputSchema: z.object({ - url: z.string().describe('URL containing the form'), - fields: z - .array( - z.object({ - selector: z + id: 'fillFormTool', + description: 'Fill out a form on a webpage and optionally submit it.', + inputSchema: z.object({ + url: z.string().describe('URL containing the form'), + fields: z + .array( + z.object({ + selector: z + .string() + .describe('CSS selector for the input field'), + value: z.string().describe('Value to fill in'), + }) + ) + .describe('Form fields to fill'), + submitSelector: z .string() - .describe('CSS selector for the input field'), - value: z.string().describe('Value to fill in'), - }) - ) - .describe('Form fields to fill'), - submitSelector: z - .string() - .optional() - .describe('CSS selector for submit button'), - waitForNavigation: z.boolean().optional().default(false), - }), - outputSchema: z.object({ - success: z.boolean(), - finalUrl: z.string().optional(), - message: z.string().optional(), - }), - execute: async (inputData, context) => { - const abortSignal = context?.abortSignal - const tracingContext = context?.tracingContext - const requestCtx = context?.requestContext as BrowserRequestContext | undefined - const span = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'browser-fill-form', - input: inputData, - metadata: { - 'tool.id': 'browser-fill-form', - 'tool.input.url': inputData.url, - 'tool.input.fieldCount': inputData.fields.length, - 'user.id': requestCtx?.userId, - }, - }) - - await context?.writer?.custom({ - type: 'data-tool-progress', - data: { message: `📝 Filling form on ${inputData.url}` }, - }) - - try { - if (typeof requestCtx?.userId === 'string') { - log.debug('Executing fill form for user', { - userId: requestCtx.userId, + .optional() + .describe('CSS selector for submit button'), + waitForNavigation: z.boolean().optional().default(false), + }), + outputSchema: z.object({ + success: z.boolean(), + finalUrl: z.string().optional(), + message: z.string().optional(), + }), + execute: async (inputData, context) => { + const abortSignal = context?.abortSignal + const tracingContext: TracingContext | undefined = context?.tracingContext + const requestCtx = context?.requestContext as + | BrowserRequestContext + | undefined + const span = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'browser-fill-form', + input: inputData, + metadata: { + 'tool.id': 'browser-fill-form', + 'tool.input.url': inputData.url, + 'tool.input.fieldCount': inputData.fields.length, + 'user.id': requestCtx?.userId, + }, + requestContext: context?.requestContext, }) - } - if (abortSignal?.aborted === true) { - throw new Error('Fill form operation cancelled') - } - const browser = await getBrowser() - const page = await browser.newPage() - await page.goto(inputData.url, { waitUntil: 'domcontentloaded' }) - - for (const field of inputData.fields) { - await page.fill(field.selector, field.value) - } - - if ( - inputData.submitSelector !== undefined && - inputData.submitSelector !== null - ) { + await context?.writer?.custom({ - type: 'data-tool-progress', - data: { message: '📤 Submitting form...' }, + type: 'data-tool-progress', + data: { message: `📝 Filling form on ${inputData.url}` }, }) - if (inputData.waitForNavigation) { - await Promise.all([ - page.waitForNavigation(), - page.click(inputData.submitSelector), - ]) - } else { - await page.click(inputData.submitSelector) + + try { + if (typeof requestCtx?.userId === 'string') { + log.debug('Executing fill form for user', { + userId: requestCtx.userId, + }) + } + if (abortSignal?.aborted === true) { + throw new Error('Fill form operation cancelled') + } + const browser = await getBrowser() + const page = await browser.newPage() + await page.goto(inputData.url, { waitUntil: 'domcontentloaded' }) + + for (const field of inputData.fields) { + await page.fill(field.selector, field.value) + } + + if ( + inputData.submitSelector !== undefined && + inputData.submitSelector !== null + ) { + await context?.writer?.custom({ + type: 'data-tool-progress', + data: { message: '📤 Submitting form...' }, + }) + if (inputData.waitForNavigation) { + await Promise.all([ + page.waitForNavigation(), + page.click(inputData.submitSelector), + ]) + } else { + await page.click(inputData.submitSelector) + } + } + + const finalUrl = page.url() + await page.close() + + await context?.writer?.custom({ + type: 'data-tool-progress', + data: { message: '✅ Form submitted' }, + }) + span?.update({ + output: { finalUrl }, + metadata: { + 'tool.output.success': true, + 'tool.output.finalUrl': finalUrl, + }, + }) + span?.end() + + return { success: true, finalUrl } + } catch (e) { + const errorMsg = e instanceof Error ? e.message : 'Unknown error' + log.error(`Form fill failed: ${errorMsg}`) + span?.error({ + error: e instanceof Error ? e : new Error(errorMsg), + endSpan: true, + }) + return { success: false, message: errorMsg } } - } - - const finalUrl = page.url() - await page.close() - - await context?.writer?.custom({ - type: 'data-tool-progress', - data: { message: '✅ Form submitted' }, - }) - span?.update({ - output: { finalUrl }, - metadata: { - 'tool.output.success': true, - 'tool.output.finalUrl': finalUrl, - }, - }) - span?.end() - - return { success: true, finalUrl } - } catch (e) { - const errorMsg = e instanceof Error ? e.message : 'Unknown error' - log.error(`Form fill failed: ${errorMsg}`) - span?.error({ - error: e instanceof Error ? e : new Error(errorMsg), - endSpan: true, - }) - return { success: false, message: errorMsg } - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Fill form tool input streaming started', { - toolCallId, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputStart', - }) - }, - onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { - log.info('Fill form tool received input chunk', { - toolCallId, - inputTextDelta, - abortSignal: abortSignal?.aborted, - messageCount: messages.length, - hook: 'onInputDelta', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Fill form tool received input', { - toolCallId, - messageCount: messages.length, - url: input.url, - abortSignal: abortSignal?.aborted, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log[output.success ? 'info' : 'warn']('Fill form tool completed', { - toolCallId, - toolName, - success: output.success, - abortSignal: abortSignal?.aborted, - hook: 'onOutput', - }) - }, + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Fill form tool input streaming started', { + toolCallId, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputStart', + }) + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('Fill form tool received input chunk', { + toolCallId, + inputTextDelta, + abortSignal: abortSignal?.aborted, + messageCount: messages.length, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Fill form tool received input', { + toolCallId, + messageCount: messages.length, + url: input.url, + abortSignal: abortSignal?.aborted, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log[output.success ? 'info' : 'warn']('Fill form tool completed', { + toolCallId, + toolName, + success: output.success, + abortSignal: abortSignal?.aborted, + hook: 'onOutput', + }) + }, }) export const googleSearch = createTool({ - id: 'googleSearch', - description: - 'Google Search. Passes the query to Google and returns the search results.', - inputSchema: z.object({ - query: z.string(), - }), - outputSchema: z.object({ - message: z.string(), - }), - execute: async (inputData, context) => { - const abortSignal = context?.abortSignal - const tracingContext = context?.tracingContext - const requestCtx = context?.requestContext as BrowserRequestContext | undefined - const span = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'google-search', - input: inputData, - metadata: { - 'tool.id': 'google-search', - 'tool.input.query': inputData.query, - 'user.id': requestCtx?.userId, - }, - }) - - await context?.writer?.custom({ - type: 'data-tool-progress', - data: { - message: - '🔍 Starting Google search for "' + inputData.query + '"', - }, - }) - - try { - if (typeof requestCtx?.userId === 'string') { - log.debug('Executing Google search for user', { - userId: requestCtx.userId, - }) - } - if (abortSignal?.aborted === true) { - throw new Error('Google search operation cancelled') - } - const browser = await getBrowser() - const page = await browser.newPage() - await page.goto( - `https://www.google.com/search?q=${encodeURIComponent(inputData.query)}` - ) - - await context?.writer?.custom({ - type: 'data-tool-progress', - data: { message: '⏳ Waiting for search results...' }, - }) - - try { - await page.click('button:has-text("Accept all")', { - timeout: 5000, + id: 'googleSearch', + description: + 'Google Search. Passes the query to Google and returns the search results.', + inputSchema: z.object({ + query: z.string(), + }), + outputSchema: z.object({ + message: z.string(), + }), + execute: async (inputData, context) => { + const abortSignal = context?.abortSignal + const tracingContext: TracingContext | undefined = context?.tracingContext + const requestCtx = context?.requestContext as + | BrowserRequestContext + | undefined + const span = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'google-search', + input: inputData, + metadata: { + 'tool.id': 'google-search', + 'tool.input.query': inputData.query, + 'user.id': requestCtx?.userId, + }, + requestContext: context?.requestContext, }) - } catch { - // Cookie dialog didn't appear, continue - } - - await page.waitForSelector('#search') - const text = await page.evaluate(() => { - const links: string[] = [] - const searchResults = document.querySelectorAll('div.g a') - - searchResults.forEach((link) => { - const href = link.getAttribute('href') - if (href?.startsWith('http') === true) { - links.push(href) - } + await context?.writer?.custom({ + type: 'data-tool-progress', + data: { + message: + '🔍 Starting Google search for "' + inputData.query + '"', + }, }) - return links - }) + try { + if (typeof requestCtx?.userId === 'string') { + log.debug('Executing Google search for user', { + userId: requestCtx.userId, + }) + } + if (abortSignal?.aborted === true) { + throw new Error('Google search operation cancelled') + } + const browser = await getBrowser() + const page = await browser.newPage() + await page.goto( + `https://www.google.com/search?q=${encodeURIComponent(inputData.query)}` + ) + + await context?.writer?.custom({ + type: 'data-tool-progress', + data: { message: '⏳ Waiting for search results...' }, + }) + + try { + await page.click('button:has-text("Accept all")', { + timeout: 5000, + }) + } catch { + // Cookie dialog didn't appear, continue + } - await page.close() + await page.waitForSelector('#search') + + const text = await page.evaluate(() => { + const links: string[] = [] + const searchResults = document.querySelectorAll('div.g a') + + searchResults.forEach((link) => { + const href = link.getAttribute('href') + if (href?.startsWith('http') === true) { + links.push(href) + } + }) + + return links + }) + + await page.close() + + if (!text.length) { + await context?.writer?.custom({ + type: 'data-tool-progress', + data: { message: '⚠️ No results found' }, + }) + span?.update({ + output: { message: 'No results' }, + metadata: { + 'tool.output.success': false, + 'tool.output.reason': 'No results', + }, + }) + span?.end() + return { message: 'No results' } + } - if (!text.length) { - await context?.writer?.custom({ - type: 'data-tool-progress', - data: { message: '⚠️ No results found' }, + await context?.writer?.custom({ + type: 'data-tool-progress', + data: { message: '✅ Found ' + text.length + ' results' }, + }) + span?.update({ + output: { resultCount: text.length }, + metadata: { + 'tool.output.success': true, + 'tool.output.resultCount': text.length, + }, + }) + span?.end() + return { message: text.join('\n') } + } catch (e) { + const errorMsg = e instanceof Error ? e.message : 'Unknown error' + log.error(`Google search failed: ${errorMsg}`) + span?.error({ + error: e instanceof Error ? e : new Error(errorMsg), + endSpan: true, + }) + return { message: `Error: ${errorMsg}` } + } + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Google search tool input streaming started', { + toolCallId, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputStart', }) - span?.update({ - output: { message: 'No results' }, - metadata: { - 'tool.output.success': false, - 'tool.output.reason': 'No results', - }, + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('Google search tool received input chunk', { + toolCallId, + inputTextDelta, + abortSignal: abortSignal?.aborted, + messageCount: messages.length, + hook: 'onInputDelta', }) - span?.end() - return { message: 'No results' } - } - - await context?.writer?.custom({ - type: 'data-tool-progress', - data: { message: '✅ Found ' + text.length + ' results' }, - }) - span?.update({ - output: { resultCount: text.length }, - metadata: { - 'tool.output.success': true, - 'tool.output.resultCount': text.length, - }, - }) - span?.end() - return { message: text.join('\n') } - } catch (e) { - const errorMsg = e instanceof Error ? e.message : 'Unknown error' - log.error(`Google search failed: ${errorMsg}`) - span?.error({ - error: e instanceof Error ? e : new Error(errorMsg), - endSpan: true, - }) - return { message: `Error: ${errorMsg}` } - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Google search tool input streaming started', { - toolCallId, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputStart', - }) - }, - onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { - log.info('Google search tool received input chunk', { - toolCallId, - inputTextDelta, - abortSignal: abortSignal?.aborted, - messageCount: messages.length, - hook: 'onInputDelta', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Google search tool received input', { - toolCallId, - messageCount: messages.length, - query: input.query, - abortSignal: abortSignal?.aborted, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - const isSuccess = - !output.message.includes('Error:') && - !output.message.includes('Failed') - log[isSuccess ? 'info' : 'warn']('Google search tool completed', { - toolCallId, - toolName, - success: isSuccess, - abortSignal: abortSignal?.aborted, - hook: 'onOutput', - }) - }, + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Google search tool received input', { + toolCallId, + messageCount: messages.length, + query: input.query, + abortSignal: abortSignal?.aborted, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + const isSuccess = + !output.message.includes('Error:') && + !output.message.includes('Failed') + log[isSuccess ? 'info' : 'warn']('Google search tool completed', { + toolCallId, + toolName, + success: isSuccess, + abortSignal: abortSignal?.aborted, + hook: 'onOutput', + }) + }, }) export const extractTablesTool = createTool({ - id: 'extractTablesTool', - description: - 'Extract all tables from a webpage and return as structured JSON data.', - inputSchema: z.object({ - url: z.string().describe('URL containing tables'), - tableIndex: z - .number() - .optional() - .describe( - 'Specific table index to extract (0-based), extracts all if not specified' - ), - }), - outputSchema: z.object({ - success: z.boolean(), - tables: z - .array( - z.object({ - headers: z.array(z.string()), - rows: z.array(z.array(z.string())), + id: 'extractTablesTool', + description: + 'Extract all tables from a webpage and return as structured JSON data.', + inputSchema: z.object({ + url: z.string().describe('URL containing tables'), + tableIndex: z + .number() + .optional() + .describe( + 'Specific table index to extract (0-based), extracts all if not specified' + ), + }), + outputSchema: z.object({ + success: z.boolean(), + tables: z + .array( + z.object({ + headers: z.array(z.string()), + rows: z.array(z.array(z.string())), + }) + ) + .optional(), + message: z.string().optional(), + }), + execute: async (inputData, context) => { + const abortSignal = context?.abortSignal + const tracingContext: TracingContext | undefined = context?.tracingContext + const requestCtx = context?.requestContext as + | BrowserRequestContext + | undefined + const span = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'browser-extract-tables', + input: inputData, + metadata: { + 'tool.id': 'browser-extract-tables', + 'tool.input.url': inputData.url, + 'user.id': requestCtx?.userId, + }, + requestContext: context?.requestContext, }) - ) - .optional(), - message: z.string().optional(), - }), - execute: async (inputData, context) => { - const abortSignal = context?.abortSignal - const tracingContext = context?.tracingContext - const requestCtx = context?.requestContext as BrowserRequestContext | undefined - const span = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'browser-extract-tables', - input: inputData, - metadata: { - 'tool.id': 'browser-extract-tables', - 'tool.input.url': inputData.url, - 'user.id': requestCtx?.userId, - }, - }) - - await context?.writer?.custom({ - type: 'data-tool-progress', - data: { message: `📊 Extracting tables from ${inputData.url}` }, - }) - - try { - if (typeof requestCtx?.userId === 'string') { - log.debug('Executing table extraction for user', { - userId: requestCtx.userId, + + await context?.writer?.custom({ + type: 'data-tool-progress', + data: { message: `📊 Extracting tables from ${inputData.url}` }, }) - } - if (abortSignal?.aborted === true) { - throw new Error('Table extraction operation cancelled') - } - const browser = await getBrowser() - const page = await browser.newPage() - await page.goto(inputData.url, { waitUntil: 'domcontentloaded' }) - - const tables = await page.evaluate((tableIndex) => { - const allTables = document.querySelectorAll('table') - const result: Array<{ headers: string[]; rows: string[][] }> = - [] - - const tablesToProcess = - tableIndex !== undefined - ? [allTables[tableIndex]].filter(Boolean) - : Array.from(allTables) - - for (const table of tablesToProcess) { - const headers: string[] = [] - const rows: string[][] = [] - - const headerCells = table.querySelectorAll('th') - headerCells.forEach((cell) => - headers.push(cell.textContent?.trim() ?? '') - ) - - const bodyRows = table.querySelectorAll('tbody tr') - bodyRows.forEach((row) => { - const rowData: string[] = [] - row.querySelectorAll('td').forEach((cell) => - rowData.push(cell.textContent?.trim() ?? '') - ) - if (rowData.length > 0) { - rows.push(rowData) - } - }) - result.push({ headers, rows }) + try { + if (typeof requestCtx?.userId === 'string') { + log.debug('Executing table extraction for user', { + userId: requestCtx.userId, + }) + } + if (abortSignal?.aborted === true) { + throw new Error('Table extraction operation cancelled') + } + const browser = await getBrowser() + const page = await browser.newPage() + await page.goto(inputData.url, { waitUntil: 'domcontentloaded' }) + + const tables = await page.evaluate((tableIndex) => { + const allTables = document.querySelectorAll('table') + const result: Array<{ headers: string[]; rows: string[][] }> = + [] + + const tablesToProcess = + tableIndex !== undefined + ? [allTables[tableIndex]].filter(Boolean) + : Array.from(allTables) + + for (const table of tablesToProcess) { + const headers: string[] = [] + const rows: string[][] = [] + + const headerCells = table.querySelectorAll('th') + headerCells.forEach((cell) => + headers.push(cell.textContent?.trim() ?? '') + ) + + const bodyRows = table.querySelectorAll('tbody tr') + bodyRows.forEach((row) => { + const rowData: string[] = [] + row.querySelectorAll('td').forEach((cell) => + rowData.push(cell.textContent?.trim() ?? '') + ) + if (rowData.length > 0) { + rows.push(rowData) + } + }) + + result.push({ headers, rows }) + } + + return result + }, inputData.tableIndex) + + await page.close() + + await context?.writer?.custom({ + type: 'data-tool-progress', + data: { message: `✅ Extracted ${tables.length} table(s)` }, + }) + span?.update({ + output: { tableCount: tables.length }, + metadata: { + 'tool.output.success': true, + 'tool.output.tableCount': tables.length, + }, + }) + span?.end() + + return { success: true, tables } + } catch (e) { + const errorMsg = e instanceof Error ? e.message : 'Unknown error' + log.error(`Table extraction failed: ${errorMsg}`) + span?.error({ + error: e instanceof Error ? e : new Error(errorMsg), + endSpan: true, + }) + return { success: false, message: errorMsg } } - - return result - }, inputData.tableIndex) - - await page.close() - - await context?.writer?.custom({ - type: 'data-tool-progress', - data: { message: `✅ Extracted ${tables.length} table(s)` }, - }) - span?.update({ - output: { tableCount: tables.length }, - metadata: { - 'tool.output.success': true, - 'tool.output.tableCount': tables.length, - }, - }) - span?.end() - - return { success: true, tables } - } catch (e) { - const errorMsg = e instanceof Error ? e.message : 'Unknown error' - log.error(`Table extraction failed: ${errorMsg}`) - span?.error({ - error: e instanceof Error ? e : new Error(errorMsg), - endSpan: true, - }) - return { success: false, message: errorMsg } - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Extract tables tool input streaming started', { - toolCallId, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputStart', - }) - }, - onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { - log.info('Extract tables tool received input chunk', { - toolCallId, - inputTextDelta, - abortSignal: abortSignal?.aborted, - messageCount: messages.length, - hook: 'onInputDelta', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Extract tables tool received input', { - toolCallId, - messageCount: messages.length, - url: input.url, - abortSignal: abortSignal?.aborted, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log[output.success ? 'info' : 'warn']('Extract tables tool completed', { - toolCallId, - toolName, - success: output.success, - abortSignal: abortSignal?.aborted, - hook: 'onOutput', - }) - }, + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Extract tables tool input streaming started', { + toolCallId, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputStart', + }) + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('Extract tables tool received input chunk', { + toolCallId, + inputTextDelta, + abortSignal: abortSignal?.aborted, + messageCount: messages.length, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Extract tables tool received input', { + toolCallId, + messageCount: messages.length, + url: input.url, + abortSignal: abortSignal?.aborted, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log[output.success ? 'info' : 'warn']('Extract tables tool completed', { + toolCallId, + toolName, + success: output.success, + abortSignal: abortSignal?.aborted, + hook: 'onOutput', + }) + }, }) export const monitorPageTool = createTool({ - id: 'monitorPageTool', - description: - 'Monitor a webpage for changes by comparing content at intervals.', - inputSchema: z.object({ - url: z.string().describe('URL to monitor'), - selector: z - .string() - .optional() - .describe('CSS selector to monitor (defaults to body)'), - checkInterval: z - .number() - .optional() - .default(5000) - .describe('Interval in milliseconds between checks'), - maxChecks: z - .number() - .optional() - .default(10) - .describe('Maximum number of checks before stopping'), - }), - outputSchema: z.object({ - success: z.boolean(), - changed: z.boolean(), - previousContent: z.string().optional(), - currentContent: z.string().optional(), - checkCount: z.number(), - message: z.string().optional(), - }), - execute: async (inputData, context) => { - const abortSignal = context?.abortSignal - const tracingContext = context?.tracingContext - const requestCtx = context?.requestContext as BrowserRequestContext | undefined - const span = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'browser-monitor-page', - input: inputData, - metadata: { - 'tool.id': 'browser-monitor-page', - 'tool.input.url': inputData.url, - 'tool.input.selector': inputData.selector, - 'user.id': requestCtx?.userId, - }, - }) - - await context?.writer?.custom({ - type: 'data-tool-progress', - data: { message: `👁️ Monitoring ${inputData.url}` }, - }) - - try { - if (typeof requestCtx?.userId === 'string') { - log.debug('Executing page monitor for user', { - userId: requestCtx.userId, + id: 'monitorPageTool', + description: + 'Monitor a webpage for changes by comparing content at intervals.', + inputSchema: z.object({ + url: z.string().describe('URL to monitor'), + selector: z + .string() + .optional() + .describe('CSS selector to monitor (defaults to body)'), + checkInterval: z + .number() + .optional() + .default(5000) + .describe('Interval in milliseconds between checks'), + maxChecks: z + .number() + .optional() + .default(10) + .describe('Maximum number of checks before stopping'), + }), + outputSchema: z.object({ + success: z.boolean(), + changed: z.boolean(), + previousContent: z.string().optional(), + currentContent: z.string().optional(), + checkCount: z.number(), + message: z.string().optional(), + }), + execute: async (inputData, context) => { + const abortSignal = context?.abortSignal + const tracingContext: TracingContext | undefined = context?.tracingContext + const requestCtx = context?.requestContext as + | BrowserRequestContext + | undefined + const span = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'browser-monitor-page', + input: inputData, + metadata: { + 'tool.id': 'browser-monitor-page', + 'tool.input.url': inputData.url, + 'tool.input.selector': inputData.selector, + 'user.id': requestCtx?.userId, + }, + requestContext: context?.requestContext, }) - } - if (abortSignal?.aborted === true) { - throw new Error('Page monitor operation cancelled') - } - const browser = await getBrowser() - const page = await browser.newPage() - const selector = inputData.selector ?? 'body' - - await page.goto(inputData.url, { waitUntil: 'domcontentloaded' }) - const previousContent = await page.$eval( - selector, - (el) => el.textContent ?? '' - ) - - let checkCount = 0 - let changed = false - let currentContent = previousContent - - while (checkCount < (inputData.maxChecks ?? 10) && !changed) { - await new Promise((resolve) => - setTimeout(resolve, inputData.checkInterval ?? 5000) - ) - await page.reload({ waitUntil: 'domcontentloaded' }) - - currentContent = await page.$eval( - selector, - (el) => el.textContent ?? '' - ) - checkCount++ - if (currentContent !== previousContent) { - changed = true - await context?.writer?.custom({ + await context?.writer?.custom({ type: 'data-tool-progress', - data: { - message: `🔔 Change detected after ${checkCount} checks`, - }, - }) + data: { message: `👁️ Monitoring ${inputData.url}` }, + }) + + try { + if (typeof requestCtx?.userId === 'string') { + log.debug('Executing page monitor for user', { + userId: requestCtx.userId, + }) + } + if (abortSignal?.aborted === true) { + throw new Error('Page monitor operation cancelled') + } + const browser = await getBrowser() + const page = await browser.newPage() + const selector = inputData.selector ?? 'body' + + await page.goto(inputData.url, { waitUntil: 'domcontentloaded' }) + const previousContent = await page.$eval( + selector, + (el) => el.textContent ?? '' + ) + + let checkCount = 0 + let changed = false + let currentContent = previousContent + + while (checkCount < (inputData.maxChecks ?? 10) && !changed) { + await new Promise((resolve) => + setTimeout(resolve, inputData.checkInterval ?? 5000) + ) + await page.reload({ waitUntil: 'domcontentloaded' }) + + currentContent = await page.$eval( + selector, + (el) => el.textContent ?? '' + ) + checkCount++ + + if (currentContent !== previousContent) { + changed = true + await context?.writer?.custom({ + type: 'data-tool-progress', + data: { + message: `🔔 Change detected after ${checkCount} checks`, + }, + }) + } + } + + await page.close() + + span?.update({ + output: { success: true, changed, checkCount }, + metadata: { + 'tool.output.success': true, + 'tool.output.changed': changed, + 'tool.output.checkCount': checkCount, + }, + }) + span?.end() + return { + success: true, + changed, + previousContent, + currentContent, + checkCount, + } + } catch (e) { + const errorMsg = e instanceof Error ? e.message : 'Unknown error' + log.error(`Page monitoring failed: ${errorMsg}`) + span?.error({ + error: e instanceof Error ? e : new Error(errorMsg), + endSpan: true, + }) + return { + success: false, + changed: false, + checkCount: 0, + message: errorMsg, + } } - } - - await page.close() - - span?.update({ - output: { success: true, changed, checkCount }, - metadata: { - 'tool.output.success': true, - 'tool.output.changed': changed, - 'tool.output.checkCount': checkCount, - }, - }) - span?.end() - return { - success: true, - changed, - previousContent, - currentContent, - checkCount, - } - } catch (e) { - const errorMsg = e instanceof Error ? e.message : 'Unknown error' - log.error(`Page monitoring failed: ${errorMsg}`) - span?.error({ - error: e instanceof Error ? e : new Error(errorMsg), - endSpan: true, - }) - return { - success: false, - changed: false, - checkCount: 0, - message: errorMsg, - } - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Monitor page tool input streaming started', { - toolCallId, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputStart', - }) - }, - onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { - log.info('Monitor page tool received input chunk', { - toolCallId, - inputTextDelta, - abortSignal: abortSignal?.aborted, - messageCount: messages.length, - hook: 'onInputDelta', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Monitor page tool received input', { - toolCallId, - messageCount: messages.length, - url: input.url, - abortSignal: abortSignal?.aborted, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log[output.success ? 'info' : 'warn']('Monitor page tool completed', { - toolCallId, - toolName, - success: output.success, - abortSignal: abortSignal?.aborted, - hook: 'onOutput', - }) - }, + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Monitor page tool input streaming started', { + toolCallId, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputStart', + }) + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('Monitor page tool received input chunk', { + toolCallId, + inputTextDelta, + abortSignal: abortSignal?.aborted, + messageCount: messages.length, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Monitor page tool received input', { + toolCallId, + messageCount: messages.length, + url: input.url, + abortSignal: abortSignal?.aborted, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log[output.success ? 'info' : 'warn']('Monitor page tool completed', { + toolCallId, + toolName, + success: output.success, + abortSignal: abortSignal?.aborted, + hook: 'onOutput', + }) + }, }) export type BrowserToolUITool = InferUITool<typeof browserTool> diff --git a/src/mastra/tools/calculator.tool.ts b/src/mastra/tools/calculator.tool.ts index aa8b7605..06d8bb7f 100644 --- a/src/mastra/tools/calculator.tool.ts +++ b/src/mastra/tools/calculator.tool.ts @@ -10,1012 +10,1029 @@ type UserTier = 'free' | 'pro' | 'enterprise' // RequestContext interface for Calculator tool export interface CalculatorToolContext extends RequestContext { - 'user-tier': UserTier - userId?: string - precision?: number - allowComplexExpressions?: boolean - maxExpressionLength?: number + 'user-tier': UserTier + userId?: string + precision?: number + allowComplexExpressions?: boolean + maxExpressionLength?: number } - // Enhanced mathematical functions and constants const MATH_CONSTANTS = { - pi: Math.PI, - e: Math.E, - ln2: Math.LN2, - ln10: Math.LN10, - log2e: Math.LOG2E, - log10e: Math.LOG10E, - sqrt2: Math.SQRT2, - sqrt1_2: Math.SQRT1_2, - phi: (1 + Math.sqrt(5)) / 2, // Golden ratio - tau: 2 * Math.PI, // Tau (2π) + pi: Math.PI, + e: Math.E, + ln2: Math.LN2, + ln10: Math.LN10, + log2e: Math.LOG2E, + log10e: Math.LOG10E, + sqrt2: Math.SQRT2, + sqrt1_2: Math.SQRT1_2, + phi: (1 + Math.sqrt(5)) / 2, // Golden ratio + tau: 2 * Math.PI, // Tau (2π) } const MATH_FUNCTIONS = { - // Basic arithmetic - abs: Math.abs, - ceil: Math.ceil, - floor: Math.floor, - round: Math.round, - trunc: Math.trunc, - sign: Math.sign, - - // Trigonometric functions - sin: Math.sin, - cos: Math.cos, - tan: Math.tan, - asin: Math.asin, - acos: Math.acos, - atan: Math.atan, - atan2: Math.atan2, - - // Hyperbolic functions - sinh: Math.sinh, - cosh: Math.cosh, - tanh: Math.tanh, - asinh: Math.asinh, - acosh: Math.acosh, - atanh: Math.atanh, - - // Exponential and logarithmic - exp: Math.exp, - log: Math.log, - log10: Math.log10, - log2: Math.log2, - pow: Math.pow, - sqrt: Math.sqrt, - cbrt: Math.cbrt, - - // Min/Max - max: Math.max, - min: Math.min, - - // Random (with seed support) - random: Math.random, - - // Special functions - factorial, - fibonacci, - gcd, - lcm, - isPrime, - combinations, - permutations, + // Basic arithmetic + abs: Math.abs, + ceil: Math.ceil, + floor: Math.floor, + round: Math.round, + trunc: Math.trunc, + sign: Math.sign, + + // Trigonometric functions + sin: Math.sin, + cos: Math.cos, + tan: Math.tan, + asin: Math.asin, + acos: Math.acos, + atan: Math.atan, + atan2: Math.atan2, + + // Hyperbolic functions + sinh: Math.sinh, + cosh: Math.cosh, + tanh: Math.tanh, + asinh: Math.asinh, + acosh: Math.acosh, + atanh: Math.atanh, + + // Exponential and logarithmic + exp: Math.exp, + log: Math.log, + log10: Math.log10, + log2: Math.log2, + pow: Math.pow, + sqrt: Math.sqrt, + cbrt: Math.cbrt, + + // Min/Max + max: Math.max, + min: Math.min, + + // Random (with seed support) + random: Math.random, + + // Special functions + factorial, + fibonacci, + gcd, + lcm, + isPrime, + combinations, + permutations, } // Unit conversion factors (to base SI units) const UNIT_CONVERSIONS = { - // Length - mm: 0.001, - cm: 0.01, - dm: 0.1, - m: 1, - km: 1000, - in: 0.0254, - ft: 0.3048, - yd: 0.9144, - mi: 1609.344, - - // Mass - mg: 0.000001, - g: 0.001, - kg: 1, - t: 1000, - oz: 0.0283495, - lb: 0.453592, - st: 6.35029, - - // Temperature (special handling) - celsius: 'celsius', - fahrenheit: 'fahrenheit', - kelvin: 'kelvin', - - // Time - ms: 0.001, - s: 1, - min: 60, - h: 3600, - day: 86400, - - // Digital storage - b: 1, - B: 8, - KB: 8000, - MB: 8000000, - GB: 8000000000, - TB: 8000000000000, + // Length + mm: 0.001, + cm: 0.01, + dm: 0.1, + m: 1, + km: 1000, + in: 0.0254, + ft: 0.3048, + yd: 0.9144, + mi: 1609.344, + + // Mass + mg: 0.000001, + g: 0.001, + kg: 1, + t: 1000, + oz: 0.0283495, + lb: 0.453592, + st: 6.35029, + + // Temperature (special handling) + celsius: 'celsius', + fahrenheit: 'fahrenheit', + kelvin: 'kelvin', + + // Time + ms: 0.001, + s: 1, + min: 60, + h: 3600, + day: 86400, + + // Digital storage + b: 1, + B: 8, + KB: 8000, + MB: 8000000, + GB: 8000000000, + TB: 8000000000000, } // Create a safe evaluation context function createSafeContext() { - return { - ...MATH_CONSTANTS, - ...MATH_FUNCTIONS, - } + return { + ...MATH_CONSTANTS, + ...MATH_FUNCTIONS, + } } // Enhanced expression evaluator with variable support function evaluateExpression( - expression: string, - variables: Record<string, number> = {} + expression: string, + variables: Record<string, number> = {} ): number { - // Remove whitespace - const cleanExpr = expression.replace(/\s+/g, '') - - // Basic security checks - if (/[^0-9+\-*/().\s,a-zA-Z_]/.test(cleanExpr)) { - throw new Error('Invalid characters in expression') - } - - if ( - cleanExpr.includes('__proto__') || - cleanExpr.includes('prototype') || - cleanExpr.includes('constructor') - ) { - throw new Error('Potentially unsafe expression') - } - - // Create function with safe context and variables - const context = { ...createSafeContext(), ...variables } - const func = new Function(...Object.keys(context), `return ${cleanExpr}`) - - try { - const result = func(...Object.values(context)) - if (typeof result !== 'number' || !isFinite(result)) { - throw new Error('Expression did not evaluate to a valid number') + // Remove whitespace + const cleanExpr = expression.replace(/\s+/g, '') + + // Basic security checks + if (/[^0-9+\-*/().\s,a-zA-Z_]/.test(cleanExpr)) { + throw new Error('Invalid characters in expression') + } + + if ( + cleanExpr.includes('__proto__') || + cleanExpr.includes('prototype') || + cleanExpr.includes('constructor') + ) { + throw new Error('Potentially unsafe expression') + } + + // Create function with safe context and variables + const context = { ...createSafeContext(), ...variables } + const func = new Function(...Object.keys(context), `return ${cleanExpr}`) + + try { + const result = func(...Object.values(context)) + if (typeof result !== 'number' || !isFinite(result)) { + throw new Error('Expression did not evaluate to a valid number') + } + return result + } catch (error) { + throw new Error( + `Evaluation failed: ${error instanceof Error ? error.message : 'Unknown error'}` + ) } - return result - } catch (error) { - throw new Error( - `Evaluation failed: ${error instanceof Error ? error.message : 'Unknown error'}` - ) - } } // Unit conversion function function convertUnits(value: number, fromUnit: string, toUnit: string): number { - // Special handling for temperature - if ( - ['celsius', 'fahrenheit', 'kelvin'].includes(fromUnit) && - ['celsius', 'fahrenheit', 'kelvin'].includes(toUnit) - ) { - return convertTemperature(value, fromUnit as 'celsius' | 'fahrenheit' | 'kelvin', toUnit as 'celsius' | 'fahrenheit' | 'kelvin') - } - - // Regular unit conversion - const fromFactor = - UNIT_CONVERSIONS[fromUnit as keyof typeof UNIT_CONVERSIONS] - const toFactor = UNIT_CONVERSIONS[toUnit as keyof typeof UNIT_CONVERSIONS] - - if (typeof fromFactor !== 'number' || typeof toFactor !== 'number') { - throw new Error(`Unsupported unit conversion: ${fromUnit} to ${toUnit}`) - } - - // Convert to base unit, then to target unit - const baseValue = value * fromFactor - return baseValue / toFactor + // Special handling for temperature + if ( + ['celsius', 'fahrenheit', 'kelvin'].includes(fromUnit) && + ['celsius', 'fahrenheit', 'kelvin'].includes(toUnit) + ) { + return convertTemperature( + value, + fromUnit as 'celsius' | 'fahrenheit' | 'kelvin', + toUnit as 'celsius' | 'fahrenheit' | 'kelvin' + ) + } + + // Regular unit conversion + const fromFactor = + UNIT_CONVERSIONS[fromUnit as keyof typeof UNIT_CONVERSIONS] + const toFactor = UNIT_CONVERSIONS[toUnit as keyof typeof UNIT_CONVERSIONS] + + if (typeof fromFactor !== 'number' || typeof toFactor !== 'number') { + throw new Error(`Unsupported unit conversion: ${fromUnit} to ${toUnit}`) + } + + // Convert to base unit, then to target unit + const baseValue = value * fromFactor + return baseValue / toFactor } function convertTemperature( - value: number, - from: 'celsius' | 'fahrenheit' | 'kelvin', - to: 'celsius' | 'fahrenheit' | 'kelvin' + value: number, + from: 'celsius' | 'fahrenheit' | 'kelvin', + to: 'celsius' | 'fahrenheit' | 'kelvin' ): number { - let celsius: number - - // Convert to Celsius first - switch (from) { - case 'celsius': - celsius = value - break - case 'fahrenheit': - celsius = ((value - 32) * 5) / 9 - break - case 'kelvin': - celsius = value - 273.15 - break - default: - throw new Error(`Unsupported temperature unit: ${from}`) - } - - // Convert from Celsius to target - switch (to) { - case 'celsius': - return celsius - case 'fahrenheit': - return (celsius * 9) / 5 + 32 - case 'kelvin': - return celsius + 273.15 - default: - throw new Error(`Unsupported temperature unit: ${to}`) - } + let celsius: number + + // Convert to Celsius first + switch (from) { + case 'celsius': + celsius = value + break + case 'fahrenheit': + celsius = ((value - 32) * 5) / 9 + break + case 'kelvin': + celsius = value - 273.15 + break + default: + throw new Error(`Unsupported temperature unit: ${from}`) + } + + // Convert from Celsius to target + switch (to) { + case 'celsius': + return celsius + case 'fahrenheit': + return (celsius * 9) / 5 + 32 + case 'kelvin': + return celsius + 273.15 + default: + throw new Error(`Unsupported temperature unit: ${to}`) + } } // Matrix operations const MATRIX_OPERATIONS = { - add: (a: number[][], b: number[][]) => { - if (a.length !== b.length || a[0].length !== b[0].length) { - throw new Error('Matrix dimensions must match for addition') - } - return a.map((row, i) => row.map((val, j) => val + b[i][j])) - }, + add: (a: number[][], b: number[][]) => { + if (a.length !== b.length || a[0].length !== b[0].length) { + throw new Error('Matrix dimensions must match for addition') + } + return a.map((row, i) => row.map((val, j) => val + b[i][j])) + }, - subtract: (a: number[][], b: number[][]) => { - if (a.length !== b.length || a[0].length !== b[0].length) { - throw new Error('Matrix dimensions must match for subtraction') - } - return a.map((row, i) => row.map((val, j) => val - b[i][j])) - }, + subtract: (a: number[][], b: number[][]) => { + if (a.length !== b.length || a[0].length !== b[0].length) { + throw new Error('Matrix dimensions must match for subtraction') + } + return a.map((row, i) => row.map((val, j) => val - b[i][j])) + }, - multiply: (a: number[][], b: number[][]) => { - if (a[0].length !== b.length) { - throw new Error('Matrix dimensions incompatible for multiplication') - } - const result = Array(a.length) - .fill(0) - .map(() => Array(b[0].length).fill(0)) - for (let i = 0; i < a.length; i++) { - for (let j = 0; j < b[0].length; j++) { - for (let k = 0; k < b.length; k++) { - result[i][j] += a[i][k] * b[k][j] + multiply: (a: number[][], b: number[][]) => { + if (a[0].length !== b.length) { + throw new Error('Matrix dimensions incompatible for multiplication') } - } - } - return result - }, + const result = Array(a.length) + .fill(0) + .map(() => Array(b[0].length).fill(0)) + for (let i = 0; i < a.length; i++) { + for (let j = 0; j < b[0].length; j++) { + for (let k = 0; k < b.length; k++) { + result[i][j] += a[i][k] * b[k][j] + } + } + } + return result + }, - transpose: (matrix: number[][]) => { - return matrix[0].map((_, colIndex) => - matrix.map((row) => row[colIndex]) - ) - }, + transpose: (matrix: number[][]) => { + return matrix[0].map((_, colIndex) => + matrix.map((row) => row[colIndex]) + ) + }, - determinant: (matrix: number[][]) => { - if (matrix.length !== matrix[0].length) { - throw new Error('Matrix must be square for determinant calculation') - } - return calculateDeterminant(matrix) - }, + determinant: (matrix: number[][]) => { + if (matrix.length !== matrix[0].length) { + throw new Error('Matrix must be square for determinant calculation') + } + return calculateDeterminant(matrix) + }, - inverse: (matrix: number[][]) => { - const det = calculateDeterminant(matrix) - if (det === 0) { - throw new Error('Matrix is singular and cannot be inverted') - } - // Simplified inverse for 2x2 matrices - if (matrix.length === 2 && matrix[0].length === 2) { - const [[a, b], [c, d]] = matrix - return [ - [d / det, -b / det], - [-c / det, a / det], - ] - } - throw new Error('Inverse calculation only supported for 2x2 matrices') - }, + inverse: (matrix: number[][]) => { + const det = calculateDeterminant(matrix) + if (det === 0) { + throw new Error('Matrix is singular and cannot be inverted') + } + // Simplified inverse for 2x2 matrices + if (matrix.length === 2 && matrix[0].length === 2) { + const [[a, b], [c, d]] = matrix + return [ + [d / det, -b / det], + [-c / det, a / det], + ] + } + throw new Error('Inverse calculation only supported for 2x2 matrices') + }, } export const calculatorTool = createTool({ - id: 'calculator', - description: - 'Advanced mathematical calculator with expressions, unit conversions, and matrix operations', - inputSchema: z.object({ - expression: z - .string() - .describe( - 'Mathematical expression to evaluate (supports variables, functions, and complex expressions)' - ), - variables: z - .record(z.string(), z.number()) - .optional() - .describe('Variable definitions for the expression'), - precision: z - .number() - .optional() - .default(6) - .describe('Number of decimal places for the result'), - }), - outputSchema: z.object({ - success: z.boolean(), - result: z.number(), - formattedResult: z.string(), - expression: z.string(), - variables: z.record(z.string(), z.number()).optional(), - message: z.string().optional(), - }), - - execute: async (inputData, context) => { - const writer = context?.writer - const abortSignal = context?.abortSignal - const tracingContext = context?.tracingContext - - const requestCtx = context?.requestContext as CalculatorToolContext | undefined - const precision = requestCtx?.precision ?? 6 - const allowComplexExpressions = requestCtx?.allowComplexExpressions ?? true - const maxExpressionLength = requestCtx?.maxExpressionLength ?? 1000 - - // Create child span for calculator operation - const calculatorSpan = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'calculator-operation', - input: inputData, - metadata: { - 'tool.id': 'calculator', - 'tool.input.expression': inputData.expression, - 'tool.input.precision': precision, - 'user.id': requestCtx?.userId, - }, - }) + id: 'calculator', + description: + 'Advanced mathematical calculator with expressions, unit conversions, and matrix operations', + inputSchema: z.object({ + expression: z + .string() + .describe( + 'Mathematical expression to evaluate (supports variables, functions, and complex expressions)' + ), + variables: z + .record(z.string(), z.number()) + .optional() + .describe('Variable definitions for the expression'), + precision: z + .number() + .optional() + .default(6) + .describe('Number of decimal places for the result'), + }), + outputSchema: z.object({ + success: z.boolean(), + result: z.number(), + formattedResult: z.string(), + expression: z.string(), + variables: z.record(z.string(), z.number()).optional(), + message: z.string().optional(), + }), - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `🔢 Evaluating expression: ${inputData.expression}`, - stage: 'calculator', - }, - id: 'calculator', - }) + execute: async (inputData, context) => { + const writer = context?.writer + const abortSignal = context?.abortSignal + const tracingContext: TracingContext | undefined = context?.tracingContext + + const requestCtx = context?.requestContext as + | CalculatorToolContext + | undefined + const precision = requestCtx?.precision ?? 6 + const allowComplexExpressions = + requestCtx?.allowComplexExpressions ?? true + const maxExpressionLength = requestCtx?.maxExpressionLength ?? 1000 + + // Create child span for calculator operation + const calculatorSpan = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'calculator-operation', + input: inputData, + metadata: { + 'tool.id': 'calculator', + 'tool.input.expression': inputData.expression, + 'tool.input.precision': precision, + 'user.id': requestCtx?.userId, + }, + // Pass requestContext to allow automatic metadata extraction + requestContext: context?.requestContext, + }) - try { - // Validate expression length - if (inputData.expression.length > maxExpressionLength) { - throw new Error( - `Expression too long (max ${maxExpressionLength} characters)` - ) - } - - const vars = (inputData.variables ?? {}) - const result = evaluateExpression( - inputData.expression, - vars - ) - const actualPrecision = inputData.precision || precision - const formattedResult = result.toFixed(actualPrecision) - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: `✅ Result: ${formattedResult}`, - stage: 'calculator', - }, - id: 'calculator', - }) - - // Update span with successful result - calculatorSpan?.update({ - output: { success: true, result, formattedResult }, - metadata: { - 'tool.output.success': true, - 'tool.output.result': result, - 'tool.input.expressionLength': inputData.expression.length, - }, - }) - calculatorSpan?.end() - - return { - success: true, - result, - formattedResult, - expression: inputData.expression, - variables: inputData.variables, - } - } catch (e) { - const errorMsg = e instanceof Error ? e.message : String(e) - log.error(`Calculator evaluation failed: ${errorMsg}`, { - error: errorMsg, - }) - - // Record error in span - calculatorSpan?.error({ - error: e instanceof Error ? e : new Error(errorMsg), - endSpan: true, - }) - - return { - success: false, - result: 0, - formattedResult: '0', - expression: inputData.expression, - variables: inputData.variables, - message: errorMsg, - } - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Calculator tool input streaming started', { - toolCallId, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputStart', - }) - }, - onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { - log.info('Calculator tool received input chunk', { - toolCallId, - inputTextDelta, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputDelta', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Calculator tool received input', { - toolCallId, - messageCount: messages.length, - inputData: { - expression: input.expression, - variablesCount: Object.keys(input.variables ?? {}).length, - }, - abortSignal: abortSignal?.aborted, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('Calculator tool completed', { - toolCallId, - toolName, - outputData: { - success: output.success, - hasResult: output.result !== undefined, - }, - abortSignal: abortSignal?.aborted, - hook: 'onOutput', - }) - }, + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `🔢 Evaluating expression: ${inputData.expression}`, + stage: 'calculator', + }, + id: 'calculator', + }) + + try { + // Validate expression length + if (inputData.expression.length > maxExpressionLength) { + throw new Error( + `Expression too long (max ${maxExpressionLength} characters)` + ) + } + + const vars = inputData.variables ?? {} + const result = evaluateExpression(inputData.expression, vars) + const actualPrecision = inputData.precision || precision + const formattedResult = result.toFixed(actualPrecision) + + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `✅ Result: ${formattedResult}`, + stage: 'calculator', + }, + id: 'calculator', + }) + + // Update span with successful result + calculatorSpan?.update({ + output: { success: true, result, formattedResult }, + metadata: { + 'tool.output.success': true, + 'tool.output.result': result, + 'tool.input.expressionLength': inputData.expression.length, + }, + }) + calculatorSpan?.end() + + return { + success: true, + result, + formattedResult, + expression: inputData.expression, + variables: inputData.variables, + } + } catch (e) { + const errorMsg = e instanceof Error ? e.message : String(e) + log.error(`Calculator evaluation failed: ${errorMsg}`, { + error: errorMsg, + }) + + // Record error in span + calculatorSpan?.error({ + error: e instanceof Error ? e : new Error(errorMsg), + endSpan: true, + }) + + return { + success: false, + result: 0, + formattedResult: '0', + expression: inputData.expression, + variables: inputData.variables, + message: errorMsg, + } + } + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Calculator tool input streaming started', { + toolCallId, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputStart', + }) + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('Calculator tool received input chunk', { + toolCallId, + inputTextDelta, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Calculator tool received input', { + toolCallId, + messageCount: messages.length, + inputData: { + expression: input.expression, + variablesCount: Object.keys(input.variables ?? {}).length, + }, + abortSignal: abortSignal?.aborted, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('Calculator tool completed', { + toolCallId, + toolName, + outputData: { + success: output.success, + hasResult: output.result !== undefined, + }, + abortSignal: abortSignal?.aborted, + hook: 'onOutput', + }) + }, }) export const unitConverterTool = createTool({ - id: 'unit-converter', - description: - 'Convert between different units of measurement (length, mass, temperature, time, digital storage)', - inputSchema: z.object({ - value: z.number().describe('Value to convert'), - fromUnit: z - .string() - .describe('Source unit (e.g., "m", "kg", "celsius", "MB")'), - toUnit: z - .string() - .describe('Target unit (e.g., "ft", "lb", "fahrenheit", "GB")'), - precision: z - .number() - .optional() - .default(4) - .describe('Decimal precision for result'), - }), - outputSchema: z.object({ - success: z.boolean(), - result: z.number(), - formattedResult: z.string(), - value: z.number(), - fromUnit: z.string(), - toUnit: z.string(), - conversionFactor: z.number(), - message: z.string().optional(), - }), - execute: async (inputData, context) => { - const writer = context?.writer - const abortSignal = context?.abortSignal - const tracingContext = context?.tracingContext - const requestCtx = context?.requestContext as CalculatorToolContext | undefined - - // Create child span for unit conversion - const unitConverterSpan = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'unit-conversion', - input: inputData, - metadata: { - 'tool.id': 'unit-converter', - 'tool.input.value': inputData.value, - 'tool.input.fromUnit': inputData.fromUnit, - 'tool.input.toUnit': inputData.toUnit, - 'user.id': requestCtx?.userId, - }, - }) + id: 'unit-converter', + description: + 'Convert between different units of measurement (length, mass, temperature, time, digital storage)', + inputSchema: z.object({ + value: z.number().describe('Value to convert'), + fromUnit: z + .string() + .describe('Source unit (e.g., "m", "kg", "celsius", "MB")'), + toUnit: z + .string() + .describe('Target unit (e.g., "ft", "lb", "fahrenheit", "GB")'), + precision: z + .number() + .optional() + .default(4) + .describe('Decimal precision for result'), + }), + outputSchema: z.object({ + success: z.boolean(), + result: z.number(), + formattedResult: z.string(), + value: z.number(), + fromUnit: z.string(), + toUnit: z.string(), + conversionFactor: z.number(), + message: z.string().optional(), + }), + execute: async (inputData, context) => { + const writer = context?.writer + const abortSignal = context?.abortSignal + const tracingContext: TracingContext | undefined = context?.tracingContext + const requestCtx = context?.requestContext as + | CalculatorToolContext + | undefined + + // Create child span for unit conversion + const unitConverterSpan = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'unit-conversion', + input: inputData, + metadata: { + 'tool.id': 'unit-converter', + 'tool.input.value': inputData.value, + 'tool.input.fromUnit': inputData.fromUnit, + 'tool.input.toUnit': inputData.toUnit, + 'user.id': requestCtx?.userId, + }, + // Pass requestContext to allow automatic metadata extraction + requestContext: context?.requestContext, + }) - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `🔄 Converting ${inputData.value} ${inputData.fromUnit} to ${inputData.toUnit}`, - stage: 'unit-converter', - }, - id: 'unit-converter', - }) + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `🔄 Converting ${inputData.value} ${inputData.fromUnit} to ${inputData.toUnit}`, + stage: 'unit-converter', + }, + id: 'unit-converter', + }) - try { - if (typeof requestCtx?.userId === 'string') { - log.debug('Executing unit conversion for user', { - userId: requestCtx.userId, + try { + if (typeof requestCtx?.userId === 'string') { + log.debug('Executing unit conversion for user', { + userId: requestCtx.userId, + }) + } + if (abortSignal?.aborted === true) { + throw new Error('Unit conversion operation cancelled') + } + const result = convertUnits( + inputData.value, + inputData.fromUnit, + inputData.toUnit + ) + const formattedResult = result.toFixed(inputData.precision) + + // Calculate conversion factor + const fromFactor = + UNIT_CONVERSIONS[ + inputData.fromUnit as keyof typeof UNIT_CONVERSIONS + ] + const toFactor = + UNIT_CONVERSIONS[ + inputData.toUnit as keyof typeof UNIT_CONVERSIONS + ] + const conversionFactor = + typeof fromFactor === 'number' && typeof toFactor === 'number' + ? toFactor / fromFactor + : 1 + + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `✅ Converted: ${formattedResult} ${inputData.toUnit}`, + stage: 'unit-converter', + }, + id: 'unit-converter', + }) + + // Update span with successful result + unitConverterSpan?.update({ + output: { success: true, result, formattedResult }, + metadata: { + 'tool.output.success': true, + 'tool.output.result': result, + 'tool.input.value': inputData.value, + }, + }) + unitConverterSpan?.end() + + return { + success: true, + result, + formattedResult, + value: inputData.value, + fromUnit: inputData.fromUnit, + toUnit: inputData.toUnit, + conversionFactor, + } + } catch (e) { + const errorMsg = e instanceof Error ? e.message : String(e) + log.error(`Unit conversion failed: ${errorMsg}`, { + error: errorMsg, + }) + + // Record error in span + unitConverterSpan?.error({ + error: e instanceof Error ? e : new Error(errorMsg), + endSpan: true, + }) + + return { + success: false, + result: 0, + formattedResult: '0', + value: inputData.value, + fromUnit: inputData.fromUnit, + toUnit: inputData.toUnit, + conversionFactor: 1, + message: errorMsg, + } + } + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Unit Converter tool input streaming started', { + toolCallId, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputStart', }) - } - if (abortSignal?.aborted === true) { - throw new Error('Unit conversion operation cancelled') - } - const result = convertUnits( - inputData.value, - inputData.fromUnit, - inputData.toUnit - ) - const formattedResult = result.toFixed(inputData.precision) - - // Calculate conversion factor - const fromFactor = - UNIT_CONVERSIONS[ - inputData.fromUnit as keyof typeof UNIT_CONVERSIONS - ] - const toFactor = - UNIT_CONVERSIONS[ - inputData.toUnit as keyof typeof UNIT_CONVERSIONS - ] - const conversionFactor = - typeof fromFactor === 'number' && typeof toFactor === 'number' - ? toFactor / fromFactor - : 1 - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: `✅ Converted: ${formattedResult} ${inputData.toUnit}`, - stage: 'unit-converter', - }, - id: 'unit-converter', - }) - - // Update span with successful result - unitConverterSpan?.update({ - output: { success: true, result, formattedResult }, - metadata: { - 'tool.output.success': true, - 'tool.output.result': result, - 'tool.input.value': inputData.value, - }, - }) - unitConverterSpan?.end() - - return { - success: true, - result, - formattedResult, - value: inputData.value, - fromUnit: inputData.fromUnit, - toUnit: inputData.toUnit, - conversionFactor, - } - } catch (e) { - const errorMsg = e instanceof Error ? e.message : String(e) - log.error(`Unit conversion failed: ${errorMsg}`, { - error: errorMsg, - }) - - // Record error in span - unitConverterSpan?.error({ - error: e instanceof Error ? e : new Error(errorMsg), - endSpan: true, - }) - - return { - success: false, - result: 0, - formattedResult: '0', - value: inputData.value, - fromUnit: inputData.fromUnit, - toUnit: inputData.toUnit, - conversionFactor: 1, - message: errorMsg, - } - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Unit Converter tool input streaming started', { - toolCallId, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputStart', - }) - }, - onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { - log.info('Unit Converter tool received input chunk', { - toolCallId, - inputTextDelta, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputDelta', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Unit Converter tool received input', { - toolCallId, - messageCount: messages.length, - inputData: { - value: input.value, - fromUnit: input.fromUnit, - toUnit: input.toUnit, - }, - abortSignal: abortSignal?.aborted, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('Unit Converter tool completed', { - toolCallId, - toolName, - outputData: { - success: output.success, - result: output.formattedResult, - }, - abortSignal: abortSignal?.aborted, - hook: 'onOutput', - }) - }, + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('Unit Converter tool received input chunk', { + toolCallId, + inputTextDelta, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Unit Converter tool received input', { + toolCallId, + messageCount: messages.length, + inputData: { + value: input.value, + fromUnit: input.fromUnit, + toUnit: input.toUnit, + }, + abortSignal: abortSignal?.aborted, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('Unit Converter tool completed', { + toolCallId, + toolName, + outputData: { + success: output.success, + result: output.formattedResult, + }, + abortSignal: abortSignal?.aborted, + hook: 'onOutput', + }) + }, }) export const matrixCalculatorTool = createTool({ - id: 'matrix-calculator', - description: - 'Perform matrix operations (add, subtract, multiply, transpose, determinant, inverse)', - inputSchema: z.object({ - operation: z - .enum([ - 'add', - 'subtract', - 'multiply', - 'transpose', - 'determinant', - 'inverse', - ]) - .describe('Matrix operation to perform'), - matrixA: z - .array(z.array(z.number())) - .describe('First matrix (or only matrix for unary operations)'), - matrixB: z - .array(z.array(z.number())) - .optional() - .describe('Second matrix (required for binary operations)'), - }), - outputSchema: z.object({ - success: z.boolean(), - result: z.union([z.array(z.array(z.number())), z.number()]), - operation: z.string(), - matrixA: z.array(z.array(z.number())), - matrixB: z.array(z.array(z.number())).optional(), - dimensions: z.object({ - a: z.array(z.number()), - b: z.array(z.number()).optional(), - result: z.array(z.number()), + id: 'matrix-calculator', + description: + 'Perform matrix operations (add, subtract, multiply, transpose, determinant, inverse)', + inputSchema: z.object({ + operation: z + .enum([ + 'add', + 'subtract', + 'multiply', + 'transpose', + 'determinant', + 'inverse', + ]) + .describe('Matrix operation to perform'), + matrixA: z + .array(z.array(z.number())) + .describe('First matrix (or only matrix for unary operations)'), + matrixB: z + .array(z.array(z.number())) + .optional() + .describe('Second matrix (required for binary operations)'), }), - message: z.string().optional(), - }), - execute: async (inputData, context) => { - const writer = context?.writer - const abortSignal = context?.abortSignal - const tracingContext = context?.tracingContext - const requestCtx = context?.requestContext as CalculatorToolContext | undefined - - // Create child span for matrix calculation - const matrixCalculatorSpan = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'matrix-calculation', - input: inputData, - metadata: { - 'tool.id': 'matrix-calculator', - 'tool.input.operation': inputData.operation, - 'tool.input.matrixASize': inputData.matrixA.length, - 'user.id': requestCtx?.userId, - }, - }) - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `📐 Performing matrix ${inputData.operation}...`, - stage: 'matrix-calculator', - }, - id: 'matrix-calculator', - }) + outputSchema: z.object({ + success: z.boolean(), + result: z.union([z.array(z.array(z.number())), z.number()]), + operation: z.string(), + matrixA: z.array(z.array(z.number())), + matrixB: z.array(z.array(z.number())).optional(), + dimensions: z.object({ + a: z.array(z.number()), + b: z.array(z.number()).optional(), + result: z.array(z.number()), + }), + message: z.string().optional(), + }), + execute: async (inputData, context) => { + const writer = context?.writer + const abortSignal = context?.abortSignal + const tracingContext: TracingContext | undefined = context?.tracingContext + const requestCtx = context?.requestContext as + | CalculatorToolContext + | undefined + + // Create child span for matrix calculation + const matrixCalculatorSpan = + tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'matrix-calculation', + input: inputData, + metadata: { + 'tool.id': 'matrix-calculator', + 'tool.input.operation': inputData.operation, + 'tool.input.matrixASize': inputData.matrixA.length, + 'user.id': requestCtx?.userId, + }, + // Pass requestContext to allow automatic metadata extraction + requestContext: context?.requestContext, + }) + + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `📐 Performing matrix ${inputData.operation}...`, + stage: 'matrix-calculator', + }, + id: 'matrix-calculator', + }) - try { - if (typeof requestCtx?.userId === 'string') { - log.debug('Executing matrix calculation for user', { - userId: requestCtx.userId, + try { + if (typeof requestCtx?.userId === 'string') { + log.debug('Executing matrix calculation for user', { + userId: requestCtx.userId, + }) + } + if (abortSignal?.aborted === true) { + throw new Error('Matrix calculation operation cancelled') + } + let result: number[][] | number + + switch (inputData.operation) { + case 'add': + if (!inputData.matrixB) { + throw new Error('Matrix B required for addition') + } + result = MATRIX_OPERATIONS.add( + inputData.matrixA, + inputData.matrixB + ) + break + case 'subtract': + if (!inputData.matrixB) { + throw new Error('Matrix B required for subtraction') + } + result = MATRIX_OPERATIONS.subtract( + inputData.matrixA, + inputData.matrixB + ) + break + case 'multiply': + if (!inputData.matrixB) { + throw new Error('Matrix B required for multiplication') + } + result = MATRIX_OPERATIONS.multiply( + inputData.matrixA, + inputData.matrixB + ) + break + case 'transpose': + result = MATRIX_OPERATIONS.transpose(inputData.matrixA) + break + case 'determinant': + result = MATRIX_OPERATIONS.determinant(inputData.matrixA) + break + case 'inverse': + result = MATRIX_OPERATIONS.inverse(inputData.matrixA) + break + default: + throw new Error( + `Unknown matrix operation: ${inputData.operation}` + ) + } + + const dimensions = { + a: [ + inputData.matrixA.length, + inputData.matrixA[0]?.length || 0, + ], + b: inputData.matrixB + ? [ + inputData.matrixB.length, + inputData.matrixB[0]?.length || 0, + ] + : undefined, + result: + Array.isArray(result) && Array.isArray(result[0]) + ? [result.length, result[0].length] + : [1, 1], + } + + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `✅ Matrix ${inputData.operation} completed`, + stage: 'matrix-calculator', + }, + id: 'matrix-calculator', + }) + + // Update span with successful result + matrixCalculatorSpan?.update({ + output: { success: true, operation: inputData.operation }, + metadata: { + 'tool.output.success': true, + 'tool.input.operation': inputData.operation, + }, + }) + matrixCalculatorSpan?.end() + + return { + success: true, + result, + operation: inputData.operation, + matrixA: inputData.matrixA, + matrixB: inputData.matrixB, + dimensions, + } + } catch (e) { + const errorMsg = e instanceof Error ? e.message : String(e) + log.error(`Matrix operation failed: ${errorMsg}`, { + error: errorMsg, + }) + + // Record error in span + matrixCalculatorSpan?.error({ + error: e instanceof Error ? e : new Error(errorMsg), + endSpan: true, + }) + + return { + success: false, + result: [], + operation: inputData.operation, + matrixA: inputData.matrixA, + matrixB: inputData.matrixB, + dimensions: { + a: [ + inputData.matrixA.length, + inputData.matrixA[0]?.length || 0, + ], + b: inputData.matrixB + ? [ + inputData.matrixB.length, + inputData.matrixB[0]?.length || 0, + ] + : undefined, + result: [0, 0], + }, + message: errorMsg, + } + } + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Matrix Calculator tool input streaming started', { + toolCallId, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputStart', }) - } - if (abortSignal?.aborted === true) { - throw new Error('Matrix calculation operation cancelled') - } - let result: number[][] | number - - switch (inputData.operation) { - case 'add': - if (!inputData.matrixB) { - throw new Error('Matrix B required for addition') - } - result = MATRIX_OPERATIONS.add( - inputData.matrixA, - inputData.matrixB - ) - break - case 'subtract': - if (!inputData.matrixB) { - throw new Error('Matrix B required for subtraction') - } - result = MATRIX_OPERATIONS.subtract( - inputData.matrixA, - inputData.matrixB - ) - break - case 'multiply': - if (!inputData.matrixB) { - throw new Error('Matrix B required for multiplication') - } - result = MATRIX_OPERATIONS.multiply( - inputData.matrixA, - inputData.matrixB - ) - break - case 'transpose': - result = MATRIX_OPERATIONS.transpose(inputData.matrixA) - break - case 'determinant': - result = MATRIX_OPERATIONS.determinant(inputData.matrixA) - break - case 'inverse': - result = MATRIX_OPERATIONS.inverse(inputData.matrixA) - break - default: - throw new Error( - `Unknown matrix operation: ${inputData.operation}` - ) - } - - const dimensions = { - a: [ - inputData.matrixA.length, - inputData.matrixA[0]?.length || 0, - ], - b: inputData.matrixB - ? [ - inputData.matrixB.length, - inputData.matrixB[0]?.length || 0, - ] - : undefined, - result: - Array.isArray(result) && Array.isArray(result[0]) - ? [result.length, result[0].length] - : [1, 1], - } - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: `✅ Matrix ${inputData.operation} completed`, - stage: 'matrix-calculator', - }, - id: 'matrix-calculator', - }) - - // Update span with successful result - matrixCalculatorSpan?.update({ - output: { success: true, operation: inputData.operation }, - metadata: { - 'tool.output.success': true, - 'tool.input.operation': inputData.operation, - }, - }) - matrixCalculatorSpan?.end() - - return { - success: true, - result, - operation: inputData.operation, - matrixA: inputData.matrixA, - matrixB: inputData.matrixB, - dimensions, - } - } catch (e) { - const errorMsg = e instanceof Error ? e.message : String(e) - log.error(`Matrix operation failed: ${errorMsg}`, { - error: errorMsg, - }) - - // Record error in span - matrixCalculatorSpan?.error({ - error: e instanceof Error ? e : new Error(errorMsg), - endSpan: true, - }) - - return { - success: false, - result: [], - operation: inputData.operation, - matrixA: inputData.matrixA, - matrixB: inputData.matrixB, - dimensions: { - a: [ - inputData.matrixA.length, - inputData.matrixA[0]?.length || 0, - ], - b: inputData.matrixB - ? [ - inputData.matrixB.length, - inputData.matrixB[0]?.length || 0, - ] - : undefined, - result: [0, 0], - }, - message: errorMsg, - } - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Matrix Calculator tool input streaming started', { - toolCallId, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputStart', - }) - }, - onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { - log.info('Matrix Calculator tool received input chunk', { - toolCallId, - inputTextDelta, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputDelta', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Matrix Calculator tool received input', { - toolCallId, - messageCount: messages.length, - inputData: { - operation: input.operation, - matrixADims: [input.matrixA.length, input.matrixA[0]?.length || 0], - }, - abortSignal: abortSignal?.aborted, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('Matrix Calculator tool completed', { - toolCallId, - toolName, - outputData: { - success: output.success, - operation: output.operation, - }, - abortSignal: abortSignal?.aborted, - hook: 'onOutput', - }) - }, + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('Matrix Calculator tool received input chunk', { + toolCallId, + inputTextDelta, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Matrix Calculator tool received input', { + toolCallId, + messageCount: messages.length, + inputData: { + operation: input.operation, + matrixADims: [ + input.matrixA.length, + input.matrixA[0]?.length || 0, + ], + }, + abortSignal: abortSignal?.aborted, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('Matrix Calculator tool completed', { + toolCallId, + toolName, + outputData: { + success: output.success, + operation: output.operation, + }, + abortSignal: abortSignal?.aborted, + hook: 'onOutput', + }) + }, }) // Helper functions function factorial(n: number): number { - if (n < 0 || !Number.isInteger(n)) { - throw new Error('Factorial requires a non-negative integer') - } - if (n === 0 || n === 1) { - return 1 - } - let result = 1 - for (let i = 2; i <= n; i++) { - result *= i - } - return result + if (n < 0 || !Number.isInteger(n)) { + throw new Error('Factorial requires a non-negative integer') + } + if (n === 0 || n === 1) { + return 1 + } + let result = 1 + for (let i = 2; i <= n; i++) { + result *= i + } + return result } function fibonacci(n: number): number { - if (n < 0 || !Number.isInteger(n)) { - throw new Error('Fibonacci requires a non-negative integer') - } - if (n === 0) { - return 0 - } - if (n === 1) { - return 1 - } - let a = 0, - b = 1 - for (let i = 2; i <= n; i++) { - const temp = a + b - a = b - b = temp - } - return b + if (n < 0 || !Number.isInteger(n)) { + throw new Error('Fibonacci requires a non-negative integer') + } + if (n === 0) { + return 0 + } + if (n === 1) { + return 1 + } + let a = 0, + b = 1 + for (let i = 2; i <= n; i++) { + const temp = a + b + a = b + b = temp + } + return b } function gcd(...numbers: number[]): number { - if (numbers.length < 2) { - throw new Error('GCD requires at least two numbers') - } - return numbers.reduce((a, b) => { - a = Math.abs(a) - b = Math.abs(b) - while (b !== 0) { - const temp = b - b = a % b - a = temp + if (numbers.length < 2) { + throw new Error('GCD requires at least two numbers') } - return a - }) + return numbers.reduce((a, b) => { + a = Math.abs(a) + b = Math.abs(b) + while (b !== 0) { + const temp = b + b = a % b + a = temp + } + return a + }) } function lcm(...numbers: number[]): number { - if (numbers.length < 2) { - throw new Error('LCM requires at least two numbers') - } - return numbers.reduce((a, b) => Math.abs(a * b) / gcd(a, b)) + if (numbers.length < 2) { + throw new Error('LCM requires at least two numbers') + } + return numbers.reduce((a, b) => Math.abs(a * b) / gcd(a, b)) } function isPrime(n: number): boolean { - if (!Number.isInteger(n)) { - throw new Error('Prime check requires an integer') - } - if (n <= 1) { - return false - } - if (n <= 3) { - return true - } - if (n % 2 === 0 || n % 3 === 0) { - return false - } - for (let i = 5; i * i <= n; i += 6) { - if (n % i === 0 || n % (i + 2) === 0) { - return false + if (!Number.isInteger(n)) { + throw new Error('Prime check requires an integer') + } + if (n <= 1) { + return false + } + if (n <= 3) { + return true + } + if (n % 2 === 0 || n % 3 === 0) { + return false + } + for (let i = 5; i * i <= n; i += 6) { + if (n % i === 0 || n % (i + 2) === 0) { + return false + } } - } - return true + return true } function combinations(n: number, k: number): number { - if ( - !Number.isInteger(n) || - !Number.isInteger(k) || - n < 0 || - k < 0 || - k > n - ) { - throw new Error('Invalid parameters for combinations') - } - return factorial(n) / (factorial(k) * factorial(n - k)) + if ( + !Number.isInteger(n) || + !Number.isInteger(k) || + n < 0 || + k < 0 || + k > n + ) { + throw new Error('Invalid parameters for combinations') + } + return factorial(n) / (factorial(k) * factorial(n - k)) } function permutations(n: number, k: number): number { - if ( - !Number.isInteger(n) || - !Number.isInteger(k) || - n < 0 || - k < 0 || - k > n - ) { - throw new Error('Invalid parameters for permutations') - } - return factorial(n) / factorial(n - k) + if ( + !Number.isInteger(n) || + !Number.isInteger(k) || + n < 0 || + k < 0 || + k > n + ) { + throw new Error('Invalid parameters for permutations') + } + return factorial(n) / factorial(n - k) } function calculateDeterminant(matrix: number[][]): number { - const n = matrix.length - if (n === 1) { - return matrix[0][0] - } - if (n === 2) { - return matrix[0][0] * matrix[1][1] - matrix[0][1] * matrix[1][0] - } - - let det = 0 - for (let i = 0; i < n; i++) { - const subMatrix = matrix - .slice(1) - .map((row) => row.filter((_, j) => j !== i)) - det += Math.pow(-1, i) * matrix[0][i] * calculateDeterminant(subMatrix) - } - return det + const n = matrix.length + if (n === 1) { + return matrix[0][0] + } + if (n === 2) { + return matrix[0][0] * matrix[1][1] - matrix[0][1] * matrix[1][0] + } + + let det = 0 + for (let i = 0; i < n; i++) { + const subMatrix = matrix + .slice(1) + .map((row) => row.filter((_, j) => j !== i)) + det += Math.pow(-1, i) * matrix[0][i] * calculateDeterminant(subMatrix) + } + return det } export type CalculatorUITool = InferUITool<typeof calculatorTool> diff --git a/src/mastra/tools/calendar-tool.ts b/src/mastra/tools/calendar-tool.ts index ec5e5904..e65a251a 100644 --- a/src/mastra/tools/calendar-tool.ts +++ b/src/mastra/tools/calendar-tool.ts @@ -8,7 +8,7 @@ import { log } from '../config/logger' import type { RequestContext } from '@mastra/core/request-context' export interface CalendarRequestContext extends RequestContext { - userId?: string; + userId?: string } interface CalendarEvent { @@ -174,8 +174,10 @@ export const listEvents = createTool({ }) }, execute: async (inputData, context) => { - const tracingContext = context?.tracingContext - const requestCtx = context?.requestContext as CalendarRequestContext | undefined + const tracingContext: TracingContext | undefined = context?.tracingContext + const requestCtx = context?.requestContext as + | CalendarRequestContext + | undefined const span = tracingContext?.currentSpan?.createChildSpan({ type: SpanType.TOOL_CALL, name: 'list-calendar-events', @@ -185,9 +187,12 @@ export const listEvents = createTool({ 'tool.input.startDate': inputData.startDate, 'user.id': requestCtx?.userId, }, + requestContext: context?.requestContext, }) - log.debug('Executing calendar list events for user', { userId: requestCtx?.userId }) + log.debug('Executing calendar list events for user', { + userId: requestCtx?.userId, + }) await context?.writer?.custom({ type: 'data-tool-progress', @@ -263,8 +268,10 @@ export const getTodayEvents = createTool({ count: z.number(), }), execute: async (inputData, context) => { - const tracingContext = context?.tracingContext - const requestCtx = context?.requestContext as CalendarRequestContext | undefined + const tracingContext: TracingContext | undefined = context?.tracingContext + const requestCtx = context?.requestContext as + | CalendarRequestContext + | undefined const span = tracingContext?.currentSpan?.createChildSpan({ type: SpanType.TOOL_CALL, name: 'get-today-events', @@ -273,9 +280,12 @@ export const getTodayEvents = createTool({ 'tool.id': 'get-today-events', 'user.id': requestCtx?.userId, }, + requestContext: context?.requestContext, }) - log.debug('Executing get today events for user', { userId: requestCtx?.userId }) + log.debug('Executing get today events for user', { + userId: requestCtx?.userId, + }) await context?.writer?.write({ type: 'progress', @@ -390,8 +400,10 @@ export const getUpcomingEvents = createTool({ count: z.number(), }), execute: async (inputData, context) => { - const tracingContext = context?.tracingContext - const requestCtx = context?.requestContext as CalendarRequestContext | undefined + const tracingContext: TracingContext | undefined = context?.tracingContext + const requestCtx = context?.requestContext as + | CalendarRequestContext + | undefined const span = tracingContext?.currentSpan?.createChildSpan({ type: SpanType.TOOL_CALL, name: 'get-upcoming-events', @@ -402,9 +414,12 @@ export const getUpcomingEvents = createTool({ 'tool.input.limit': inputData.limit, 'user.id': requestCtx?.userId, }, + requestContext: context?.requestContext, }) - log.debug('Executing get upcoming events for user', { userId: requestCtx?.userId }) + log.debug('Executing get upcoming events for user', { + userId: requestCtx?.userId, + }) await context?.writer?.write({ type: 'progress', @@ -543,8 +558,10 @@ export const findFreeSlots = createTool({ ), }), execute: async (inputData, context) => { - const tracingContext = context?.tracingContext - const requestCtx = context?.requestContext as CalendarRequestContext | undefined + const tracingContext: TracingContext | undefined = context?.tracingContext + const requestCtx = context?.requestContext as + | CalendarRequestContext + | undefined const span = tracingContext?.currentSpan?.createChildSpan({ type: SpanType.TOOL_CALL, name: 'find-free-slots', @@ -554,9 +571,12 @@ export const findFreeSlots = createTool({ 'tool.input.date': inputData.date, 'user.id': requestCtx?.userId, }, + requestContext: context?.requestContext, }) - log.debug('Executing find free slots for user', { userId: requestCtx?.userId }) + log.debug('Executing find free slots for user', { + userId: requestCtx?.userId, + }) await context?.writer?.write({ type: 'progress', diff --git a/src/mastra/tools/chartjs.tool.ts b/src/mastra/tools/chartjs.tool.ts index f1803d21..5555a2a7 100644 --- a/src/mastra/tools/chartjs.tool.ts +++ b/src/mastra/tools/chartjs.tool.ts @@ -56,18 +56,20 @@ export const chartJsTool = createTool({ type: z.string(), data: z.object({ labels: z.array(z.string()), - datasets: z.array(z.object({ - label: z.string().optional(), - data: z.array(z.union([z.number(), z.null()])), - borderColor: z.string().optional(), - backgroundColor: z.string().optional(), - type: z.string().optional(), - yAxisID: z.string().optional(), - order: z.number().optional(), - borderWidth: z.number().optional(), - pointRadius: z.number().optional(), - fill: z.union([z.boolean(), z.string()]).optional(), - })), + datasets: z.array( + z.object({ + label: z.string().optional(), + data: z.array(z.union([z.number(), z.null()])), + borderColor: z.string().optional(), + backgroundColor: z.string().optional(), + type: z.string().optional(), + yAxisID: z.string().optional(), + order: z.number().optional(), + borderWidth: z.number().optional(), + pointRadius: z.number().optional(), + fill: z.union([z.boolean(), z.string()]).optional(), + }) + ), }), options: z.record(z.string(), z.unknown()), }), @@ -86,7 +88,7 @@ export const chartJsTool = createTool({ }) // Tracing: create a TOOL_CALL span for this tool execution - const tracingContext = context?.tracingContext + const tracingContext: TracingContext | undefined = context?.tracingContext const abortSignal = context?.abortSignal if (abortSignal?.aborted === true) { @@ -96,7 +98,12 @@ export const chartJsTool = createTool({ const toolSpan = tracingContext?.currentSpan?.createChildSpan({ type: SpanType.TOOL_CALL, name: 'chartjs-generator', - input: { dataCount: data.length, indicatorsCount: indicators.length, chartType }, + input: { + dataCount: data.length, + indicatorsCount: indicators.length, + chartType, + }, + requestContext: context?.requestContext, metadata: { 'tool.id': 'chartjs-generator', 'tool.input.dataCount': data.length, @@ -106,7 +113,10 @@ export const chartJsTool = createTool({ const startTime = Date.now() // Log tool execution start - logToolExecution('chartjs-generator', { dataCount: data.length, indicatorsCount: indicators.length }) + logToolExecution('chartjs-generator', { + dataCount: data.length, + indicatorsCount: indicators.length, + }) const labels = data.map((d) => d.date) const closePrices = data.map((d) => d.close) @@ -137,11 +147,13 @@ export const chartJsTool = createTool({ order: 1, }) - let config: { - type: string - data: { labels: string[]; datasets: ChartJsDataset[] } - options: Record<string, unknown> - } | undefined + let config: + | { + type: string + data: { labels: string[]; datasets: ChartJsDataset[] } + options: Record<string, unknown> + } + | undefined try { // Calculate indicators @@ -153,261 +165,289 @@ export const chartJsTool = createTool({ try { switch (ind.type) { case 'SMA': - indicatorData = SMA.calculate({ - period: ind.period, - values: closePrices, - }) - label = `SMA (${ind.period})` - // Pad beginning with nulls to match length - indicatorData = Array( - closePrices.length - indicatorData.length - ) - .fill(null) - .concat(indicatorData) - datasets.push({ - label, - data: indicatorData, - borderColor: color, - borderWidth: 1.5, - pointRadius: 0, - fill: false, - yAxisID: 'y', - }) - break - case 'EMA': - indicatorData = EMA.calculate({ - period: ind.period, - values: closePrices, - }) - label = `EMA (${ind.period})` - indicatorData = Array( - closePrices.length - indicatorData.length - ) - .fill(null) - .concat(indicatorData) - datasets.push({ - label, - data: indicatorData, - borderColor: color, - borderWidth: 1.5, - pointRadius: 0, - fill: false, - yAxisID: 'y', - }) - break - case 'RSI': - indicatorData = RSI.calculate({ - period: ind.period, - values: closePrices, - }) - label = `RSI (${ind.period})` - indicatorData = Array( - closePrices.length - indicatorData.length - ) - .fill(null) - .concat(indicatorData) - datasets.push({ - label, - data: indicatorData, - borderColor: color, - borderWidth: 1.5, - pointRadius: 0, - fill: false, - yAxisID: 'y1', // Separate axis - type: 'line', - }) - break - case 'MACD': { - const macdResult = MACD.calculate({ - fastPeriod: ind.fastPeriod ?? 12, - slowPeriod: ind.slowPeriod ?? 26, - signalPeriod: ind.signalPeriod ?? 9, - values: closePrices, - SimpleMAOscillator: false, - SimpleMASignal: false - }) - const macd = macdResult.map((m) => m.MACD) - const signal = macdResult.map((m) => m.signal) - const histogram = macdResult.map((m) => m.histogram) - const padding = Array( - closePrices.length - macd.length - ).fill(null) + indicatorData = SMA.calculate({ + period: ind.period, + values: closePrices, + }) + label = `SMA (${ind.period})` + // Pad beginning with nulls to match length + indicatorData = Array( + closePrices.length - indicatorData.length + ) + .fill(null) + .concat(indicatorData) + datasets.push({ + label, + data: indicatorData, + borderColor: color, + borderWidth: 1.5, + pointRadius: 0, + fill: false, + yAxisID: 'y', + }) + break + case 'EMA': + indicatorData = EMA.calculate({ + period: ind.period, + values: closePrices, + }) + label = `EMA (${ind.period})` + indicatorData = Array( + closePrices.length - indicatorData.length + ) + .fill(null) + .concat(indicatorData) + datasets.push({ + label, + data: indicatorData, + borderColor: color, + borderWidth: 1.5, + pointRadius: 0, + fill: false, + yAxisID: 'y', + }) + break + case 'RSI': + indicatorData = RSI.calculate({ + period: ind.period, + values: closePrices, + }) + label = `RSI (${ind.period})` + indicatorData = Array( + closePrices.length - indicatorData.length + ) + .fill(null) + .concat(indicatorData) + datasets.push({ + label, + data: indicatorData, + borderColor: color, + borderWidth: 1.5, + pointRadius: 0, + fill: false, + yAxisID: 'y1', // Separate axis + type: 'line', + }) + break + case 'MACD': { + const macdResult = MACD.calculate({ + fastPeriod: ind.fastPeriod ?? 12, + slowPeriod: ind.slowPeriod ?? 26, + signalPeriod: ind.signalPeriod ?? 9, + values: closePrices, + SimpleMAOscillator: false, + SimpleMASignal: false, + }) + const macd = macdResult.map((m) => m.MACD) + const signal = macdResult.map((m) => m.signal) + const histogram = macdResult.map((m) => m.histogram) + const padding = Array( + closePrices.length - macd.length + ).fill(null) - datasets.push({ - label: 'MACD', - data: padding.concat(macd), - borderColor: color, - borderWidth: 1.5, - pointRadius: 0, - fill: false, - yAxisID: 'y1', - }) - datasets.push({ - label: 'MACD Signal', - data: padding.concat(signal), - borderColor: '#00ff00', - borderWidth: 1.5, - pointRadius: 0, - fill: false, - yAxisID: 'y1', - }) - datasets.push({ - label: 'MACD Histogram', - data: padding.concat(histogram), - backgroundColor: color, - type: 'bar', - yAxisID: 'y1', - }) - break; - } - case 'BollingerBands': { - const bb = BollingerBands.calculate({ - period: ind.period, - stdDev: ind.stdDev ?? 2, - values: closePrices, - }) - const upper = bb.map((b) => b.upper) - const middle = bb.map((b) => b.middle) - const lower = bb.map((b) => b.lower) - const padding = Array( - closePrices.length - bb.length - ).fill(null) + datasets.push({ + label: 'MACD', + data: padding.concat(macd), + borderColor: color, + borderWidth: 1.5, + pointRadius: 0, + fill: false, + yAxisID: 'y1', + }) + datasets.push({ + label: 'MACD Signal', + data: padding.concat(signal), + borderColor: '#00ff00', + borderWidth: 1.5, + pointRadius: 0, + fill: false, + yAxisID: 'y1', + }) + datasets.push({ + label: 'MACD Histogram', + data: padding.concat(histogram), + backgroundColor: color, + type: 'bar', + yAxisID: 'y1', + }) + break + } + case 'BollingerBands': + { + const bb = BollingerBands.calculate({ + period: ind.period, + stdDev: ind.stdDev ?? 2, + values: closePrices, + }) + const upper = bb.map((b) => b.upper) + const middle = bb.map((b) => b.middle) + const lower = bb.map((b) => b.lower) + const padding = Array( + closePrices.length - bb.length + ).fill(null) - datasets.push({ - label: 'BB Upper', - data: padding.concat(upper), - borderColor: 'rgba(200, 200, 200, 0.5)', - borderWidth: 1, - pointRadius: 0, - fill: false, - yAxisID: 'y', - }) - datasets.push({ - label: 'BB Middle', - data: padding.concat(middle), - borderColor: color, - borderWidth: 1, - pointRadius: 0, - fill: false, - yAxisID: 'y', - }) - datasets.push({ - label: 'BB Lower', - data: padding.concat(lower), - borderColor: 'rgba(200, 200, 200, 0.5)', - borderWidth: 1, - pointRadius: 0, - fill: '-1', // Fill to upper - backgroundColor: 'rgba(200, 200, 200, 0.1)', - yAxisID: 'y', - }) + datasets.push({ + label: 'BB Upper', + data: padding.concat(upper), + borderColor: 'rgba(200, 200, 200, 0.5)', + borderWidth: 1, + pointRadius: 0, + fill: false, + yAxisID: 'y', + }) + datasets.push({ + label: 'BB Middle', + data: padding.concat(middle), + borderColor: color, + borderWidth: 1, + pointRadius: 0, + fill: false, + yAxisID: 'y', + }) + datasets.push({ + label: 'BB Lower', + data: padding.concat(lower), + borderColor: 'rgba(200, 200, 200, 0.5)', + borderWidth: 1, + pointRadius: 0, + fill: '-1', // Fill to upper + backgroundColor: 'rgba(200, 200, 200, 0.1)', + yAxisID: 'y', + }) + } + break } - break + } catch (e) { + log.warn(`Failed to calculate ${ind.type}`, { error: e }) } - } catch (e) { - log.warn(`Failed to calculate ${ind.type}`, { error: e }) } - } - // Decimation: downsample for visualization when the series is very large - // Uses LTTB (Largest Triangle Three Buckets) to pick representative indices - const DECIMATE_THRESHOLD = 5000 - const DECIMATED_POINTS = 2000 + // Decimation: downsample for visualization when the series is very large + // Uses LTTB (Largest Triangle Three Buckets) to pick representative indices + const DECIMATE_THRESHOLD = 5000 + const DECIMATED_POINTS = 2000 - // Final labels/datasets (fall back to full if decimation fails or is not needed) - let finalLabels = labels - let finalDatasets = datasets + // Final labels/datasets (fall back to full if decimation fails or is not needed) + let finalLabels = labels + let finalDatasets = datasets - if (labels.length > DECIMATE_THRESHOLD) { - try { - // Use price series (first dataset) indices to decimate all datasets consistently - const baseSeries = datasets[0]?.data ?? closePrices - const baseSeriesArr = Array.isArray(baseSeries) ? (baseSeries as Array<number | null | undefined>) : closePrices - const valuesForDownsample = baseSeriesArr.map((v) => (v === null || v === undefined ? NaN : Number(v))) - - // Use downsample tool (server-side) to decimate according to algorithm - const dec = (await downsampleTool.execute({ values: valuesForDownsample, target: DECIMATED_POINTS, algorithm: 'lttb' })) as { indices: number[]; values: number[]; originalLength: number; target: number } - const indices: number[] = dec.indices + if (labels.length > DECIMATE_THRESHOLD) { + try { + // Use price series (first dataset) indices to decimate all datasets consistently + const baseSeries = datasets[0]?.data ?? closePrices + const baseSeriesArr = Array.isArray(baseSeries) + ? (baseSeries as Array<number | null | undefined>) + : closePrices + const valuesForDownsample = baseSeriesArr.map((v) => + v === null || v === undefined ? NaN : Number(v) + ) - // Build new labels and datasets sampled at the chosen indices - finalLabels = indices.map((i) => labels[i]) - finalDatasets = datasets.map((ds) => { - const dsData = (ds as { data?: unknown }).data - const dsArr = Array.isArray(dsData) ? (dsData as unknown[]) : [] - return { - ...ds, - data: indices.map((idx) => { - const v = dsArr[idx] - return typeof v === 'number' ? v : null - }), + // Use downsample tool (server-side) to decimate according to algorithm + const dec = (await downsampleTool.execute({ + values: valuesForDownsample, + target: DECIMATED_POINTS, + algorithm: 'lttb', + })) as { + indices: number[] + values: number[] + originalLength: number + target: number } - }) - } catch (e) { - log.warn('Decimation failed, continuing with full dataset', { error: e }) + const indices: number[] = dec.indices + + // Build new labels and datasets sampled at the chosen indices + finalLabels = indices.map((i) => labels[i]) + finalDatasets = datasets.map((ds) => { + const dsData = (ds as { data?: unknown }).data + const dsArr = Array.isArray(dsData) + ? (dsData as unknown[]) + : [] + return { + ...ds, + data: indices.map((idx) => { + const v = dsArr[idx] + return typeof v === 'number' ? v : null + }), + } + }) + } catch (e) { + log.warn( + 'Decimation failed, continuing with full dataset', + { error: e } + ) + } } - } - config = { - type: chartType, // Base type from request - data: { - labels: finalLabels, - datasets: finalDatasets, - }, - options: { - responsive: true, - interaction: { - mode: 'index', - intersect: false, + config = { + type: chartType, // Base type from request + data: { + labels: finalLabels, + datasets: finalDatasets, }, - plugins: { - title: { - display: typeof title === 'string' && title.trim().length > 0, - text: title, + options: { + responsive: true, + interaction: { + mode: 'index', + intersect: false, }, - tooltip: { - enabled: true, + plugins: { + title: { + display: + typeof title === 'string' && + title.trim().length > 0, + text: title, + }, + tooltip: { + enabled: true, + }, }, - }, - scales: { - y: { - type: 'linear', - display: true, - position: 'left', - title: { display: true, text: 'Price' }, + scales: { + y: { + type: 'linear', + display: true, + position: 'left', + title: { display: true, text: 'Price' }, + }, + // Only show y1 if RSI is used + y1: indicators.some((i) => i.type === 'RSI') + ? { + type: 'linear', + display: true, + position: 'right', + title: { display: true, text: 'Oscillators' }, + min: 0, + max: 100, + grid: { + drawOnChartArea: false, + }, + } + : undefined, }, - // Only show y1 if RSI is used - y1: indicators.some((i) => i.type === 'RSI') - ? { - type: 'linear', - display: true, - position: 'right', - title: { display: true, text: 'Oscillators' }, - min: 0, - max: 100, - grid: { - drawOnChartArea: false, - }, - } - : undefined, }, - }, - } - - // Update trace span with duration and end - const duration = Date.now() - startTime - toolSpan?.update({ - output: { config }, - metadata: { - 'tool.output.labels': finalLabels.length, - 'tool.output.datasetCount': finalDatasets.length, - 'tool.duration_ms': duration, - }, - }) - toolSpan?.end() + } - // Log tool execution success - logToolExecution('chartjs-generator', { dataCount: data.length, indicatorsCount: indicators.length }, { labels: finalLabels.length, datasetCount: finalDatasets.length, durationMs: duration }) + // Update trace span with duration and end + const duration = Date.now() - startTime + toolSpan?.update({ + output: { config }, + metadata: { + 'tool.output.labels': finalLabels.length, + 'tool.output.datasetCount': finalDatasets.length, + 'tool.duration_ms': duration, + }, + }) + toolSpan?.end() + // Log tool execution success + logToolExecution( + 'chartjs-generator', + { dataCount: data.length, indicatorsCount: indicators.length }, + { + labels: finalLabels.length, + datasetCount: finalDatasets.length, + durationMs: duration, + } + ) } catch (e: unknown) { // Cast unknown to Error when reporting to span const err = e instanceof Error ? e : new Error(String(e)) @@ -415,7 +455,11 @@ export const chartJsTool = createTool({ // Log error to central logger log.error('chartjs-generator failed', { error: err.message }) // Log tool execution failure - logToolExecution('chartjs-generator', { dataCount: data.length, indicatorsCount: indicators.length }, { success: false, error: err.message }) + logToolExecution( + 'chartjs-generator', + { dataCount: data.length, indicatorsCount: indicators.length }, + { success: false, error: err.message } + ) throw e } @@ -431,14 +475,28 @@ export const chartJsTool = createTool({ return { config } }, - onInputStart: ({ toolCallId, messages }: { toolCallId: string; messages: unknown[] }) => { + onInputStart: ({ + toolCallId, + messages, + }: { + toolCallId: string + messages: unknown[] + }) => { log.info('Chart.js generator tool input streaming started', { toolCallId, messages: Array.isArray(messages) ? messages.length : 0, hook: 'onInputStart', }) }, - onInputDelta: ({ inputTextDelta, toolCallId, messages }: { inputTextDelta: string; toolCallId: string; messages: unknown[] }) => { + onInputDelta: ({ + inputTextDelta, + toolCallId, + messages, + }: { + inputTextDelta: string + toolCallId: string + messages: unknown[] + }) => { log.info('Chart.js generator tool received input chunk', { toolCallId, inputTextDelta, @@ -446,7 +504,15 @@ export const chartJsTool = createTool({ hook: 'onInputDelta', }) }, - onInputAvailable: ({ input, toolCallId, messages }: { input: unknown; toolCallId: string; messages: unknown[] }) => { + onInputAvailable: ({ + input, + toolCallId, + messages, + }: { + input: unknown + toolCallId: string + messages: unknown[] + }) => { log.info('Chart.js generator tool received input', { toolCallId, inputData: input, @@ -454,7 +520,15 @@ export const chartJsTool = createTool({ hook: 'onInputAvailable', }) }, - onOutput: ({ output, toolCallId, toolName }: { output: unknown; toolCallId: string; toolName: string }) => { + onOutput: ({ + output, + toolCallId, + toolName, + }: { + output: unknown + toolCallId: string + toolName: string + }) => { log.info('Chart.js generator tool completed', { toolCallId, toolName, @@ -465,4 +539,3 @@ export const chartJsTool = createTool({ }) export type ChartJsUITool = InferUITool<typeof chartJsTool> - diff --git a/src/mastra/tools/code-analysis.tool.ts b/src/mastra/tools/code-analysis.tool.ts index 4b1f1826..3900ebc0 100644 --- a/src/mastra/tools/code-analysis.tool.ts +++ b/src/mastra/tools/code-analysis.tool.ts @@ -12,136 +12,136 @@ import { log } from '../config/logger' import { PythonParser } from './semantic-utils' export interface CodeAnalysisRequestContext extends RequestContext { - userId?: string; + userId?: string } async function fileExists(filePath: string): Promise<boolean> { - try { - await fs.access(filePath) - return true - } catch { - return false - } + try { + await fs.access(filePath) + return true + } catch { + return false + } } const fastGlobDefaults: FastGlobOptions = { - onlyFiles: true, - absolute: true, - dot: true, - unique: true, + onlyFiles: true, + absolute: true, + dot: true, + unique: true, } async function globFiles( - pattern: string, - options?: FastGlobOptions + pattern: string, + options?: FastGlobOptions ): Promise<string[]> { - return fg(pattern, { ...fastGlobDefaults, ...options }) + return fg(pattern, { ...fastGlobDefaults, ...options }) } const DEFAULT_IGNORE = [ - '**/node_modules/**', - '**/.git/**', - '**/dist/**', - '**/build/**', + '**/node_modules/**', + '**/.git/**', + '**/dist/**', + '**/build/**', ] const codeAnalysisInputSchema = z.object({ - target: z - .union([ - z.string().describe('File path or glob pattern'), - z.array(z.string()).describe('Array of file paths'), - ]) - .describe('Files to analyze'), - options: z - .object({ - includeMetrics: z - .boolean() - .default(true) - .describe('Calculate LOC and complexity'), - detectPatterns: z - .boolean() - .default(true) - .describe('Detect code patterns and issues'), - maxFileSize: z - .number() - .default(100000) - .describe('Skip files larger than this (bytes)'), - }) - .optional(), + target: z + .union([ + z.string().describe('File path or glob pattern'), + z.array(z.string()).describe('Array of file paths'), + ]) + .describe('Files to analyze'), + options: z + .object({ + includeMetrics: z + .boolean() + .default(true) + .describe('Calculate LOC and complexity'), + detectPatterns: z + .boolean() + .default(true) + .describe('Detect code patterns and issues'), + maxFileSize: z + .number() + .default(100000) + .describe('Skip files larger than this (bytes)'), + }) + .optional(), }) const issueSchema = z.object({ - type: z.enum(['error', 'warning', 'info']), - line: z.number(), - column: z.number().optional(), - message: z.string(), - rule: z.string().optional(), + type: z.enum(['error', 'warning', 'info']), + line: z.number(), + column: z.number().optional(), + message: z.string(), + rule: z.string().optional(), }) const fileAnalysisSchema = z.object({ - path: z.string(), - language: z.string(), - loc: z.number().describe('Lines of code (non-empty, non-comment)'), - totalLines: z.number(), - complexity: z.number().describe('Estimated cyclomatic complexity'), - issues: z.array(issueSchema), + path: z.string(), + language: z.string(), + loc: z.number().describe('Lines of code (non-empty, non-comment)'), + totalLines: z.number(), + complexity: z.number().describe('Estimated cyclomatic complexity'), + issues: z.array(issueSchema), }) const codeAnalysisOutputSchema = z.object({ - files: z.array(fileAnalysisSchema), - summary: z.object({ - totalFiles: z.number(), - totalLoc: z.number(), - avgComplexity: z.number(), - issueCount: z.record(z.string(), z.number()), - }), + files: z.array(fileAnalysisSchema), + summary: z.object({ + totalFiles: z.number(), + totalLoc: z.number(), + avgComplexity: z.number(), + issueCount: z.record(z.string(), z.number()), + }), }) export type CodeAnalysisInput = z.infer<typeof codeAnalysisInputSchema> export type CodeAnalysisOutput = z.infer<typeof codeAnalysisOutputSchema> const LANGUAGE_MAP: Record<string, string> = { - '.ts': 'typescript', - '.tsx': 'typescript', - '.js': 'javascript', - '.jsx': 'javascript', - '.py': 'python', - '.rs': 'rust', - '.go': 'go', - '.java': 'java', - '.cpp': 'cpp', - '.c': 'c', - '.cs': 'csharp', - '.rb': 'ruby', - '.php': 'php', - '.swift': 'swift', - '.kt': 'kotlin', - '.md': 'markdown', - '.json': 'json', - '.yaml': 'yaml', - '.yml': 'yaml', + '.ts': 'typescript', + '.tsx': 'typescript', + '.js': 'javascript', + '.jsx': 'javascript', + '.py': 'python', + '.rs': 'rust', + '.go': 'go', + '.java': 'java', + '.cpp': 'cpp', + '.c': 'c', + '.cs': 'csharp', + '.rb': 'ruby', + '.php': 'php', + '.swift': 'swift', + '.kt': 'kotlin', + '.md': 'markdown', + '.json': 'json', + '.yaml': 'yaml', + '.yml': 'yaml', } function detectLanguage(filePath: string): string { - const ext = path.extname(filePath).toLowerCase() - return LANGUAGE_MAP[ext] || 'unknown' + const ext = path.extname(filePath).toLowerCase() + return LANGUAGE_MAP[ext] || 'unknown' } function calculateLoc(content: string): { loc: number; totalLines: number } { - const totalLines = content.split('\n').length - let sanitized = content + const totalLines = content.split('\n').length + let sanitized = content - try { - sanitized = stripComments(content, { preserveNewlines: true }) - } catch { - sanitized = content - } + try { + sanitized = stripComments(content, { preserveNewlines: true }) + } catch { + sanitized = content + } - const loc = sanitized - .split('\n') - .reduce((count, line) => (line.trim() ? count + 1 : count), 0) + const loc = sanitized + .split('\n') + .reduce((count, line) => (line.trim() ? count + 1 : count), 0) - return { loc, totalLines } + return { loc, totalLines } } /** @@ -153,495 +153,500 @@ function calculateLoc(content: string): { loc: number; totalLines: number } { * @returns The estimated complexity number. */ function estimateComplexity(content: string, language: string): number { - let complexity = 1 - - const languagePatterns: Record<string, RegExp[]> = { - typescript: [ - /\bif\b/g, - /\belse\s+if\b/g, - /\bfor\b/g, - /\bwhile\b/g, - /\bcase\b/g, - /\bcatch\b/g, - /\b\?\s*[^:]+\s*:/g, // ternary - /&&/g, - /\|\|/g, - ], - javascript: [ - /\bif\b/g, - /\belse\s+if\b/g, - /\bfor\b/g, - /\bwhile\b/g, - /\bcase\b/g, - /\bcatch\b/g, - /\b\?\s*[^:]+\s*:/g, // ternary - /&&/g, - /\|\|/g, - ], - python: [ - /\bif\b/g, - /\belif\b/g, - /\bfor\b/g, - /\bwhile\b/g, - /\band\b/g, - /\bor\b/g, - ], - java: [ - /\bif\b/g, - /\belse\s+if\b/g, - /\bfor\b/g, - /\bwhile\b/g, - /\bcase\b/g, - /\bcatch\b/g, - /\b\?\s*[^:]+\s*:/g, // ternary - /&&/g, - /\|\|/g, - ], - cpp: [ - /\bif\b/g, - /\belse\s+if\b/g, - /\bfor\b/g, - /\bwhile\b/g, - /\bcase\b/g, - /\bcatch\b/g, - /\b\?\s*[^:]+\s*:/g, // ternary - /&&/g, - /\|\|/g, - ], - c: [ - /\bif\b/g, - /\belse\s+if\b/g, - /\bfor\b/g, - /\bwhile\b/g, - /\bcase\b/g, - /\b\?\s*[^:]+\s*:/g, // ternary - /&&/g, - /\|\|/g, - ], - csharp: [ - /\bif\b/g, - /\belse\s+if\b/g, - /\bfor\b/g, - /\bwhile\b/g, - /\bcase\b/g, - /\bcatch\b/g, - /\b\?\s*[^:]+\s*:/g, // ternary - /&&/g, - /\|\|/g, - ], - rust: [ - /\bif\b/g, - /\belse\s+if\b/g, - /\bfor\b/g, - /\bwhile\b/g, - /\bmatch\b/g, - /\b\?\s*[^:]+\s*:/g, // ternary - /&&/g, - /\|\|/g, - ], - go: [ - /\bif\b/g, - /\belse\s+if\b/g, - /\bfor\b/g, - /\bcase\b/g, - /\b\?\s*[^:]+\s*:/g, // ternary - /&&/g, - /\|\|/g, - ], - ruby: [ - /\bif\b/g, - /\belsif\b/g, - /\bfor\b/g, - /\bwhile\b/g, - /\bcase\b/g, - /\band\b/g, - /\bor\b/g, - ], - php: [ - /\bif\b/g, - /\belseif\b/g, - /\bfor\b/g, - /\bwhile\b/g, - /\bcase\b/g, - /\bcatch\b/g, - /\b\?\s*[^:]+\s*:/g, // ternary - /&&/g, - /\|\|/g, - ], - swift: [ - /\bif\b/g, - /\belse\s+if\b/g, - /\bfor\b/g, - /\bwhile\b/g, - /\bcase\b/g, - /\bcatch\b/g, - /\b\?\s*[^:]+\s*:/g, // ternary - /&&/g, - /\|\|/g, - ], - kotlin: [ - /\bif\b/g, - /\belse\s+if\b/g, - /\bfor\b/g, - /\bwhile\b/g, - /\bwhen\b/g, - /\bcatch\b/g, - /\b\?\s*[^:]+\s*:/g, // ternary - /&&/g, - /\|\|/g, - ], - } - - const patterns = - language in languagePatterns - ? languagePatterns[language] - : languagePatterns['typescript'] // default to typescript patterns - - for (const pattern of patterns) { - const matches = content.match(pattern) - if (matches) { - complexity += matches.length + let complexity = 1 + + const languagePatterns: Record<string, RegExp[]> = { + typescript: [ + /\bif\b/g, + /\belse\s+if\b/g, + /\bfor\b/g, + /\bwhile\b/g, + /\bcase\b/g, + /\bcatch\b/g, + /\b\?\s*[^:]+\s*:/g, // ternary + /&&/g, + /\|\|/g, + ], + javascript: [ + /\bif\b/g, + /\belse\s+if\b/g, + /\bfor\b/g, + /\bwhile\b/g, + /\bcase\b/g, + /\bcatch\b/g, + /\b\?\s*[^:]+\s*:/g, // ternary + /&&/g, + /\|\|/g, + ], + python: [ + /\bif\b/g, + /\belif\b/g, + /\bfor\b/g, + /\bwhile\b/g, + /\band\b/g, + /\bor\b/g, + ], + java: [ + /\bif\b/g, + /\belse\s+if\b/g, + /\bfor\b/g, + /\bwhile\b/g, + /\bcase\b/g, + /\bcatch\b/g, + /\b\?\s*[^:]+\s*:/g, // ternary + /&&/g, + /\|\|/g, + ], + cpp: [ + /\bif\b/g, + /\belse\s+if\b/g, + /\bfor\b/g, + /\bwhile\b/g, + /\bcase\b/g, + /\bcatch\b/g, + /\b\?\s*[^:]+\s*:/g, // ternary + /&&/g, + /\|\|/g, + ], + c: [ + /\bif\b/g, + /\belse\s+if\b/g, + /\bfor\b/g, + /\bwhile\b/g, + /\bcase\b/g, + /\b\?\s*[^:]+\s*:/g, // ternary + /&&/g, + /\|\|/g, + ], + csharp: [ + /\bif\b/g, + /\belse\s+if\b/g, + /\bfor\b/g, + /\bwhile\b/g, + /\bcase\b/g, + /\bcatch\b/g, + /\b\?\s*[^:]+\s*:/g, // ternary + /&&/g, + /\|\|/g, + ], + rust: [ + /\bif\b/g, + /\belse\s+if\b/g, + /\bfor\b/g, + /\bwhile\b/g, + /\bmatch\b/g, + /\b\?\s*[^:]+\s*:/g, // ternary + /&&/g, + /\|\|/g, + ], + go: [ + /\bif\b/g, + /\belse\s+if\b/g, + /\bfor\b/g, + /\bcase\b/g, + /\b\?\s*[^:]+\s*:/g, // ternary + /&&/g, + /\|\|/g, + ], + ruby: [ + /\bif\b/g, + /\belsif\b/g, + /\bfor\b/g, + /\bwhile\b/g, + /\bcase\b/g, + /\band\b/g, + /\bor\b/g, + ], + php: [ + /\bif\b/g, + /\belseif\b/g, + /\bfor\b/g, + /\bwhile\b/g, + /\bcase\b/g, + /\bcatch\b/g, + /\b\?\s*[^:]+\s*:/g, // ternary + /&&/g, + /\|\|/g, + ], + swift: [ + /\bif\b/g, + /\belse\s+if\b/g, + /\bfor\b/g, + /\bwhile\b/g, + /\bcase\b/g, + /\bcatch\b/g, + /\b\?\s*[^:]+\s*:/g, // ternary + /&&/g, + /\|\|/g, + ], + kotlin: [ + /\bif\b/g, + /\belse\s+if\b/g, + /\bfor\b/g, + /\bwhile\b/g, + /\bwhen\b/g, + /\bcatch\b/g, + /\b\?\s*[^:]+\s*:/g, // ternary + /&&/g, + /\|\|/g, + ], + } + + const patterns = + language in languagePatterns + ? languagePatterns[language] + : languagePatterns['typescript'] // default to typescript patterns + + for (const pattern of patterns) { + const matches = content.match(pattern) + if (matches) { + complexity += matches.length + } } - } - return complexity + return complexity } function detectIssues( - content: string, - language: string + content: string, + language: string ): Array<z.infer<typeof issueSchema>> { - const issues: Array<z.infer<typeof issueSchema>> = [] - const lines = content.split('\n') - - const patterns = [ - { - regex: /console\.log\(/g, - message: 'Console statement found', - rule: 'no-console', - type: 'warning' as const, - }, - { - regex: /debugger/g, - message: 'Debugger statement found', - rule: 'no-debugger', - type: 'error' as const, - }, - { - regex: /TODO:/gi, - message: 'TODO comment found', - rule: 'no-todo', - type: 'info' as const, - }, - { - regex: /FIXME:/gi, - message: 'FIXME comment found', - rule: 'no-fixme', - type: 'warning' as const, - }, - { - regex: /HACK:/gi, - message: 'HACK comment found', - rule: 'no-hack', - type: 'warning' as const, - }, - { - regex: /any(?:\s|;|,|\))/g, - message: 'Explicit any type', - rule: 'no-explicit-any', - type: 'warning' as const, - }, - { - regex: /==(?!=)/g, - message: 'Loose equality used', - rule: 'eqeqeq', - type: 'warning' as const, - }, - { - regex: /\bvar\b/g, - message: 'var keyword used (prefer const/let)', - rule: 'no-var', - type: 'info' as const, - }, - ] - - for (let i = 0; i < lines.length; i++) { - const line = lines[i] - - for (const pattern of patterns) { - if ( - language === 'typescript' || - language === 'javascript' || - pattern.rule === 'no-todo' || - pattern.rule === 'no-fixme' || - pattern.rule === 'no-hack' - ) { - if (pattern.regex.test(line)) { - issues.push({ - type: pattern.type, - line: i + 1, - message: pattern.message, - rule: pattern.rule, - }) + const issues: Array<z.infer<typeof issueSchema>> = [] + const lines = content.split('\n') + + const patterns = [ + { + regex: /console\.log\(/g, + message: 'Console statement found', + rule: 'no-console', + type: 'warning' as const, + }, + { + regex: /debugger/g, + message: 'Debugger statement found', + rule: 'no-debugger', + type: 'error' as const, + }, + { + regex: /TODO:/gi, + message: 'TODO comment found', + rule: 'no-todo', + type: 'info' as const, + }, + { + regex: /FIXME:/gi, + message: 'FIXME comment found', + rule: 'no-fixme', + type: 'warning' as const, + }, + { + regex: /HACK:/gi, + message: 'HACK comment found', + rule: 'no-hack', + type: 'warning' as const, + }, + { + regex: /any(?:\s|;|,|\))/g, + message: 'Explicit any type', + rule: 'no-explicit-any', + type: 'warning' as const, + }, + { + regex: /==(?!=)/g, + message: 'Loose equality used', + rule: 'eqeqeq', + type: 'warning' as const, + }, + { + regex: /\bvar\b/g, + message: 'var keyword used (prefer const/let)', + rule: 'no-var', + type: 'info' as const, + }, + ] + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + for (const pattern of patterns) { + if ( + language === 'typescript' || + language === 'javascript' || + pattern.rule === 'no-todo' || + pattern.rule === 'no-fixme' || + pattern.rule === 'no-hack' + ) { + if (pattern.regex.test(line)) { + issues.push({ + type: pattern.type, + line: i + 1, + message: pattern.message, + rule: pattern.rule, + }) + } + pattern.regex.lastIndex = 0 + } } - pattern.regex.lastIndex = 0 - } - } - if (line.length > 120) { - issues.push({ - type: 'info', - line: i + 1, - message: `Line exceeds 120 characters (${line.length})`, - rule: 'max-line-length', - }) + if (line.length > 120) { + issues.push({ + type: 'info', + line: i + 1, + message: `Line exceeds 120 characters (${line.length})`, + rule: 'max-line-length', + }) + } } - } - return issues + return issues } export const codeAnalysisTool = createTool({ - id: 'coding:codeAnalysis', - description: `Analyze source code files for metrics and issues. + id: 'coding:codeAnalysis', + description: `Analyze source code files for metrics and issues. Returns lines of code, complexity estimates, and detected patterns. Supports TypeScript, JavaScript, Python, and other languages. Use for code review preparation, quality assessment, and refactoring planning.`, - inputSchema: codeAnalysisInputSchema, - outputSchema: codeAnalysisOutputSchema, - execute: async (inputData, context): Promise<CodeAnalysisOutput> => { - const writer = context?.writer - const requestCtx = context?.requestContext as CodeAnalysisRequestContext | undefined - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `🔬 Starting code analysis for target: ${Array.isArray(inputData.target) ? inputData.target.join(',') : inputData.target}`, - stage: 'coding:codeAnalysis', - }, - id: 'coding:codeAnalysis', - }) - - if (requestCtx?.userId) { - log.debug('Executing code analysis for user', { userId: requestCtx.userId }) - } + inputSchema: codeAnalysisInputSchema, + outputSchema: codeAnalysisOutputSchema, + execute: async (inputData, context): Promise<CodeAnalysisOutput> => { + const writer = context?.writer + const requestCtx = context?.requestContext as + | CodeAnalysisRequestContext + | undefined - const tracingContext = context?.tracingContext - const span = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'code-analysis', - input: { target: inputData.target }, - metadata: { - 'tool.id': 'coding:codeAnalysis', - 'tool.input.target': Array.isArray(inputData.target) - ? inputData.target.join(',') - : inputData.target, - }, - }) - - try { - const { target, options } = inputData - const includeMetrics = options?.includeMetrics ?? true - const detectPatterns = options?.detectPatterns ?? true - const maxFileSize = options?.maxFileSize ?? 100000 - - const targets = Array.isArray(target) ? target : [target] - let filePaths: string[] = [] - - for (const t of targets) { - if (t.includes('*')) { - const matches = await globFiles(t, { - ignore: DEFAULT_IGNORE, - }) - filePaths.push(...matches) - continue - } + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `🔬 Starting code analysis for target: ${Array.isArray(inputData.target) ? inputData.target.join(',') : inputData.target}`, + stage: 'coding:codeAnalysis', + }, + id: 'coding:codeAnalysis', + }) - if (!(await fileExists(t))) { - continue + if (requestCtx?.userId) { + log.debug('Executing code analysis for user', { + userId: requestCtx.userId, + }) } - const stat = await fs.stat(t) - if (stat.isDirectory()) { - const dirFiles = await globFiles(path.join(t, '**/*'), { - ignore: DEFAULT_IGNORE, - }) - filePaths.push(...dirFiles) - } else if (stat.isFile()) { - filePaths.push(t) - } - } + const tracingContext: TracingContext | undefined = context?.tracingContext + const span = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'code-analysis', + input: { target: inputData.target }, + requestContext: context?.requestContext, + metadata: { + 'tool.id': 'coding:codeAnalysis', + 'tool.input.target': Array.isArray(inputData.target) + ? inputData.target.join(',') + : inputData.target, + }, + }) - filePaths = [...new Set(filePaths)] + try { + const { target, options } = inputData + const includeMetrics = options?.includeMetrics ?? true + const detectPatterns = options?.detectPatterns ?? true + const maxFileSize = options?.maxFileSize ?? 100000 + + const targets = Array.isArray(target) ? target : [target] + let filePaths: string[] = [] + + for (const t of targets) { + if (t.includes('*')) { + const matches = await globFiles(t, { + ignore: DEFAULT_IGNORE, + }) + filePaths.push(...matches) + continue + } + + if (!(await fileExists(t))) { + continue + } + + const stat = await fs.stat(t) + if (stat.isDirectory()) { + const dirFiles = await globFiles(path.join(t, '**/*'), { + ignore: DEFAULT_IGNORE, + }) + filePaths.push(...dirFiles) + } else if (stat.isFile()) { + filePaths.push(t) + } + } - const fileAnalyses: Array<z.infer<typeof fileAnalysisSchema>> = [] + filePaths = [...new Set(filePaths)] + + const fileAnalyses: Array<z.infer<typeof fileAnalysisSchema>> = [] + + for (const filePath of filePaths) { + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `📄 Analyzing file: ${filePath}`, + stage: 'coding:codeAnalysis', + }, + id: 'coding:codeAnalysis', + }) + + try { + const stats = await fs.stat(filePath) + if (!stats.isFile()) { + continue + } + if (stats.size > maxFileSize) { + continue + } + + const content = await fs.readFile(filePath, 'utf-8') + const language = detectLanguage(filePath) + + const { loc, totalLines } = includeMetrics + ? calculateLoc(content) + : { loc: 0, totalLines: 0 } + + let complexity = 0 + if (includeMetrics) { + if (language === 'python') { + try { + const complexityData = + await PythonParser.analyzeComplexity( + content + ) + complexity = complexityData.cyclomaticComplexity + } catch { + complexity = estimateComplexity( + content, + language + ) + } + } else { + complexity = estimateComplexity(content, language) + } + } + + const issues = detectPatterns + ? detectIssues(content, language) + : [] + + fileAnalyses.push({ + path: filePath, + language, + loc, + totalLines, + complexity, + issues, + }) + } catch { + // Skip unreadable files + } + } - for (const filePath of filePaths) { - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `📄 Analyzing file: ${filePath}`, - stage: 'coding:codeAnalysis', - }, - id: 'coding:codeAnalysis', - }) + const totalLoc = fileAnalyses.reduce((sum, f) => sum + f.loc, 0) + const avgComplexity = + fileAnalyses.length > 0 + ? fileAnalyses.reduce((sum, f) => sum + f.complexity, 0) / + fileAnalyses.length + : 0 + + const issueCount: Record<string, number> = {} + for (const file of fileAnalyses) { + for (const issue of file.issues) { + issueCount[issue.type] = (issueCount[issue.type] || 0) + 1 + } + } - try { - const stats = await fs.stat(filePath) - if (!stats.isFile()) { - continue - } - if (stats.size > maxFileSize) { - continue - } - - const content = await fs.readFile(filePath, 'utf-8') - const language = detectLanguage(filePath) - - const { loc, totalLines } = includeMetrics - ? calculateLoc(content) - : { loc: 0, totalLines: 0 } - - let complexity = 0 - if (includeMetrics) { - if (language === 'python') { - try { - const complexityData = - await PythonParser.analyzeComplexity( - content - ) - complexity = complexityData.cyclomaticComplexity - } catch { - complexity = estimateComplexity( - content, - language - ) - } - } else { - complexity = estimateComplexity(content, language) + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `✅ Code analysis complete: ${fileAnalyses.length} file(s), ${totalLoc} lines`, + stage: 'coding:codeAnalysis', + }, + id: 'coding:codeAnalysis', + }) + + span?.update({ + output: { files: fileAnalyses }, + metadata: { + 'tool.output.totalFiles': fileAnalyses.length, + 'tool.output.totalLoc': totalLoc, + 'tool.output.avgComplexity': avgComplexity, + 'tool.output.issueCount': issueCount, + }, + }) + span?.end() + + return { + files: fileAnalyses, + summary: { + totalFiles: fileAnalyses.length, + totalLoc, + avgComplexity: Math.round(avgComplexity * 100) / 100, + issueCount, + }, } - } - - const issues = detectPatterns - ? detectIssues(content, language) - : [] - - fileAnalyses.push({ - path: filePath, - language, - loc, - totalLines, - complexity, - issues, - }) - } catch { - // Skip unreadable files + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : String(error) + + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `❌ Code analysis failed: ${errorMessage}`, + stage: 'coding:codeAnalysis', + }, + id: 'coding:codeAnalysis', + }) + + span?.error({ + error: error instanceof Error ? error : new Error(errorMessage), + endSpan: true, + }) + throw error } - } - - const totalLoc = fileAnalyses.reduce((sum, f) => sum + f.loc, 0) - const avgComplexity = - fileAnalyses.length > 0 - ? fileAnalyses.reduce((sum, f) => sum + f.complexity, 0) / - fileAnalyses.length - : 0 - - const issueCount: Record<string, number> = {} - for (const file of fileAnalyses) { - for (const issue of file.issues) { - issueCount[issue.type] = (issueCount[issue.type] || 0) + 1 - } - } - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: `✅ Code analysis complete: ${fileAnalyses.length} file(s), ${totalLoc} lines`, - stage: 'coding:codeAnalysis', - }, - id: 'coding:codeAnalysis', - }) - - span?.update({ - output: { files: fileAnalyses }, - metadata: { - 'tool.output.totalFiles': fileAnalyses.length, - 'tool.output.totalLoc': totalLoc, - 'tool.output.avgComplexity': avgComplexity, - 'tool.output.issueCount': issueCount, - }, - }) - span?.end() - - return { - files: fileAnalyses, - summary: { - totalFiles: fileAnalyses.length, - totalLoc, - avgComplexity: Math.round(avgComplexity * 100) / 100, - issueCount, - }, - } - } catch (error: unknown) { - const errorMessage = - error instanceof Error ? error.message : String(error) - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: `❌ Code analysis failed: ${errorMessage}`, - stage: 'coding:codeAnalysis', - }, - id: 'coding:codeAnalysis', - }) - - span?.error({ - error: error instanceof Error ? error : new Error(errorMessage), - endSpan: true, - }) - throw error - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Code analysis tool input streaming started', { - toolCallId, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputStart', - }) - }, - onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { - log.info('Code analysis tool received input chunk', { - toolCallId, - inputTextDelta, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputDelta', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Code analysis tool received input', { - toolCallId, - messageCount: messages.length, - inputData: { target: input.target, options: input.options }, - abortSignal: abortSignal?.aborted, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('Code analysis tool completed', { - toolCallId, - toolName, - outputData: { - totalFiles: output.summary.totalFiles, - totalLoc: output.summary.totalLoc, - }, - abortSignal: abortSignal?.aborted, - hook: 'onOutput', - }) - }, + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Code analysis tool input streaming started', { + toolCallId, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputStart', + }) + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('Code analysis tool received input chunk', { + toolCallId, + inputTextDelta, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Code analysis tool received input', { + toolCallId, + messageCount: messages.length, + inputData: { target: input.target, options: input.options }, + abortSignal: abortSignal?.aborted, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('Code analysis tool completed', { + toolCallId, + toolName, + outputData: { + totalFiles: output.summary.totalFiles, + totalLoc: output.summary.totalLoc, + }, + abortSignal: abortSignal?.aborted, + hook: 'onOutput', + }) + }, }) diff --git a/src/mastra/tools/code-chunking.ts b/src/mastra/tools/code-chunking.ts index b61ca13d..46002f05 100644 --- a/src/mastra/tools/code-chunking.ts +++ b/src/mastra/tools/code-chunking.ts @@ -10,303 +10,322 @@ import { log } from '../config/logger' import { PythonParser } from './semantic-utils' export interface CodeChunkingRequestContext extends RequestContext { - chunkSize?: number - chunkOverlap?: number + chunkSize?: number + chunkOverlap?: number } const codeChunkingInputSchema = z.object({ - filePath: z.string(), - content: z.string(), - options: z - .object({ - chunkSize: z.number().default(512), - chunkOverlap: z.number().default(50), - }) - .optional(), + filePath: z.string(), + content: z.string(), + options: z + .object({ + chunkSize: z.number().default(512), + chunkOverlap: z.number().default(50), + }) + .optional(), }) const codeChunkingOutputSchema = z.object({ - chunks: z.array( - z.object({ - text: z.string(), - metadata: z.object({ - startLine: z.number(), - endLine: z.number(), - type: z.string(), - name: z.string().optional(), - }), - }) - ), + chunks: z.array( + z.object({ + text: z.string(), + metadata: z.object({ + startLine: z.number(), + endLine: z.number(), + type: z.string(), + name: z.string().optional(), + }), + }) + ), }) export const codeChunkerTool = createTool({ - id: 'code-chunker', - description: - 'Intelligently chunks code files based on syntax (functions, classes) for TS/JS and Python, falling back to recursive chunking for others.', - inputSchema: codeChunkingInputSchema, - outputSchema: codeChunkingOutputSchema, - execute: async (input: z.infer<typeof codeChunkingInputSchema>, context) => { - const { filePath, content, options } = input - const requestContext = context?.requestContext as CodeChunkingRequestContext | undefined - const ext = filePath.split('.').pop()?.toLowerCase() - interface Chunk { - text: string - metadata: { - startLine: number - endLine: number - type: string - name?: string - } - } - const chunks: Chunk[] = [] + id: 'code-chunker', + description: + 'Intelligently chunks code files based on syntax (functions, classes) for TS/JS and Python, falling back to recursive chunking for others.', + inputSchema: codeChunkingInputSchema, + outputSchema: codeChunkingOutputSchema, + execute: async ( + input: z.infer<typeof codeChunkingInputSchema>, + context + ) => { + const { filePath, content, options } = input + const requestContext = context?.requestContext as + | CodeChunkingRequestContext + | undefined + const ext = filePath.split('.').pop()?.toLowerCase() + interface Chunk { + text: string + metadata: { + startLine: number + endLine: number + type: string + name?: string + } + } + const chunks: Chunk[] = [] - const writer = context?.writer - const abortSignal = context?.abortSignal + const writer = context?.writer + const abortSignal = context?.abortSignal - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `🔄 Starting code chunking for ${filePath}`, - stage: 'code-chunker', - }, - id: 'code-chunker', - }) + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `🔄 Starting code chunking for ${filePath}`, + stage: 'code-chunker', + }, + id: 'code-chunker', + }) - const tracingContext = context?.tracingContext - const span = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'code-chunker', - input: { filePath, ext }, - metadata: { - 'tool.id': 'code-chunker', - operation: 'code-chunking', - }, - }) + const tracingContext = context?.tracingContext + const span = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'code-chunker', + input: { filePath, ext }, + requestContext: context?.requestContext, + metadata: { + 'tool.id': 'code-chunker', + operation: 'code-chunking', + }, + }) + + try { + if (['ts', 'tsx', 'js', 'jsx'].includes(ext ?? '')) { + // TypeScript/JavaScript Chunking + const project = new Project({ useInMemoryFileSystem: true }) + const sourceFile = project.createSourceFile(filePath, content) + + sourceFile.forEachChild((node) => { + // typedNode narrows down the commonly used shape we need for code chunking + const typedNode = node as unknown as { + getName?: () => string + getText: () => string + getStartLineNumber: () => number + getEndLineNumber: () => number + getKindName: () => string + } + let name: string | undefined + + if ( + node.isKind(SyntaxKind.FunctionDeclaration) || + node.isKind(SyntaxKind.ClassDeclaration) || + node.isKind(SyntaxKind.InterfaceDeclaration) || + node.isKind(SyntaxKind.TypeAliasDeclaration) || + node.isKind(SyntaxKind.EnumDeclaration) || + node.isKind(SyntaxKind.VariableStatement) + ) { + if (typeof typedNode.getName === 'function') { + name = typedNode.getName() + } - try { - if (['ts', 'tsx', 'js', 'jsx'].includes(ext ?? '')) { - // TypeScript/JavaScript Chunking - const project = new Project({ useInMemoryFileSystem: true }) - const sourceFile = project.createSourceFile(filePath, content) + chunks.push({ + text: typedNode.getText(), + metadata: { + startLine: typedNode.getStartLineNumber(), + endLine: typedNode.getEndLineNumber(), + type: typedNode.getKindName(), + name, + }, + }) + } + }) - sourceFile.forEachChild((node) => { - // typedNode narrows down the commonly used shape we need for code chunking - const typedNode = node as unknown as { - getName?: () => string - getText: () => string - getStartLineNumber: () => number - getEndLineNumber: () => number - getKindName: () => string - } - let name: string | undefined + // If no structural chunks found, or file is mostly top-level code, might want to fallback? + // For now, if we got chunks, return them. If not, fallback to MDocument. + if (chunks.length === 0 && content.trim().length > 0) { + // Fallback to recursive + } else { + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `✅ Chunking complete: ${chunks.length} chunk${chunks.length !== 1 ? 's' : ''}`, + stage: 'code-chunker', + }, + id: 'code-chunker', + }) + span?.update({ + metadata: { 'tool.output.chunksCount': chunks.length }, + }) + span?.end() + return { chunks } + } + } + + if (ext === 'py') { + // Python Chunking + try { + const symbols = await PythonParser.findSymbols(content) + const lines = content.split('\n') + + for (const symbol of symbols) { + if ( + (symbol.kind === 'function' || + symbol.kind === 'class') && + symbol.endLine !== null && + symbol.endLine !== undefined && + symbol.endLine > 0 + ) { + const chunkLines = lines.slice( + symbol.line - 1, + symbol.endLine + ) + chunks.push({ + text: chunkLines.join('\n'), + metadata: { + startLine: symbol.line, + endLine: symbol.endLine, + type: symbol.kind, + name: symbol.name, + }, + }) + } + } - if ( - node.isKind(SyntaxKind.FunctionDeclaration) || - node.isKind(SyntaxKind.ClassDeclaration) || - node.isKind(SyntaxKind.InterfaceDeclaration) || - node.isKind(SyntaxKind.TypeAliasDeclaration) || - node.isKind(SyntaxKind.EnumDeclaration) || - node.isKind(SyntaxKind.VariableStatement) - ) { - if (typeof typedNode.getName === 'function') { - name = typedNode.getName() + if (chunks.length > 0) { + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `✅ Chunking complete: ${chunks.length} chunk${chunks.length !== 1 ? 's' : ''}`, + stage: 'code-chunker', + }, + id: 'code-chunker', + }) + span?.update({ + metadata: { + 'tool.output.chunksCount': chunks.length, + }, + }) + span?.end() + return { chunks } + } + } catch (e) { + log.warn( + `Python parsing failed for ${filePath}, falling back to text chunking: ${e}` + ) + } } - chunks.push({ - text: typedNode.getText(), - metadata: { - startLine: typedNode.getStartLineNumber(), - endLine: typedNode.getEndLineNumber(), - type: typedNode.getKindName(), - name, - }, + // Fallback: Recursive Chunking via MDocument + const doc = new MDocument({ + docs: [{ text: content, metadata: { source: filePath } }], + type: 'document', }) - } - }) - // If no structural chunks found, or file is mostly top-level code, might want to fallback? - // For now, if we got chunks, return them. If not, fallback to MDocument. - if (chunks.length === 0 && content.trim().length > 0) { - // Fallback to recursive - } else { - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: `✅ Chunking complete: ${chunks.length} chunk${chunks.length !== 1 ? 's' : ''}`, - stage: 'code-chunker', - }, - id: 'code-chunker', - }) - span?.update({ metadata: { 'tool.output.chunksCount': chunks.length } }) - span?.end() - return { chunks } - } - } + if (abortSignal?.aborted) { + span?.error({ + error: new Error('Code chunking cancelled'), + endSpan: true, + }) + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `🛑 Code chunking cancelled for ${filePath}`, + stage: 'code-chunker', + }, + id: 'code-chunker', + }) + throw new Error('Code chunking cancelled') + } - if (ext === 'py') { - // Python Chunking - try { - const symbols = await PythonParser.findSymbols(content) - const lines = content.split('\n') + const textChunks = await doc.chunk({ + strategy: 'recursive', + maxSize: options?.chunkSize ?? requestContext?.chunkSize ?? 512, + overlap: + options?.chunkOverlap ?? requestContext?.chunkOverlap ?? 50, + separators: ['\n\n', '\n', ' '], + }) - for (const symbol of symbols) { - if ( - (symbol.kind === 'function' || - symbol.kind === 'class') && - symbol.endLine !== null && - symbol.endLine !== undefined && - symbol.endLine > 0 - ) { - const chunkLines = lines.slice( - symbol.line - 1, - symbol.endLine - ) - chunks.push({ - text: chunkLines.join('\n'), + const mapped = textChunks.map((c, i) => ({ + text: c.text ?? '', metadata: { - startLine: symbol.line, - endLine: symbol.endLine, - type: symbol.kind, - name: symbol.name, + startLine: 0, // MDocument doesn't give line numbers easily + endLine: 0, + type: 'text-chunk', + name: `chunk-${i}`, }, - }) - } - } + })) - if (chunks.length > 0) { await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: `✅ Chunking complete: ${chunks.length} chunk${chunks.length !== 1 ? 's' : ''}`, - stage: 'code-chunker', - }, - id: 'code-chunker', + type: 'data-tool-progress', + data: { + status: 'done', + message: `✅ Chunking complete: ${mapped.length} chunk${mapped.length !== 1 ? 's' : ''}`, + stage: 'code-chunker', + }, + id: 'code-chunker', + }) + span?.update({ + metadata: { 'tool.output.chunksCount': mapped.length }, }) - span?.update({ metadata: { 'tool.output.chunksCount': chunks.length } }) span?.end() - return { chunks } - } - } catch (e) { - log.warn( - `Python parsing failed for ${filePath}, falling back to text chunking: ${e}` - ) - } - } + return { chunks: mapped } + } catch (error) { + const errorMeta = + error instanceof Error + ? { message: error.message, stack: error.stack } + : { value: String(error) } - // Fallback: Recursive Chunking via MDocument - const doc = new MDocument({ - docs: [{ text: content, metadata: { source: filePath } }], - type: 'document', - }) + log.error(`Error chunking ${filePath}:`, errorMeta) + // Trace the exception and set status + span?.error({ + error: + error instanceof Error ? error : new Error(String(error)), + endSpan: true, + }) - if (abortSignal?.aborted) { - span?.error({ error: new Error('Code chunking cancelled'), endSpan: true }) - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: `🛑 Code chunking cancelled for ${filePath}`, - stage: 'code-chunker', - }, - id: 'code-chunker', + // Final fallback + return { + chunks: [ + { + text: content, + metadata: { + startLine: 1, + endLine: content.split('\n').length, + type: 'file', + name: 'full-file', + }, + }, + ], + } + } + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Code chunker tool input streaming started', { + toolCallId, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputStart', }) - throw new Error('Code chunking cancelled') - } - - const textChunks = await doc.chunk({ - strategy: 'recursive', - maxSize: options?.chunkSize ?? requestContext?.chunkSize ?? 512, - overlap: options?.chunkOverlap ?? requestContext?.chunkOverlap ?? 50, - separators: ['\n\n', '\n', ' '], - }) - - const mapped = textChunks.map((c, i) => ({ - text: c.text ?? '', - metadata: { - startLine: 0, // MDocument doesn't give line numbers easily - endLine: 0, - type: 'text-chunk', - name: `chunk-${i}`, - }, - })) - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: `✅ Chunking complete: ${mapped.length} chunk${mapped.length !== 1 ? 's' : ''}`, - stage: 'code-chunker', - }, - id: 'code-chunker', - }) - span?.update({ metadata: { 'tool.output.chunksCount': mapped.length } }) - span?.end() - return { chunks: mapped } - } catch (error) { - const errorMeta = - error instanceof Error - ? { message: error.message, stack: error.stack } - : { value: String(error) } - - log.error(`Error chunking ${filePath}:`, errorMeta) - // Trace the exception and set status - span?.error({ - error: error instanceof Error ? error : new Error(String(error)), - endSpan: true, - }) - - // Final fallback - return { - chunks: [ - { - text: content, - metadata: { - startLine: 1, - endLine: content.split('\n').length, - type: 'file', - name: 'full-file', - }, - }, - ], - } - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Code chunker tool input streaming started', { - toolCallId, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputStart', - }) - }, - onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { - log.info('Code chunker received input chunk', { - toolCallId, - inputTextDelta, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputDelta', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Code chunker received input', { - toolCallId, - messageCount: messages.length, - inputData: { filePath: input.filePath }, - abortSignal: abortSignal?.aborted, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('Code chunker completed', { - toolCallId, - toolName, - chunksCreated: output.chunks.length, - abortSignal: abortSignal?.aborted, - hook: 'onOutput', - }) - }, + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('Code chunker received input chunk', { + toolCallId, + inputTextDelta, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Code chunker received input', { + toolCallId, + messageCount: messages.length, + inputData: { filePath: input.filePath }, + abortSignal: abortSignal?.aborted, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('Code chunker completed', { + toolCallId, + toolName, + chunksCreated: output.chunks.length, + abortSignal: abortSignal?.aborted, + hook: 'onOutput', + }) + }, }) diff --git a/src/mastra/tools/code-search.tool.ts b/src/mastra/tools/code-search.tool.ts index 9590befe..7180cc1a 100644 --- a/src/mastra/tools/code-search.tool.ts +++ b/src/mastra/tools/code-search.tool.ts @@ -10,374 +10,390 @@ import { z } from 'zod' import { log } from '../config/logger' export interface CodeSearchRequestContext extends RequestContext { - caseSensitive?: boolean - maxResults?: number - includeContext?: boolean - contextLines?: number - maxFileSize?: number + caseSensitive?: boolean + maxResults?: number + includeContext?: boolean + contextLines?: number + maxFileSize?: number } interface RegexLike { - lastIndex?: number - exec: RegExp['exec'] + lastIndex?: number + exec: RegExp['exec'] } async function fileExists(filePath: string): Promise<boolean> { - try { - await fs.access(filePath) - return true - } catch { - return false - } + try { + await fs.access(filePath) + return true + } catch { + return false + } } const defaultGlobOptions: FastGlobOptions = { - onlyFiles: true, - absolute: true, - dot: true, - unique: true, - ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'], + onlyFiles: true, + absolute: true, + dot: true, + unique: true, + ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'], } async function globFiles( - pattern: string, - options?: FastGlobOptions + pattern: string, + options?: FastGlobOptions ): Promise<string[]> { - return fg(pattern, { ...defaultGlobOptions, ...options }) + return fg(pattern, { ...defaultGlobOptions, ...options }) } const codeSearchInputSchema = z.object({ - pattern: z.string().describe('Search pattern (string or regex)'), - target: z - .union([ - z.string().describe('File path or glob pattern'), - z.array(z.string()).describe('Array of file paths or globs'), - ]) - .describe('Files to search'), - options: z - .object({ - isRegex: z.boolean().optional().describe('Treat pattern as regex'), - caseSensitive: z - .boolean() - .optional() - .describe('Case-sensitive search'), - maxResults: z - .number() - .optional() - .describe('Maximum results to return'), - includeContext: z - .boolean() - .optional() - .describe('Include surrounding lines'), - contextLines: z - .number() - .optional() - .describe('Number of context lines'), - maxFileSize: z - .number() - .optional() - .describe('Skip files larger than this (bytes)'), - }) - .optional(), + pattern: z.string().describe('Search pattern (string or regex)'), + target: z + .union([ + z.string().describe('File path or glob pattern'), + z.array(z.string()).describe('Array of file paths or globs'), + ]) + .describe('Files to search'), + options: z + .object({ + isRegex: z.boolean().optional().describe('Treat pattern as regex'), + caseSensitive: z + .boolean() + .optional() + .describe('Case-sensitive search'), + maxResults: z + .number() + .optional() + .describe('Maximum results to return'), + includeContext: z + .boolean() + .optional() + .describe('Include surrounding lines'), + contextLines: z + .number() + .optional() + .describe('Number of context lines'), + maxFileSize: z + .number() + .optional() + .describe('Skip files larger than this (bytes)'), + }) + .optional(), }) const matchSchema = z.object({ - file: z.string(), - line: z.number(), - column: z.number(), - content: z.string(), - context: z - .object({ - before: z.array(z.string()), - after: z.array(z.string()), - }) - .optional(), + file: z.string(), + line: z.number(), + column: z.number(), + content: z.string(), + context: z + .object({ + before: z.array(z.string()), + after: z.array(z.string()), + }) + .optional(), }) const codeSearchOutputSchema = z.object({ - matches: z.array(matchSchema), - stats: z.object({ - totalMatches: z.number(), - filesSearched: z.number(), - filesWithMatches: z.number(), - }), - truncated: z.boolean(), + matches: z.array(matchSchema), + stats: z.object({ + totalMatches: z.number(), + filesSearched: z.number(), + filesWithMatches: z.number(), + }), + truncated: z.boolean(), }) export type CodeSearchInput = z.infer<typeof codeSearchInputSchema> export type CodeSearchOutput = z.infer<typeof codeSearchOutputSchema> export const codeSearchTool = createTool({ - id: 'coding:codeSearch', - description: `Search for patterns across source files. + id: 'coding:codeSearch', + description: `Search for patterns across source files. Supports string and regex patterns with context lines. Use for finding usages, identifying patterns, and code exploration.`, - inputSchema: codeSearchInputSchema, - outputSchema: codeSearchOutputSchema, - execute: async (input: z.infer<typeof codeSearchInputSchema>, context): Promise<CodeSearchOutput> => { - const writer = context?.writer - const abortSignal = context?.abortSignal - const requestContext = context?.requestContext as CodeSearchRequestContext | undefined - - // Check if operation was already cancelled - if (abortSignal?.aborted === true) { - throw new Error('Code search cancelled') - } - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `🔎 Starting code search for pattern '${input.pattern}'`, - stage: 'coding:codeSearch', - }, - id: 'coding:codeSearch', - }) - const tracingContext = context?.tracingContext - const span = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'code-search', - input: { - pattern: input.pattern, - targetCount: Array.isArray(input.target) ? input.target.length : 1, - }, - metadata: { - 'tool.id': 'coding:codeSearch', - 'tool.input.pattern': input.pattern, - operation: 'code-search', - }, - }) - - try { - const { pattern, target, options } = input - const isRegex = options?.isRegex ?? false - const caseSensitive = options?.caseSensitive ?? requestContext?.caseSensitive ?? false - const maxResults = options?.maxResults ?? requestContext?.maxResults ?? 100 - const includeContext = options?.includeContext ?? requestContext?.includeContext ?? true - const contextLines = options?.contextLines ?? requestContext?.contextLines ?? 2 - const maxFileSize = options?.maxFileSize ?? requestContext?.maxFileSize ?? 1_000_000 - - let filePaths: string[] = [] - const targets = Array.isArray(target) ? target : [target] - - for (const t of targets) { - if (t.includes('*')) { - const matches = await globFiles(t) - filePaths.push(...matches) - } else if (await fileExists(t)) { - const stat = await fs.stat(t) - if (stat.isFile()) { - filePaths.push(t) - } else if (stat.isDirectory()) { - const dirFiles = await globFiles(path.join(t, '**/*')) - filePaths.push(...dirFiles) - } + inputSchema: codeSearchInputSchema, + outputSchema: codeSearchOutputSchema, + execute: async ( + input: z.infer<typeof codeSearchInputSchema>, + context + ): Promise<CodeSearchOutput> => { + const writer = context?.writer + const abortSignal = context?.abortSignal + const requestContext = context?.requestContext as + | CodeSearchRequestContext + | undefined + + // Check if operation was already cancelled + if (abortSignal?.aborted === true) { + throw new Error('Code search cancelled') } - } - - filePaths = [...new Set(filePaths)] - - // Check for cancellation before file processing - if (abortSignal?.aborted) { - const cancelMessage = 'Code search cancelled during file processing' - span?.error({ error: new Error(cancelMessage), endSpan: true }) await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: `🛑 ${cancelMessage}`, - stage: 'coding:codeSearch', - }, - id: 'coding:codeSearch', + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `🔎 Starting code search for pattern '${input.pattern}'`, + stage: 'coding:codeSearch', + }, + id: 'coding:codeSearch', + }) + const tracingContext: TracingContext | undefined = context?.tracingContext + const span = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'code-search', + input: { + pattern: input.pattern, + targetCount: Array.isArray(input.target) + ? input.target.length + : 1, + }, + requestContext: context?.requestContext, + metadata: { + 'tool.id': 'coding:codeSearch', + 'tool.input.pattern': input.pattern, + operation: 'code-search', + }, }) - log.warn(cancelMessage) - throw new Error(cancelMessage) - } - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `📁 Files to search: ${filePaths.length}`, - stage: 'coding:codeSearch', - }, - id: 'coding:codeSearch', - }) - - const matches: Array<z.infer<typeof matchSchema>> = [] - const filesWithMatches = new Set<string>() - let truncated = false - - let searchRegex: RegexLike - try { - if (isRegex) { - searchRegex = new RE2( - pattern, - caseSensitive ? 'g' : 'gi' - ) as unknown as RegexLike - } else { - searchRegex = new RegExp( - pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), - caseSensitive ? 'g' : 'gi' - ) - } - } catch (error) { - throw new Error( - `Invalid ${isRegex ? 'regex' : 'pattern'}: ${error instanceof Error ? error.message : String(error)}` - ) - } - - for (const filePath of filePaths) { - if (matches.length >= maxResults) { - truncated = true - break - } try { - const stat = await fs.stat(filePath) - if (!stat.isFile()) { - continue - } - if (stat.size > maxFileSize) { - continue - } - const content = await fs.readFile(filePath, 'utf-8') - const lines = content.split('\n') - - for (let i = 0; i < lines.length; i++) { - if (matches.length >= maxResults) { - truncated = true - break + const { pattern, target, options } = input + const isRegex = options?.isRegex ?? false + const caseSensitive = + options?.caseSensitive ?? requestContext?.caseSensitive ?? false + const maxResults = + options?.maxResults ?? requestContext?.maxResults ?? 100 + const includeContext = + options?.includeContext ?? + requestContext?.includeContext ?? + true + const contextLines = + options?.contextLines ?? requestContext?.contextLines ?? 2 + const maxFileSize = + options?.maxFileSize ?? requestContext?.maxFileSize ?? 1_000_000 + + let filePaths: string[] = [] + const targets = Array.isArray(target) ? target : [target] + + for (const t of targets) { + if (t.includes('*')) { + const matches = await globFiles(t) + filePaths.push(...matches) + } else if (await fileExists(t)) { + const stat = await fs.stat(t) + if (stat.isFile()) { + filePaths.push(t) + } else if (stat.isDirectory()) { + const dirFiles = await globFiles(path.join(t, '**/*')) + filePaths.push(...dirFiles) + } + } } - const line = lines[i] - if (typeof searchRegex.lastIndex === 'number') { - searchRegex.lastIndex = 0 + filePaths = [...new Set(filePaths)] + + // Check for cancellation before file processing + if (abortSignal?.aborted) { + const cancelMessage = + 'Code search cancelled during file processing' + span?.error({ error: new Error(cancelMessage), endSpan: true }) + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `🛑 ${cancelMessage}`, + stage: 'coding:codeSearch', + }, + id: 'coding:codeSearch', + }) + log.warn(cancelMessage) + throw new Error(cancelMessage) } - let match - - while ((match = searchRegex.exec(line)) !== null) { - filesWithMatches.add(filePath) - - const contextObj = includeContext - ? { - before: lines.slice( - Math.max(0, i - contextLines), - i - ), - after: lines.slice( - i + 1, - i + 1 + contextLines - ), + + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `📁 Files to search: ${filePaths.length}`, + stage: 'coding:codeSearch', + }, + id: 'coding:codeSearch', + }) + + const matches: Array<z.infer<typeof matchSchema>> = [] + const filesWithMatches = new Set<string>() + let truncated = false + + let searchRegex: RegexLike + try { + if (isRegex) { + searchRegex = new RE2( + pattern, + caseSensitive ? 'g' : 'gi' + ) as unknown as RegexLike + } else { + searchRegex = new RegExp( + pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), + caseSensitive ? 'g' : 'gi' + ) } - : undefined - - matches.push({ - file: filePath, - line: i + 1, - column: match.index + 1, - content: line, - context: contextObj, - }) - - if (!isRegex) { - break - } + } catch (error) { + throw new Error( + `Invalid ${isRegex ? 'regex' : 'pattern'}: ${error instanceof Error ? error.message : String(error)}` + ) } - } - } catch { - // Skip binary or unreadable files - } - } - - const result = { - matches, - stats: { - totalMatches: matches.length, - filesSearched: filePaths.length, - filesWithMatches: filesWithMatches.size, - }, - truncated, - } - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: `✅ Code search complete: ${result.stats.totalMatches} matches across ${result.stats.filesWithMatches} files`, - stage: 'coding:codeSearch', - }, - id: 'coding:codeSearch', - }) - span?.end() - return result - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error) - - // Handle AbortError specifically - if (error instanceof Error && error.name === 'AbortError') { - const cancelMessage = `Code search cancelled for pattern "${input.pattern}"` - span?.error({ error: new Error(cancelMessage), endSpan: true }) - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: `🛑 ${cancelMessage}`, - stage: 'coding:codeSearch', - }, - id: 'coding:codeSearch', - }) + for (const filePath of filePaths) { + if (matches.length >= maxResults) { + truncated = true + break + } + + try { + const stat = await fs.stat(filePath) + if (!stat.isFile()) { + continue + } + if (stat.size > maxFileSize) { + continue + } + const content = await fs.readFile(filePath, 'utf-8') + const lines = content.split('\n') + + for (let i = 0; i < lines.length; i++) { + if (matches.length >= maxResults) { + truncated = true + break + } + + const line = lines[i] + if (typeof searchRegex.lastIndex === 'number') { + searchRegex.lastIndex = 0 + } + let match + + while ((match = searchRegex.exec(line)) !== null) { + filesWithMatches.add(filePath) + + const contextObj = includeContext + ? { + before: lines.slice( + Math.max(0, i - contextLines), + i + ), + after: lines.slice( + i + 1, + i + 1 + contextLines + ), + } + : undefined + + matches.push({ + file: filePath, + line: i + 1, + column: match.index + 1, + content: line, + context: contextObj, + }) + + if (!isRegex) { + break + } + } + } + } catch { + // Skip binary or unreadable files + } + } - log.warn(cancelMessage) - throw new Error(cancelMessage) - } + const result = { + matches, + stats: { + totalMatches: matches.length, + filesSearched: filePaths.length, + filesWithMatches: filesWithMatches.size, + }, + truncated, + } - span?.error({ error: new Error(errorMessage), endSpan: true }) - throw error - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Code search tool input streaming started', { - toolCallId, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputStart', - }) - }, - onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { - log.info('Code search tool received input chunk', { - toolCallId, - inputTextDelta, - abortSignal: abortSignal?.aborted, - messageCount: messages.length, - hook: 'onInputDelta', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - const targetDesc = Array.isArray(input.target) - ? `${input.target.length} targets` - : input.target - log.info('Code search received complete input', { - toolCallId, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - pattern: input.pattern, - target: targetDesc, - isRegex: input.options?.isRegex ?? false, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('Code search completed', { - toolCallId, - toolName, - abortSignal: abortSignal?.aborted, - totalMatches: output.stats.totalMatches, - filesWithMatches: output.stats.filesWithMatches, - filesSearched: output.stats.filesSearched, - truncated: output.truncated, - hook: 'onOutput', - }) - }, + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `✅ Code search complete: ${result.stats.totalMatches} matches across ${result.stats.filesWithMatches} files`, + stage: 'coding:codeSearch', + }, + id: 'coding:codeSearch', + }) + span?.end() + return result + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error) + + // Handle AbortError specifically + if (error instanceof Error && error.name === 'AbortError') { + const cancelMessage = `Code search cancelled for pattern "${input.pattern}"` + span?.error({ error: new Error(cancelMessage), endSpan: true }) + + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `🛑 ${cancelMessage}`, + stage: 'coding:codeSearch', + }, + id: 'coding:codeSearch', + }) + + log.warn(cancelMessage) + throw new Error(cancelMessage) + } + + span?.error({ error: new Error(errorMessage), endSpan: true }) + throw error + } + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Code search tool input streaming started', { + toolCallId, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputStart', + }) + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('Code search tool received input chunk', { + toolCallId, + inputTextDelta, + abortSignal: abortSignal?.aborted, + messageCount: messages.length, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + const targetDesc = Array.isArray(input.target) + ? `${input.target.length} targets` + : input.target + log.info('Code search received complete input', { + toolCallId, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + pattern: input.pattern, + target: targetDesc, + isRegex: input.options?.isRegex ?? false, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('Code search completed', { + toolCallId, + toolName, + abortSignal: abortSignal?.aborted, + totalMatches: output.stats.totalMatches, + filesWithMatches: output.stats.filesWithMatches, + filesSearched: output.stats.filesSearched, + truncated: output.truncated, + hook: 'onOutput', + }) + }, }) diff --git a/src/mastra/tools/color-change-tool.ts b/src/mastra/tools/color-change-tool.ts index dc446ce6..11833910 100644 --- a/src/mastra/tools/color-change-tool.ts +++ b/src/mastra/tools/color-change-tool.ts @@ -6,138 +6,144 @@ import z from 'zod' import { log } from '../config/logger' export interface ColorChangeRequestContext extends RequestContext { - theme?: string - userId?: string - workspaceId?: string + theme?: string + userId?: string + workspaceId?: string } export function changeBgColor(color: string) { - if (typeof window !== 'undefined') { - document.body.style.setProperty('--background', color) - } + if (typeof window !== 'undefined') { + document.body.style.setProperty('--background', color) + } } export const colorChangeTool = createTool({ - id: 'changeColor', - description: 'Changes the background color', - inputSchema: z.object({ - color: z.string(), - }), - execute: async (input, context) => { - const { color } = input; - const writer = context?.writer; - const abortSignal = context?.abortSignal; - const tracingContext = context?.tracingContext; - const requestCtx = context?.requestContext as ColorChangeRequestContext | undefined; - const userId = requestCtx?.userId; - const workspaceId = requestCtx?.workspaceId; + id: 'changeColor', + description: 'Changes the background color', + inputSchema: z.object({ + color: z.string(), + }), + execute: async (input, context) => { + const { color } = input + const writer = context?.writer + const abortSignal = context?.abortSignal + const tracingContext: TracingContext | undefined = context?.tracingContext + const requestCtx = context?.requestContext as + | ColorChangeRequestContext + | undefined + const userId = requestCtx?.userId + const workspaceId = requestCtx?.workspaceId - // Respect cancellation early - if (abortSignal?.aborted ?? false) { - throw new Error('Change color operation cancelled') - } + // Respect cancellation early + if (abortSignal?.aborted ?? false) { + throw new Error('Change color operation cancelled') + } - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `🎨 Changing background color to ${color}`, - stage: 'changeColor', - }, - id: 'changeColor', - }) + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `🎨 Changing background color to ${color}`, + stage: 'changeColor', + }, + id: 'changeColor', + }) - const span = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'change-color', - input: { color }, - metadata: { - 'tool.id': 'changeColor', - 'tool.input.color': color, - 'user.id': userId, - 'workspace.id': workspaceId, - }, - }) + const span = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'change-color', + input: { color }, + requestContext: context?.requestContext, + metadata: { + 'tool.id': 'changeColor', + 'tool.input.color': color, + 'user.id': userId, + 'workspace.id': workspaceId, + }, + }) - try { - // On the server, we just return the color. - // The client-side UI will handle the actual style change when it receives the tool result or call. + try { + // On the server, we just return the color. + // The client-side UI will handle the actual style change when it receives the tool result or call. - span?.update({ - output: { success: true, color }, - metadata: { - 'tool.output.success': true, - }, - }) - span?.end() + span?.update({ + output: { success: true, color }, + metadata: { + 'tool.output.success': true, + }, + }) + span?.end() - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: `✅ Background color changed to ${color}`, - stage: 'changeColor', - }, - id: 'changeColor', - }) + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `✅ Background color changed to ${color}`, + stage: 'changeColor', + }, + id: 'changeColor', + }) - return { success: true, color } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) + return { success: true, color } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error) - span?.error({ - error: error instanceof Error ? error : new Error(errorMessage), - endSpan: true, - }) + span?.error({ + error: error instanceof Error ? error : new Error(errorMessage), + endSpan: true, + }) - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: `Input: color="${color}" - ❌ Failed to change color: ${errorMessage}`, - stage: 'changeColor', - }, - id: 'changeColor', - }) + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `Input: color="${color}" - ❌ Failed to change color: ${errorMessage}`, + stage: 'changeColor', + }, + id: 'changeColor', + }) - log.error(`Failed to change background color to ${color}: ${errorMessage}`) - throw error - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Color change tool input streaming started', { - toolCallId, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputStart', - }) - }, - onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { - log.info('Color change received input chunk', { - toolCallId, - inputTextDelta, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputDelta', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Color change received complete input', { - toolCallId, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - color: input.color, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('Color change completed', { - toolCallId, - toolName, - abortSignal: abortSignal?.aborted, - success: output.success, - color: output.color, - hook: 'onOutput', - }) - }, + log.error( + `Failed to change background color to ${color}: ${errorMessage}` + ) + throw error + } + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Color change tool input streaming started', { + toolCallId, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputStart', + }) + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('Color change received input chunk', { + toolCallId, + inputTextDelta, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Color change received complete input', { + toolCallId, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + color: input.color, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('Color change completed', { + toolCallId, + toolName, + abortSignal: abortSignal?.aborted, + success: output.success, + color: output.color, + hook: 'onOutput', + }) + }, }) diff --git a/src/mastra/tools/confirmation.tool.ts b/src/mastra/tools/confirmation.tool.ts index 117bc19d..9a2b3f66 100644 --- a/src/mastra/tools/confirmation.tool.ts +++ b/src/mastra/tools/confirmation.tool.ts @@ -2,6 +2,7 @@ import { createTool } from '@mastra/core/tools' import { z } from 'zod' import { SpanType } from '@mastra/core/observability' import type { TracingContext } from '@mastra/core/observability' +import { log } from '../config/logger' const confirmationTool = createTool({ id: 'confirmation-tool', @@ -96,6 +97,41 @@ const confirmationTool = createTool({ return result }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Confirmation tool input streaming started', { + toolCallId, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputStart', + }) + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('Confirmation tool received input chunk', { + toolCallId, + inputTextDelta, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Confirmation tool received input', { + toolCallId, + messageCount: messages.length, + inputData: { action: input.action }, + abortSignal: abortSignal?.aborted, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('Confirmation tool completed', { + toolCallId, + toolName, + outputData: { confirmed: output.confirmed }, + abortSignal: abortSignal?.aborted, + hook: 'onOutput', + }) + }, }) export { confirmationTool } diff --git a/src/mastra/tools/copywriter-agent-tool.ts b/src/mastra/tools/copywriter-agent-tool.ts index 75cbd104..53666c60 100644 --- a/src/mastra/tools/copywriter-agent-tool.ts +++ b/src/mastra/tools/copywriter-agent-tool.ts @@ -76,11 +76,14 @@ export const copywriterTool = createTool({ execute: async (input, context) => { const writer = context?.writer const mastra = context?.mastra - const tracingContext = context?.tracingContext - const requestCtx = context?.requestContext as CopywriterRequestContext | undefined + const tracingContext: TracingContext | undefined = context?.tracingContext + const requestCtx = context?.requestContext as + | CopywriterRequestContext + | undefined const userId = - typeof requestCtx?.userId === 'string' && requestCtx.userId.trim().length > 0 + typeof requestCtx?.userId === 'string' && + requestCtx.userId.trim().length > 0 ? requestCtx.userId : 'anonymous' const { @@ -112,6 +115,7 @@ export const copywriterTool = createTool({ tone, length, }, + requestContext: context?.requestContext, metadata: { 'tool.id': 'copywriter-agent', 'user.id': requestCtx?.userId, @@ -357,7 +361,7 @@ export const copywriterTool = createTool({ 'tool.output.success': true, 'tool.output.wordCount': wordCount, 'tool.output.contentLength': content.length, - } + }, }) span?.end() diff --git a/src/mastra/tools/csv-to-json.tool.ts b/src/mastra/tools/csv-to-json.tool.ts index 41c5923f..dc1abd38 100644 --- a/src/mastra/tools/csv-to-json.tool.ts +++ b/src/mastra/tools/csv-to-json.tool.ts @@ -50,7 +50,9 @@ export const csvToJsonTool = createTool({ }), execute: async (input, context) => { const writer = context?.writer - const requestContext = context?.requestContext as CsvToJsonRequestContext | undefined + const requestContext = context?.requestContext as + | CsvToJsonRequestContext + | undefined const abortSignal = context?.abortSignal const tracingContext = context?.tracingContext @@ -74,6 +76,7 @@ export const csvToJsonTool = createTool({ type: SpanType.TOOL_CALL, name: 'csv-to-json-conversion', input, + requestContext: context?.requestContext, metadata: { 'tool.id': 'csv-to-json', 'tool.input.hasFilePath': !!input.filePath, @@ -88,7 +91,9 @@ export const csvToJsonTool = createTool({ // Check for cancellation before file operations if (abortSignal?.aborted) { csvSpan?.error({ - error: new Error('Operation cancelled during file operations'), + error: new Error( + 'Operation cancelled during file operations' + ), endSpan: true, }) throw new Error( @@ -98,15 +103,9 @@ export const csvToJsonTool = createTool({ let contentToParse = input.csvData - if ( - input.filePath !== undefined && - input.filePath !== null - ) { + if (input.filePath !== undefined && input.filePath !== null) { try { - contentToParse = await fs.readFile( - input.filePath, - 'utf-8' - ) + contentToParse = await fs.readFile(input.filePath, 'utf-8') } catch (err) { throw new Error( `Failed to read file at ${input.filePath}: ${(err as Error).message}` @@ -208,7 +207,9 @@ export const csvToJsonTool = createTool({ }) }, onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - const source = input.filePath ? `file:${input.filePath}` : 'raw CSV data' + const source = input.filePath + ? `file:${input.filePath}` + : 'raw CSV data' const options = input.options ?? {} log.info('CSV to JSON received complete input', { toolCallId, diff --git a/src/mastra/tools/cytoscape.tool.ts b/src/mastra/tools/cytoscape.tool.ts index b80f96cf..d854e235 100644 --- a/src/mastra/tools/cytoscape.tool.ts +++ b/src/mastra/tools/cytoscape.tool.ts @@ -1,5 +1,5 @@ import type { RequestContext } from '@mastra/core/request-context' -import type { InferUITool } from '@mastra/core/tools'; +import type { InferUITool } from '@mastra/core/tools' import { createTool } from '@mastra/core/tools' import type { TracingContext } from '@mastra/core/observability' import { SpanType } from '@mastra/core/observability' @@ -63,7 +63,7 @@ export const cytoscapeTool = createTool({ const { nodes, edges, layout } = input const writer = context?.writer const abortSignal = context?.abortSignal - const tracingContext = context?.tracingContext + const tracingContext: TracingContext | undefined = context?.tracingContext const requestCtx = context?.requestContext as | CytoscapeContext | undefined @@ -79,6 +79,7 @@ export const cytoscapeTool = createTool({ type: SpanType.TOOL_CALL, name: 'cytoscape-generator', input, + requestContext: context?.requestContext, metadata: { 'tool.id': 'cytoscape-generator', 'tool.input.nodesCount': nodes.length, @@ -88,7 +89,10 @@ export const cytoscapeTool = createTool({ }, }) const startTime = Date.now() - logToolExecution('cytoscape-generator', { nodes: nodes.length, edges: edges.length }) + logToolExecution('cytoscape-generator', { + nodes: nodes.length, + edges: edges.length, + }) await writer?.custom({ type: 'data-tool-progress', @@ -185,7 +189,11 @@ export const cytoscapeTool = createTool({ toolSpan?.end() // Log success - logToolExecution('cytoscape-generator', { nodes: nodes.length, edges: edges.length }, { elements: elements.length, durationMs: duration }) + logToolExecution( + 'cytoscape-generator', + { nodes: nodes.length, edges: edges.length }, + { elements: elements.length, durationMs: duration } + ) await writer?.custom({ type: 'data-tool-progress', @@ -235,4 +243,3 @@ export const cytoscapeTool = createTool({ }) export type CytoscapeUITool = InferUITool<typeof cytoscapeTool> - diff --git a/src/mastra/tools/data-file-manager.ts b/src/mastra/tools/data-file-manager.ts index bfbaecaa..d1aa9ec0 100644 --- a/src/mastra/tools/data-file-manager.ts +++ b/src/mastra/tools/data-file-manager.ts @@ -13,22 +13,22 @@ // approvedBy: sam // approvalDate: 9/22 //import type { RequestContext } from '@mastra/core/request-context'; -import { SpanType } from "@mastra/core/observability"; -import type { RequestContext } from "@mastra/core/request-context"; -import type { InferUITool } from "@mastra/core/tools"; -import { createTool } from "@mastra/core/tools"; -import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; -import { pipeline } from 'node:stream/promises'; -import * as zlib from 'zlib'; -import { z } from 'zod'; -import { log } from '../config/logger'; - +import { SpanType } from '@mastra/core/observability' +import type { TracingContext } from '@mastra/core/observability' +import type { RequestContext } from '@mastra/core/request-context' +import type { InferUITool } from '@mastra/core/tools' +import { createTool } from '@mastra/core/tools' +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import { pipeline } from 'node:stream/promises' +import * as zlib from 'zlib' +import { z } from 'zod' +import { log } from '../config/logger' // Define runtime context for these tools export interface DataFileManagerContext extends RequestContext { - userId?: string - workspaceId?: string + userId?: string + workspaceId?: string } const DATA_DIR = path.join(process.cwd(), './data') @@ -37,14 +37,14 @@ const DATA_DIR = path.join(process.cwd(), './data') * Ensures the data directory exists */ async function ensureDataDir(): Promise<void> { - try { - await fs.mkdir(DATA_DIR, { recursive: true }) - } catch (error) { - // Directory might already exist, ignore error - if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { - throw error + try { + await fs.mkdir(DATA_DIR, { recursive: true }) + } catch (error) { + // Directory might already exist, ignore error + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + throw error + } } - } } /** @@ -55,1208 +55,1506 @@ async function ensureDataDir(): Promise<void> { */ function validateDataPath(filePath: string): string { - const absolutePath = path.resolve(DATA_DIR, filePath) - if (!absolutePath.startsWith(DATA_DIR)) { - throw new Error( - `Access denied: File path "${filePath}" is outside the allowed data directory.` - ) - } - return absolutePath + const absolutePath = path.resolve(DATA_DIR, filePath) + if (!absolutePath.startsWith(DATA_DIR)) { + throw new Error( + `Access denied: File path "${filePath}" is outside the allowed data directory.` + ) + } + return absolutePath } export const readDataFileTool = createTool({ - id: 'read:file', - description: 'Reads content from a file within the data directory.', - inputSchema: z.object({ - fileName: z - .string() - .describe( - 'The name of the file (relative to the data/ directory).' - ), - }), - outputSchema: z.string().describe('The content of the file as a string.'), - execute: async (input, context) => { - const writer = context?.writer; - const requestCtx = context?.requestContext as DataFileManagerContext | undefined; - const tracingContext = context?.tracingContext; - await ensureDataDir(); - await writer?.custom({ type: 'data-tool-progress', data: { status: 'in-progress', message: '📖 Reading file: ' + input.fileName, stage: 'read:file' }, id: 'read:file' }); - const readSpan = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'read:file', - input, - metadata: { - 'tool.id': 'read:file', - 'tool.input.fileName': input.fileName, - 'user.id': requestCtx?.userId, - } - }); + id: 'read:file', + description: 'Reads content from a file within the data directory.', + inputSchema: z.object({ + fileName: z + .string() + .describe( + 'The name of the file (relative to the data/ directory).' + ), + }), + outputSchema: z.string().describe('The content of the file as a string.'), + execute: async (input, context) => { + const writer = context?.writer + const requestCtx = context?.requestContext as + | DataFileManagerContext + | undefined + const tracingContext = context?.tracingContext + await ensureDataDir() + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: '📖 Reading file: ' + input.fileName, + stage: 'read:file', + }, + id: 'read:file', + }) + const readSpan = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'read:file', + input, + requestContext: context?.requestContext, + metadata: { + 'tool.id': 'read:file', + 'tool.input.fileName': input.fileName, + 'user.id': requestCtx?.userId, + }, + }) - try { - const { fileName } = input - const fullPath = validateDataPath(fileName) - // Resolve the real path to protect against symlink/relative attacks, then validate it is inside DATA_DIR. - const realFullPath = await fs.realpath(fullPath) - if (!realFullPath.startsWith(DATA_DIR)) { - throw new Error( - `Access denied: File path "${fileName}" is outside the allowed data directory.` - ) - } - const content = await fs.readFile(realFullPath, 'utf-8') - log.info(`Read file: ${fileName}`) - readSpan?.update({ - output: { fileSize: content.length }, - metadata: { - 'tool.output.fileSize': content.length + try { + const { fileName } = input + const fullPath = validateDataPath(fileName) + // Resolve the real path to protect against symlink/relative attacks, then validate it is inside DATA_DIR. + const realFullPath = await fs.realpath(fullPath) + if (!realFullPath.startsWith(DATA_DIR)) { + throw new Error( + `Access denied: File path "${fileName}" is outside the allowed data directory.` + ) + } + const content = await fs.readFile(realFullPath, 'utf-8') + log.info(`Read file: ${fileName}`) + readSpan?.update({ + output: { fileSize: content.length }, + metadata: { + 'tool.output.fileSize': content.length, + }, + }) + readSpan?.end() + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: '✅ File read successfully', + stage: 'read:file', + }, + id: 'read:file', + }) + return content + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error) + readSpan?.error({ + error: error instanceof Error ? error : new Error(errorMessage), + endSpan: true, + }) + throw error } - }); - readSpan?.end(); - await writer?.custom({ type: 'data-tool-progress', data: { status: 'done', message: '✅ File read successfully', stage: 'read:file' }, id: 'read:file' }); - return content - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error) - readSpan?.error({ - error: error instanceof Error ? error : new Error(errorMessage), - endSpan: true, - }) - throw error - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Read file tool input streaming started', { - toolCallId, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputStart', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Read file received complete input', { - toolCallId, - messageCount: messages.length, - fileName: input.fileName, - abortSignal: abortSignal?.aborted, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('Read file completed', { - toolCallId, - toolName, - fileSize: output.length, - abortSignal: abortSignal?.aborted, - hook: 'onOutput', - }) - }, + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Read file tool input streaming started', { + toolCallId, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputStart', + }) + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('Read file received input chunk', { + toolCallId, + inputTextDelta, + abortSignal: abortSignal?.aborted, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Read file received complete input', { + toolCallId, + messageCount: messages.length, + fileName: input.fileName, + abortSignal: abortSignal?.aborted, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('Read file completed', { + toolCallId, + toolName, + fileSize: output.length, + abortSignal: abortSignal?.aborted, + hook: 'onOutput', + }) + }, }) -export type ReadDataFileUITool = InferUITool<typeof readDataFileTool>; +export type ReadDataFileUITool = InferUITool<typeof readDataFileTool> export const writeDataFileTool = createTool({ - id: 'write:file', - description: - 'Writes content to a file within the data directory. If the file does not exist, it will be created. If it exists, its content will be overwritten.', - inputSchema: z.object({ - fileName: z - .string() - .describe( - 'The name of the file (relative to the data/ directory).' - ), - content: z.string().describe('The content to write to the file.'), - }), - outputSchema: z - .string() - .describe('A confirmation string indicating success.'), - execute: async (input, context) => { - const writer = context?.writer; - const requestCtx = context?.requestContext as DataFileManagerContext | undefined; - const tracingContext = context?.tracingContext; - await writer?.custom({ type: 'data-tool-progress', data: { status: 'in-progress', message: '💾 Writing to file: ' + input.fileName, stage: 'write:file' }, id: 'write:file' }); - const writeSpan = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'write:file', - input, - metadata: { - 'tool.id': 'write:file', - 'tool.input.fileName': input.fileName, - 'user.id': requestCtx?.userId, - } - }); + id: 'write:file', + description: + 'Writes content to a file within the data directory. If the file does not exist, it will be created. If it exists, its content will be overwritten.', + inputSchema: z.object({ + fileName: z + .string() + .describe( + 'The name of the file (relative to the data/ directory).' + ), + content: z.string().describe('The content to write to the file.'), + }), + outputSchema: z + .string() + .describe('A confirmation string indicating success.'), + execute: async (input, context) => { + const writer = context?.writer + const requestCtx = context?.requestContext as + | DataFileManagerContext + | undefined + const tracingContext = context?.tracingContext + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: '💾 Writing to file: ' + input.fileName, + stage: 'write:file', + }, + id: 'write:file', + }) + const writeSpan = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'write:file', + input, + requestContext: context?.requestContext, + metadata: { + 'tool.id': 'write:file', + 'tool.input.fileName': input.fileName, + 'user.id': requestCtx?.userId, + }, + }) - try { - const { fileName, content } = input - const fullPath = validateDataPath(fileName) - // Resolve the real path to protect against symlink/relative attacks, then validate it is inside DATA_DIR. - const realFullPath = await fs.realpath(fullPath) - if (!realFullPath.startsWith(DATA_DIR)) { - throw new Error( - `Access denied: File path "${fileName}" is outside the allowed data directory.` - ) - } - const realDirPath = path.dirname(realFullPath) - // Defensive: Ensure realDirPath is within DATA_DIR before creating directory - if (!realDirPath.startsWith(DATA_DIR)) { - throw new Error( - `Access denied: Directory path "${realDirPath}" is outside the allowed data directory.` - ) - } - await fs.mkdir(realDirPath, { recursive: true }) - await fs.writeFile(realFullPath, content, 'utf-8') - log.info(`Written to file: ${fileName}`) - writeSpan?.update({ - output: { success: true }, - metadata: { - 'tool.output.success': true + try { + const { fileName, content } = input + const fullPath = validateDataPath(fileName) + // Resolve the real path to protect against symlink/relative attacks, then validate it is inside DATA_DIR. + const realFullPath = await fs.realpath(fullPath) + if (!realFullPath.startsWith(DATA_DIR)) { + throw new Error( + `Access denied: File path "${fileName}" is outside the allowed data directory.` + ) + } + const realDirPath = path.dirname(realFullPath) + // Defensive: Ensure realDirPath is within DATA_DIR before creating directory + if (!realDirPath.startsWith(DATA_DIR)) { + throw new Error( + `Access denied: Directory path "${realDirPath}" is outside the allowed data directory.` + ) + } + await fs.mkdir(realDirPath, { recursive: true }) + await fs.writeFile(realFullPath, content, 'utf-8') + log.info(`Written to file: ${fileName}`) + writeSpan?.update({ + output: { success: true }, + metadata: { + 'tool.output.success': true, + }, + }) + writeSpan?.end() + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: '✅ File written successfully', + stage: 'write:file', + }, + id: 'write:file', + }) + return `File ${fileName} written successfully.` + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error) + writeSpan?.error({ + error: error instanceof Error ? error : new Error(errorMessage), + endSpan: true, + }) + throw error } - }); - writeSpan?.end(); - await writer?.custom({ type: 'data-tool-progress', data: { status: 'done', message: '✅ File written successfully', stage: 'write:file' }, id: 'write:file' }); - return `File ${fileName} written successfully.` - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error) - writeSpan?.error({ - error: error instanceof Error ? error : new Error(errorMessage), - endSpan: true, - }) - throw error - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Write file tool input streaming started', { - toolCallId, - messageCount: messages.length, - hook: 'onInputStart', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Write file received complete input', { - toolCallId, - messageCount: messages.length, - fileName: input.fileName, - contentLength: input.content.length, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('Write file completed', { - toolCallId, - toolName, - success: output.includes('successfully'), - hook: 'onOutput', - }) - }, + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Write file tool input streaming started', { + toolCallId, + messageCount: messages.length, + hook: 'onInputStart', + }) + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('Write file received input chunk', { + toolCallId, + inputTextDelta, + abortSignal: abortSignal?.aborted, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Write file received complete input', { + toolCallId, + messageCount: messages.length, + fileName: input.fileName, + contentLength: input.content.length, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('Write file completed', { + toolCallId, + toolName, + success: output.includes('successfully'), + hook: 'onOutput', + }) + }, }) -export type WriteDataFileUITool = InferUITool<typeof writeDataFileTool>; +export type WriteDataFileUITool = InferUITool<typeof writeDataFileTool> export const deleteDataFileTool = createTool({ - id: 'delete:file', - description: 'Deletes a file within the data directory.', - inputSchema: z.object({ - fileName: z - .string() - .describe( - 'The name of the file (relative to the data/ directory).' - ), - }), - outputSchema: z - .string() - .describe('A confirmation string indicating success.'), - execute: async (input, context) => { - const writer = context?.writer; - const requestCtx = context?.requestContext as DataFileManagerContext | undefined; - const tracingContext = context?.tracingContext; - await writer?.custom({ type: 'data-tool-progress', data: { status: 'in-progress', message: '🗑️ Deleting file: ' + input.fileName, stage: 'delete:file' }, id: 'delete:file' }); - const deleteSpan = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'delete:file', - input, - metadata: { - 'tool.id': 'delete:file', - 'tool.input.fileName': input.fileName, - 'user.id': requestCtx?.userId, - } - }); + id: 'delete:file', + description: 'Deletes a file within the data directory.', + inputSchema: z.object({ + fileName: z + .string() + .describe( + 'The name of the file (relative to the data/ directory).' + ), + }), + outputSchema: z + .string() + .describe('A confirmation string indicating success.'), + execute: async (input, context) => { + const writer = context?.writer + const requestCtx = context?.requestContext as + | DataFileManagerContext + | undefined + const tracingContext = context?.tracingContext + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: '🗑️ Deleting file: ' + input.fileName, + stage: 'delete:file', + }, + id: 'delete:file', + }) + const deleteSpan = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'delete:file', + input, + requestContext: context?.requestContext, + metadata: { + 'tool.id': 'delete:file', + 'tool.input.fileName': input.fileName, + 'user.id': requestCtx?.userId, + }, + }) - try { - const { fileName } = input - const fullPath = validateDataPath(fileName) - // Defensive: Ensure fullPath is within DATA_DIR before deleting - if (!fullPath.startsWith(DATA_DIR)) { - throw new Error( - `Access denied: File path "${fileName}" is outside the allowed data directory.` - ) - } - await fs.unlink(fullPath) - log.info(`Deleted file: ${fileName}`) - deleteSpan?.update({ - output: { success: true }, - metadata: { - 'tool.output.success': true + try { + const { fileName } = input + const fullPath = validateDataPath(fileName) + // Defensive: Ensure fullPath is within DATA_DIR before deleting + if (!fullPath.startsWith(DATA_DIR)) { + throw new Error( + `Access denied: File path "${fileName}" is outside the allowed data directory.` + ) + } + await fs.unlink(fullPath) + log.info(`Deleted file: ${fileName}`) + deleteSpan?.update({ + output: { success: true }, + metadata: { + 'tool.output.success': true, + }, + }) + deleteSpan?.end() + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: '✅ File deleted successfully', + stage: 'delete:file', + }, + id: 'delete:file', + }) + return `File ${fileName} deleted successfully.` + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error) + deleteSpan?.error({ + error: error instanceof Error ? error : new Error(errorMessage), + endSpan: true, + }) + throw error } - }); - deleteSpan?.end(); - await writer?.custom({ type: 'data-tool-progress', data: { status: 'done', message: '✅ File deleted successfully', stage: 'delete:file' }, id: 'delete:file' }); - return `File ${fileName} deleted successfully.` - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error) - deleteSpan?.error({ - error: error instanceof Error ? error : new Error(errorMessage), - endSpan: true, - }) - throw error - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Delete file tool input streaming started', { - toolCallId, - messageCount: messages.length, - hook: 'onInputStart', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Delete file received complete input', { - toolCallId, - messageCount: messages.length, - fileName: input.fileName, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('Delete file completed', { - toolCallId, - toolName, - success: output.includes('successfully'), - hook: 'onOutput', - }) - }, + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Delete file tool input streaming started', { + toolCallId, + messageCount: messages.length, + hook: 'onInputStart', + }) + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('Delete file received input chunk', { + toolCallId, + inputTextDelta, + abortSignal: abortSignal?.aborted, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Delete file received complete input', { + toolCallId, + messageCount: messages.length, + fileName: input.fileName, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('Delete file completed', { + toolCallId, + toolName, + success: output.includes('successfully'), + hook: 'onOutput', + }) + }, }) -export type DeleteDataFileUITool = InferUITool<typeof deleteDataFileTool>; +export type DeleteDataFileUITool = InferUITool<typeof deleteDataFileTool> export const listDataDirTool = createTool({ - id: 'list:directory', - description: - 'Lists files and directories within a specified path in the data directory.', - inputSchema: z.object({ - dirPath: z - .string() - .optional() - .describe( - "The path within the data directory to list (e.g., '', 'subfolder/')." - ), - }), - outputSchema: z - .array(z.string()) - .describe('An array of file and directory names.'), - execute: async (input, context) => { - const writer = context?.writer; - const requestCtx = context?.requestContext as DataFileManagerContext | undefined; - const tracingContext = context?.tracingContext; - await writer?.custom({ type: 'data-tool-progress', data: { status: 'in-progress', message: '📂 Listing directory: ' + (input.dirPath ?? 'docs/data'), stage: 'list:directory' }, id: 'list:directory' }); - const listSpan = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'list:directory', - input, - metadata: { - 'tool.id': 'list:directory', - 'tool.input.dirPath': input.dirPath ?? 'docs/data', - 'user.id': requestCtx?.userId, - } - }); + id: 'list:directory', + description: + 'Lists files and directories within a specified path in the data directory.', + inputSchema: z.object({ + dirPath: z + .string() + .optional() + .describe( + "The path within the data directory to list (e.g., '', 'subfolder/')." + ), + }), + outputSchema: z + .array(z.string()) + .describe('An array of file and directory names.'), + execute: async (input, context) => { + const writer = context?.writer + const requestCtx = context?.requestContext as + | DataFileManagerContext + | undefined + const tracingContext = context?.tracingContext + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: + '📂 Listing directory: ' + (input.dirPath ?? 'docs/data'), + stage: 'list:directory', + }, + id: 'list:directory', + }) + const listSpan = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'list:directory', + input, + metadata: { + 'tool.id': 'list:directory', + 'tool.input.dirPath': input.dirPath ?? 'docs/data', + 'user.id': requestCtx?.userId, + }, + }) - try { - const { dirPath = 'docs/data' } = input - const fullPath = validateDataPath(dirPath) - // Defensive: Ensure fullPath is within DATA_DIR before reading directory - if (!fullPath.startsWith(DATA_DIR)) { - throw new Error( - `Access denied: Directory path "${dirPath}" is outside the allowed data directory.` - ) - } - const contents = await fs.readdir(fullPath) - log.info(`Listed directory: ${dirPath}`) - listSpan?.update({ - output: { count: contents.length }, - metadata: { - 'tool.output.count': contents.length + try { + const { dirPath = 'docs/data' } = input + const fullPath = validateDataPath(dirPath) + // Defensive: Ensure fullPath is within DATA_DIR before reading directory + if (!fullPath.startsWith(DATA_DIR)) { + throw new Error( + `Access denied: Directory path "${dirPath}" is outside the allowed data directory.` + ) + } + const contents = await fs.readdir(fullPath) + log.info(`Listed directory: ${dirPath}`) + listSpan?.update({ + output: { count: contents.length }, + metadata: { + 'tool.output.count': contents.length, + }, + }) + listSpan?.end() + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: '✅ Directory listed successfully', + stage: 'list:directory', + }, + id: 'list:directory', + }) + return contents + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error) + listSpan?.error({ + error: error instanceof Error ? error : new Error(errorMessage), + endSpan: true, + }) + throw error } - }); - listSpan?.end(); - await writer?.custom({ type: 'data-tool-progress', data: { status: 'done', message: '✅ Directory listed successfully', stage: 'list:directory' }, id: 'list:directory' }); - return contents - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error) - listSpan?.error({ - error: error instanceof Error ? error : new Error(errorMessage), - endSpan: true, - }) - throw error - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('List directory tool input streaming started', { - toolCallId, - messageCount: messages.length, - hook: 'onInputStart', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('List directory received complete input', { - toolCallId, - messageCount: messages.length, - dirPath: input.dirPath, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('List directory completed', { - toolCallId, - toolName, - itemCount: output.length, - hook: 'onOutput', - }) - }, + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('List directory tool input streaming started', { + toolCallId, + messageCount: messages.length, + hook: 'onInputStart', + }) + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('List directory received input chunk', { + toolCallId, + inputTextDelta, + abortSignal: abortSignal?.aborted, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('List directory received complete input', { + toolCallId, + messageCount: messages.length, + dirPath: input.dirPath, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('List directory completed', { + toolCallId, + toolName, + itemCount: output.length, + hook: 'onOutput', + }) + }, }) -export type ListDataDirUITool = InferUITool<typeof listDataDirTool>; +export type ListDataDirUITool = InferUITool<typeof listDataDirTool> export const copyDataFileTool = createTool({ - id: 'copy:file', - description: 'Copies a file within the data directory to a new location.', - inputSchema: z.object({ - sourceFile: z - .string() - .describe( - 'The source file path (relative to the data/ directory).' - ), - destFile: z - .string() - .describe( - 'The destination file path (relative to the data/ directory).' - ), - }), - outputSchema: z - .string() - .describe('A confirmation string indicating success.'), - execute: async (input, context) => { - const writer = context?.writer; - const requestCtx = context?.requestContext as DataFileManagerContext | undefined; - const tracingContext = context?.tracingContext; - await writer?.custom({ type: 'data-tool-progress', data: { status: 'in-progress', message: `📋 Copying file: ${input.sourceFile} to ${input.destFile}`, stage: 'copy:file' }, id: 'copy:file' }); - const copySpan = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'copy:file', - input, - metadata: { - 'tool.id': 'copy:file', - 'tool.input.sourceFile': input.sourceFile, - 'tool.input.destFile': input.destFile, - 'user.id': requestCtx?.userId, - } - }); + id: 'copy:file', + description: 'Copies a file within the data directory to a new location.', + inputSchema: z.object({ + sourceFile: z + .string() + .describe( + 'The source file path (relative to the data/ directory).' + ), + destFile: z + .string() + .describe( + 'The destination file path (relative to the data/ directory).' + ), + }), + outputSchema: z + .string() + .describe('A confirmation string indicating success.'), + execute: async (input, context) => { + const writer = context?.writer + const requestCtx = context?.requestContext as + | DataFileManagerContext + | undefined + const tracingContext = context?.tracingContext + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `📋 Copying file: ${input.sourceFile} to ${input.destFile}`, + stage: 'copy:file', + }, + id: 'copy:file', + }) + const copySpan = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'copy:file', + input, + requestContext: context?.requestContext, + metadata: { + 'tool.id': 'copy:file', + 'tool.input.sourceFile': input.sourceFile, + 'tool.input.destFile': input.destFile, + 'user.id': requestCtx?.userId, + }, + }) - try { - const { sourceFile, destFile } = input - const sourcePath = validateDataPath(sourceFile) - const destPath = validateDataPath(destFile) - // Defensive: Ensure both paths are within DATA_DIR - if ( - !sourcePath.startsWith(DATA_DIR) || - !destPath.startsWith(DATA_DIR) - ) { - throw new Error( - `Access denied: Paths are outside the allowed data directory.` - ) - } - const destDir = path.dirname(destPath) - await fs.mkdir(destDir, { recursive: true }) - await fs.copyFile(sourcePath, destPath) - log.info(`Copied file: ${sourceFile} to ${destFile}`) - copySpan?.update({ - output: { success: true }, - metadata: { - 'tool.output.success': true + try { + const { sourceFile, destFile } = input + const sourcePath = validateDataPath(sourceFile) + const destPath = validateDataPath(destFile) + // Defensive: Ensure both paths are within DATA_DIR + if ( + !sourcePath.startsWith(DATA_DIR) || + !destPath.startsWith(DATA_DIR) + ) { + throw new Error( + `Access denied: Paths are outside the allowed data directory.` + ) + } + const destDir = path.dirname(destPath) + await fs.mkdir(destDir, { recursive: true }) + await fs.copyFile(sourcePath, destPath) + log.info(`Copied file: ${sourceFile} to ${destFile}`) + copySpan?.update({ + output: { success: true }, + metadata: { + 'tool.output.success': true, + }, + }) + copySpan?.end() + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: '✅ File copied successfully', + stage: 'copy:file', + }, + id: 'copy:file', + }) + return `File ${sourceFile} copied to ${destFile} successfully.` + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error) + copySpan?.error({ + error: error instanceof Error ? error : new Error(errorMessage), + endSpan: true, + }) + throw error } - }); - copySpan?.end(); - await writer?.custom({ type: 'data-tool-progress', data: { status: 'done', message: '✅ File copied successfully', stage: 'copy:file' }, id: 'copy:file' }); - return `File ${sourceFile} copied to ${destFile} successfully.` - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error) - copySpan?.error({ - error: error instanceof Error ? error : new Error(errorMessage), - endSpan: true, - }) - throw error - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Copy file tool input streaming started', { - toolCallId, - messageCount: messages.length, - hook: 'onInputStart', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Copy file received complete input', { - toolCallId, - messageCount: messages.length, - sourceFile: input.sourceFile, - destFile: input.destFile, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('Copy file completed', { - toolCallId, - toolName, - success: output.includes('successfully'), - hook: 'onOutput', - }) - }, + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Copy file tool input streaming started', { + toolCallId, + messageCount: messages.length, + hook: 'onInputStart', + }) + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('Copy file received input chunk', { + toolCallId, + inputTextDelta, + abortSignal: abortSignal?.aborted, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Copy file received complete input', { + toolCallId, + messageCount: messages.length, + sourceFile: input.sourceFile, + destFile: input.destFile, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('Copy file completed', { + toolCallId, + toolName, + success: output.includes('successfully'), + hook: 'onOutput', + }) + }, }) -export type CopyDataFileUITool = InferUITool<typeof copyDataFileTool>; +export type CopyDataFileUITool = InferUITool<typeof copyDataFileTool> export const moveDataFileTool = createTool({ - id: 'move:file', - description: 'Moves or renames a file within the data directory.', - inputSchema: z.object({ - sourceFile: z - .string() - .describe( - 'The source file path (relative to the data/ directory).' - ), - destFile: z - .string() - .describe( - 'The destination file path (relative to the data/ directory).' - ), - }), - outputSchema: z - .string() - .describe('A confirmation string indicating success.'), - execute: async (input, context) => { - const writer = context?.writer; - const requestCtx = context?.requestContext as DataFileManagerContext | undefined; - const tracingContext = context?.tracingContext; - await writer?.custom({ type: 'data-tool-progress', data: { status: 'in-progress', message: `🚚 Moving file: ${input.sourceFile} to ${input.destFile}`, stage: 'move:file' }, id: 'move:file' }); - const moveSpan = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'move:file', - input, - metadata: { - 'tool.id': 'move:file', - 'tool.input.sourceFile': input.sourceFile, - 'tool.input.destFile': input.destFile, - 'user.id': requestCtx?.userId, - } - }); + id: 'move:file', + description: 'Moves or renames a file within the data directory.', + inputSchema: z.object({ + sourceFile: z + .string() + .describe( + 'The source file path (relative to the data/ directory).' + ), + destFile: z + .string() + .describe( + 'The destination file path (relative to the data/ directory).' + ), + }), + outputSchema: z + .string() + .describe('A confirmation string indicating success.'), + execute: async (input, context) => { + const writer = context?.writer + const requestCtx = context?.requestContext as + | DataFileManagerContext + | undefined + const tracingContext = context?.tracingContext + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `🚚 Moving file: ${input.sourceFile} to ${input.destFile}`, + stage: 'move:file', + }, + id: 'move:file', + }) + const moveSpan = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'move:file', + input, + requestContext: context?.requestContext, + metadata: { + 'tool.id': 'move:file', + 'tool.input.sourceFile': input.sourceFile, + 'tool.input.destFile': input.destFile, + 'user.id': requestCtx?.userId, + }, + }) - try { - const { sourceFile, destFile } = input - const sourcePath = validateDataPath(sourceFile) - const destPath = validateDataPath(destFile) - // Defensive: Ensure both paths are within DATA_DIR - if ( - !sourcePath.startsWith(DATA_DIR) || - !destPath.startsWith(DATA_DIR) - ) { - throw new Error( - `Access denied: Paths are outside the allowed data directory.` - ) - } - const destDir = path.dirname(destPath) - await fs.mkdir(destDir, { recursive: true }) - await fs.rename(sourcePath, destPath) - log.info(`Moved file: ${sourceFile} to ${destFile}`) - moveSpan?.update({ - output: { success: true }, - metadata: { - 'tool.output.success': true + try { + const { sourceFile, destFile } = input + const sourcePath = validateDataPath(sourceFile) + const destPath = validateDataPath(destFile) + // Defensive: Ensure both paths are within DATA_DIR + if ( + !sourcePath.startsWith(DATA_DIR) || + !destPath.startsWith(DATA_DIR) + ) { + throw new Error( + `Access denied: Paths are outside the allowed data directory.` + ) + } + const destDir = path.dirname(destPath) + await fs.mkdir(destDir, { recursive: true }) + await fs.rename(sourcePath, destPath) + log.info(`Moved file: ${sourceFile} to ${destFile}`) + moveSpan?.update({ + output: { success: true }, + metadata: { + 'tool.output.success': true, + }, + }) + moveSpan?.end() + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: '✅ File moved successfully', + stage: 'move:file', + }, + id: 'move:file', + }) + return `File ${sourceFile} moved to ${destFile} successfully.` + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error) + moveSpan?.error({ + error: error instanceof Error ? error : new Error(errorMessage), + endSpan: true, + }) + throw error } - }); - moveSpan?.end(); - await writer?.custom({ type: 'data-tool-progress', data: { status: 'done', message: '✅ File moved successfully', stage: 'move:file' }, id: 'move:file' }); - return `File ${sourceFile} moved to ${destFile} successfully.` - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error) - moveSpan?.error({ - error: error instanceof Error ? error : new Error(errorMessage), - endSpan: true, - }) - throw error - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Move file tool input streaming started', { - toolCallId, - messageCount: messages.length, - hook: 'onInputStart', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Move file received complete input', { - toolCallId, - messageCount: messages.length, - sourceFile: input.sourceFile, - destFile: input.destFile, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('Move file completed', { - toolCallId, - toolName, - success: output.includes('successfully'), - hook: 'onOutput', - }) - }, + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Move file tool input streaming started', { + toolCallId, + messageCount: messages.length, + hook: 'onInputStart', + }) + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('Move file received input chunk', { + toolCallId, + inputTextDelta, + abortSignal: abortSignal?.aborted, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Move file received complete input', { + toolCallId, + messageCount: messages.length, + sourceFile: input.sourceFile, + destFile: input.destFile, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('Move file completed', { + toolCallId, + toolName, + success: output.includes('successfully'), + hook: 'onOutput', + }) + }, }) -export type MoveDataFileUITool = InferUITool<typeof moveDataFileTool>; +export type MoveDataFileUITool = InferUITool<typeof moveDataFileTool> export const searchDataFilesTool = createTool({ - id: 'search:files', - description: - 'Searches for files by name pattern or content within the data directory.', - inputSchema: z.object({ - pattern: z - .string() - .describe('The search pattern (regex for name or content).'), - searchContent: z - .boolean() - .optional() - .describe( - 'Whether to search file content (default: false for name only).' - ), - dirPath: z - .string() - .optional() - .describe( - "The directory to search in (relative to data/, default: '')." - ), - }), - outputSchema: z - .array(z.string()) - .describe('An array of matching file paths.'), - execute: async (input, context) => { - const writer = context?.writer; - const requestCtx = context?.requestContext as DataFileManagerContext | undefined; - const tracingContext = context?.tracingContext; - await writer?.custom({ type: 'data-tool-progress', data: { status: 'in-progress', message: `🔍 Searching for pattern: "${input.pattern}"`, stage: 'search:files' }, id: 'search:files' }); - const searchSpan = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'search:files', - input, - metadata: { - 'tool.id': 'search:files', - 'tool.input.pattern': input.pattern, - 'tool.input.searchContent': input.searchContent, - 'tool.input.dirPath': input.dirPath, - 'user.id': requestCtx?.userId, - } - }); + id: 'search:files', + description: + 'Searches for files by name pattern or content within the data directory.', + inputSchema: z.object({ + pattern: z + .string() + .describe('The search pattern (regex for name or content).'), + searchContent: z + .boolean() + .optional() + .describe( + 'Whether to search file content (default: false for name only).' + ), + dirPath: z + .string() + .optional() + .describe( + "The directory to search in (relative to data/, default: '')." + ), + }), + outputSchema: z + .array(z.string()) + .describe('An array of matching file paths.'), + execute: async (input, context) => { + const writer = context?.writer + const requestCtx = context?.requestContext as + | DataFileManagerContext + | undefined + const tracingContext = context?.tracingContext + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `🔍 Searching for pattern: "${input.pattern}"`, + stage: 'search:files', + }, + id: 'search:files', + }) + const searchSpan = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'search:files', + input, + requestContext: context?.requestContext, + metadata: { + 'tool.id': 'search:files', + 'tool.input.pattern': input.pattern, + 'tool.input.searchContent': input.searchContent, + 'tool.input.dirPath': input.dirPath, + 'user.id': requestCtx?.userId, + }, + }) - try { - const { - pattern, - searchContent = false, - dirPath = 'docs/data', - } = input - const searchPath = validateDataPath(dirPath) - if (!searchPath.startsWith(DATA_DIR)) { - throw new Error( - `Access denied: Search path is outside the allowed data directory.` - ) - } - const MAX_PATTERN_LENGTH = 1000 - if (pattern.length > MAX_PATTERN_LENGTH) { - throw new Error( - `Pattern too long; maximum allowed length is ${MAX_PATTERN_LENGTH} characters.` - ) - } - // Use a case-insensitive literal string search instead of constructing a RegExp from untrusted input. - const safePattern = pattern.toLowerCase() - const results: string[] = [] - - const searchDir = async (dir: string): Promise<void> => { - const items = await fs.readdir(dir, { withFileTypes: true }) - for (const item of items) { - const itemPath = path.join(dir, item.name) - const relativePath = path.relative(DATA_DIR, itemPath) - if (item.isDirectory()) { - await searchDir(itemPath) - } else if (item.isFile()) { - if (searchContent) { - try { - const content = await fs.readFile( - itemPath, - 'utf-8' + try { + const { + pattern, + searchContent = false, + dirPath = 'docs/data', + } = input + const searchPath = validateDataPath(dirPath) + if (!searchPath.startsWith(DATA_DIR)) { + throw new Error( + `Access denied: Search path is outside the allowed data directory.` + ) + } + const MAX_PATTERN_LENGTH = 1000 + if (pattern.length > MAX_PATTERN_LENGTH) { + throw new Error( + `Pattern too long; maximum allowed length is ${MAX_PATTERN_LENGTH} characters.` ) - if ( - content.toLowerCase().includes(safePattern) - ) { - results.push(relativePath) + } + // Use a case-insensitive literal string search instead of constructing a RegExp from untrusted input. + const safePattern = pattern.toLowerCase() + const results: string[] = [] + + const searchDir = async (dir: string): Promise<void> => { + const items = await fs.readdir(dir, { withFileTypes: true }) + for (const item of items) { + const itemPath = path.join(dir, item.name) + const relativePath = path.relative(DATA_DIR, itemPath) + if (item.isDirectory()) { + await searchDir(itemPath) + } else if (item.isFile()) { + if (searchContent) { + try { + const content = await fs.readFile( + itemPath, + 'utf-8' + ) + if ( + content.toLowerCase().includes(safePattern) + ) { + results.push(relativePath) + } + } catch { + // Skip files that can't be read as text + } + } else if ( + item.name.toLowerCase().includes(safePattern) + ) { + results.push(relativePath) + } + } } - } catch { - // Skip files that can't be read as text - } - } else if ( - item.name.toLowerCase().includes(safePattern) - ) { - results.push(relativePath) } - } - } - } - await searchDir(searchPath) - log.info(`Searched for pattern: ${pattern} in ${dirPath}`) - searchSpan?.update({ - output: { resultCount: results.length }, - metadata: { - 'tool.output.resultCount': results.length + await searchDir(searchPath) + log.info(`Searched for pattern: ${pattern} in ${dirPath}`) + searchSpan?.update({ + output: { resultCount: results.length }, + metadata: { + 'tool.output.resultCount': results.length, + }, + }) + searchSpan?.end() + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `✅ Found ${results.length} matches`, + stage: 'search:files', + }, + id: 'search:files', + }) + return results + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error) + searchSpan?.error({ + error: error instanceof Error ? error : new Error(errorMessage), + endSpan: true, + }) + throw error } - }); - searchSpan?.end(); - await writer?.custom({ type: 'data-tool-progress', data: { status: 'done', message: `✅ Found ${results.length} matches`, stage: 'search:files' }, id: 'search:files' }); - return results - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error) - searchSpan?.error({ - error: error instanceof Error ? error : new Error(errorMessage), - endSpan: true, - }) - throw error - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Search files tool input streaming started', { - toolCallId, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputStart', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Search files received complete input', { - toolCallId, - messageCount: messages.length, - pattern: input.pattern, - searchContent: input.searchContent, - dirPath: input.dirPath, - abortSignal: abortSignal?.aborted, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('Search files completed', { - toolCallId, - toolName, - resultCount: output.length, - abortSignal: abortSignal?.aborted, - hook: 'onOutput', - }) - }, + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Search files tool input streaming started', { + toolCallId, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputStart', + }) + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('Search files received input chunk', { + toolCallId, + inputTextDelta, + abortSignal: abortSignal?.aborted, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Search files received complete input', { + toolCallId, + messageCount: messages.length, + pattern: input.pattern, + searchContent: input.searchContent, + dirPath: input.dirPath, + abortSignal: abortSignal?.aborted, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('Search files completed', { + toolCallId, + toolName, + resultCount: output.length, + abortSignal: abortSignal?.aborted, + hook: 'onOutput', + }) + }, }) -export type SearchFilesUITool = InferUITool<typeof searchDataFilesTool>; +export type SearchFilesUITool = InferUITool<typeof searchDataFilesTool> export const getDataFileInfoTool = createTool({ - id: 'get:fileinfo', - description: - 'Gets metadata information about a file within the data directory.', - inputSchema: z.object({ - fileName: z - .string() - .describe( - 'The name of the file (relative to the data/ directory).' - ), - }), - outputSchema: z - .object({ - size: z.number(), - modified: z.string(), - created: z.string(), - isFile: z.boolean(), - isDirectory: z.boolean(), - }) - .describe('File metadata information.'), - execute: async (input, context) => { - const writer = context?.writer; - const requestCtx = context?.requestContext as DataFileManagerContext | undefined; - const tracingContext = context?.tracingContext; - await writer?.custom({ type: 'data-tool-progress', data: { status: 'in-progress', message: 'ℹ️ Getting info for file: ' + input.fileName, stage: 'get:fileinfo' }, id: 'get:fileinfo' }); - const infoSpan = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'get:fileinfo', - input, - metadata: { - 'tool.id': 'get:fileinfo', - 'tool.input.fileName': input.fileName, - 'user.id': requestCtx?.userId, - } - }); + id: 'get:fileinfo', + description: + 'Gets metadata information about a file within the data directory.', + inputSchema: z.object({ + fileName: z + .string() + .describe( + 'The name of the file (relative to the data/ directory).' + ), + }), + outputSchema: z + .object({ + size: z.number(), + modified: z.string(), + created: z.string(), + isFile: z.boolean(), + isDirectory: z.boolean(), + }) + .describe('File metadata information.'), + execute: async (input, context) => { + const writer = context?.writer + const requestCtx = context?.requestContext as + | DataFileManagerContext + | undefined + const tracingContext = context?.tracingContext + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: 'ℹ️ Getting info for file: ' + input.fileName, + stage: 'get:fileinfo', + }, + id: 'get:fileinfo', + }) + const infoSpan = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'get:fileinfo', + input, + requestContext: context?.requestContext, + metadata: { + 'tool.id': 'get:fileinfo', + 'tool.input.fileName': input.fileName, + 'user.id': requestCtx?.userId, + }, + }) - try { - const { fileName } = input - const fullPath = validateDataPath(fileName) - // Resolve the real path to protect against symlink/relative attacks, then validate it is inside DATA_DIR. - const realFullPath = await fs.realpath(fullPath) - if (!realFullPath.startsWith(DATA_DIR)) { - throw new Error( - `Access denied: File path is outside the allowed data directory.` - ) - } - const stats = await fs.stat(realFullPath) - log.info(`Got info for file: ${fileName}`) - const result = { - size: stats.size, - modified: stats.mtime.toISOString(), - created: stats.birthtime.toISOString(), - isFile: stats.isFile(), - isDirectory: stats.isDirectory(), - } - infoSpan?.update({ - output: result, - metadata: { - 'tool.output.fileSize': stats.size, - 'tool.output.isFile': stats.isFile(), + try { + const { fileName } = input + const fullPath = validateDataPath(fileName) + // Resolve the real path to protect against symlink/relative attacks, then validate it is inside DATA_DIR. + const realFullPath = await fs.realpath(fullPath) + if (!realFullPath.startsWith(DATA_DIR)) { + throw new Error( + `Access denied: File path is outside the allowed data directory.` + ) + } + const stats = await fs.stat(realFullPath) + log.info(`Got info for file: ${fileName}`) + const result = { + size: stats.size, + modified: stats.mtime.toISOString(), + created: stats.birthtime.toISOString(), + isFile: stats.isFile(), + isDirectory: stats.isDirectory(), + } + infoSpan?.update({ + output: result, + metadata: { + 'tool.output.fileSize': stats.size, + 'tool.output.isFile': stats.isFile(), + }, + }) + infoSpan?.end() + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: '✅ File info retrieved', + stage: 'get:fileinfo', + }, + id: 'get:fileinfo', + }) + return result + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error) + infoSpan?.error({ + error: error instanceof Error ? error : new Error(errorMessage), + endSpan: true, + }) + throw error } - }); - infoSpan?.end(); - await writer?.custom({ type: 'data-tool-progress', data: { status: 'done', message: '✅ File info retrieved', stage: 'get:fileinfo' }, id: 'get:fileinfo' }); - return result - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error) - infoSpan?.error({ - error: error instanceof Error ? error : new Error(errorMessage), - endSpan: true, - }) - throw error - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Get file info tool input streaming started', { - toolCallId, - messageCount: messages.length, - hook: 'onInputStart', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Get file info received complete input', { - toolCallId, - messageCount: messages.length, - fileName: input.fileName, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('Get file info completed', { - toolCallId, - toolName, - fileSize: output.size, - isFile: output.isFile, - hook: 'onOutput', - }) - }, + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Get file info tool input streaming started', { + toolCallId, + messageCount: messages.length, + hook: 'onInputStart', + }) + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('Get file info received input chunk', { + toolCallId, + inputTextDelta, + abortSignal: abortSignal?.aborted, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Get file info received complete input', { + toolCallId, + messageCount: messages.length, + fileName: input.fileName, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('Get file info completed', { + toolCallId, + toolName, + fileSize: output.size, + isFile: output.isFile, + hook: 'onOutput', + }) + }, }) -export type GetDataFileInfoUITool = InferUITool<typeof getDataFileInfoTool>; +export type GetDataFileInfoUITool = InferUITool<typeof getDataFileInfoTool> export const createDataDirTool = createTool({ - id: 'create:directory', - description: 'Creates a new directory within the data directory.', - inputSchema: z.object({ - dirPath: z - .string() - .describe( - 'The path of the directory to create (relative to the data/ directory).' - ), - }), - outputSchema: z - .string() - .describe('A confirmation string indicating success.'), - execute: async (input, context) => { - const writer = context?.writer; - const requestCtx = context?.requestContext as DataFileManagerContext | undefined; - const tracingContext = context?.tracingContext; - await writer?.custom({ type: 'data-tool-progress', data: { status: 'in-progress', message: '📁 Creating directory: ' + input.dirPath, stage: 'create:directory' }, id: 'create:directory' }); - const createDirSpan = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'create:directory', - input, - metadata: { - 'tool.id': 'create:directory', - 'tool.input.dirPath': input.dirPath, - 'user.id': requestCtx?.userId, - } - }); + id: 'create:directory', + description: 'Creates a new directory within the data directory.', + inputSchema: z.object({ + dirPath: z + .string() + .describe( + 'The path of the directory to create (relative to the data/ directory).' + ), + }), + outputSchema: z + .string() + .describe('A confirmation string indicating success.'), + execute: async (input, context) => { + const writer = context?.writer + const requestCtx = context?.requestContext as + | DataFileManagerContext + | undefined + const tracingContext = context?.tracingContext + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: '📁 Creating directory: ' + input.dirPath, + stage: 'create:directory', + }, + id: 'create:directory', + }) + const createDirSpan = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'create:directory', + input, + requestContext: context?.requestContext, + metadata: { + 'tool.id': 'create:directory', + 'tool.input.dirPath': input.dirPath, + 'user.id': requestCtx?.userId, + }, + }) - try { - const { dirPath } = input - const fullPath = validateDataPath(dirPath) - if (!fullPath.startsWith(DATA_DIR)) { - throw new Error( - `Access denied: Directory path is outside the allowed data directory.` - ) - } - await fs.mkdir(fullPath, { recursive: true }) - log.info(`Created directory: ${dirPath}`) - createDirSpan?.update({ - output: { success: true }, - metadata: { - 'tool.output.success': true + try { + const { dirPath } = input + const fullPath = validateDataPath(dirPath) + if (!fullPath.startsWith(DATA_DIR)) { + throw new Error( + `Access denied: Directory path is outside the allowed data directory.` + ) + } + await fs.mkdir(fullPath, { recursive: true }) + log.info(`Created directory: ${dirPath}`) + createDirSpan?.update({ + output: { success: true }, + metadata: { + 'tool.output.success': true, + }, + }) + createDirSpan?.end() + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: '✅ Directory created successfully', + stage: 'create:directory', + }, + id: 'create:directory', + }) + return `Directory ${dirPath} created successfully.` + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error) + createDirSpan?.error({ + error: error instanceof Error ? error : new Error(errorMessage), + endSpan: true, + }) + throw error } - }); - createDirSpan?.end(); - await writer?.custom({ type: 'data-tool-progress', data: { status: 'done', message: '✅ Directory created successfully', stage: 'create:directory' }, id: 'create:directory' }); - return `Directory ${dirPath} created successfully.` - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error) - createDirSpan?.error({ - error: error instanceof Error ? error : new Error(errorMessage), - endSpan: true, - }) - throw error - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Create directory tool input streaming started', { - toolCallId, - messageCount: messages.length, - hook: 'onInputStart', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Create directory received complete input', { - toolCallId, - messageCount: messages.length, - dirPath: input.dirPath, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('Create directory completed', { - toolCallId, - toolName, - success: output.includes('successfully'), - hook: 'onOutput', - }) - }, + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Create directory tool input streaming started', { + toolCallId, + messageCount: messages.length, + hook: 'onInputStart', + }) + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('Create directory received input chunk', { + toolCallId, + inputTextDelta, + abortSignal: abortSignal?.aborted, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Create directory received complete input', { + toolCallId, + messageCount: messages.length, + dirPath: input.dirPath, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('Create directory completed', { + toolCallId, + toolName, + success: output.includes('successfully'), + hook: 'onOutput', + }) + }, }) -export type CreateDataDirUITool = InferUITool<typeof createDataDirTool>; +export type CreateDataDirUITool = InferUITool<typeof createDataDirTool> export const removeDataDirTool = createTool({ - id: 'remove:directory', - description: 'Removes an empty directory within the data directory.', - inputSchema: z.object({ - dirPath: z - .string() - .describe( - 'The path of the directory to remove (relative to the data/ directory).' - ), - }), - outputSchema: z - .string() - .describe('A confirmation string indicating success.'), - execute: async (input, context) => { - const writer = context?.writer; - const requestCtx = context?.requestContext as DataFileManagerContext | undefined; - const tracingContext = context?.tracingContext; - await writer?.custom({ type: 'data-tool-progress', data: { status: 'in-progress', message: '🗑️ Removing directory: ' + input.dirPath, stage: 'remove:directory' }, id: 'remove:directory' }); - const removeDirSpan = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'remove:directory', - input, - metadata: { - 'tool.id': 'remove:directory', - 'tool.input.dirPath': input.dirPath, - 'user.id': requestCtx?.userId, - } - }); + id: 'remove:directory', + description: 'Removes an empty directory within the data directory.', + inputSchema: z.object({ + dirPath: z + .string() + .describe( + 'The path of the directory to remove (relative to the data/ directory).' + ), + }), + outputSchema: z + .string() + .describe('A confirmation string indicating success.'), + execute: async (input, context) => { + const writer = context?.writer + const requestCtx = context?.requestContext as + | DataFileManagerContext + | undefined + const tracingContext = context?.tracingContext + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: '🗑️ Removing directory: ' + input.dirPath, + stage: 'remove:directory', + }, + id: 'remove:directory', + }) + const removeDirSpan = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'remove:directory', + input, + metadata: { + 'tool.id': 'remove:directory', + 'tool.input.dirPath': input.dirPath, + 'user.id': requestCtx?.userId, + }, + }) - try { - const { dirPath } = input - const fullPath = validateDataPath(dirPath) - if (!fullPath.startsWith(DATA_DIR)) { - throw new Error( - `Access denied: Directory path is outside the allowed data directory.` - ) - } - // Check if directory is empty - const contents = await fs.readdir(fullPath) - if (contents.length > 0) { - throw new Error(`Directory ${dirPath} is not empty.`) - } - await fs.rmdir(fullPath) - log.info(`Removed directory: ${dirPath}`) - removeDirSpan?.update({ - output: { success: true }, - metadata: { - 'tool.output.success': true + try { + const { dirPath } = input + const fullPath = validateDataPath(dirPath) + if (!fullPath.startsWith(DATA_DIR)) { + throw new Error( + `Access denied: Directory path is outside the allowed data directory.` + ) + } + // Check if directory is empty + const contents = await fs.readdir(fullPath) + if (contents.length > 0) { + throw new Error(`Directory ${dirPath} is not empty.`) + } + await fs.rmdir(fullPath) + log.info(`Removed directory: ${dirPath}`) + removeDirSpan?.update({ + output: { success: true }, + metadata: { + 'tool.output.success': true, + }, + }) + removeDirSpan?.end() + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: '✅ Directory removed successfully', + stage: 'remove:directory', + }, + id: 'remove:directory', + }) + return `Directory ${dirPath} removed successfully.` + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error) + removeDirSpan?.error({ + error: error instanceof Error ? error : new Error(errorMessage), + endSpan: true, + }) + throw error } - }); - removeDirSpan?.end(); - await writer?.custom({ type: 'data-tool-progress', data: { status: 'done', message: '✅ Directory removed successfully', stage: 'remove:directory' }, id: 'remove:directory' }); - return `Directory ${dirPath} removed successfully.` - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error) - removeDirSpan?.error({ - error: error instanceof Error ? error : new Error(errorMessage), - endSpan: true, - }) - throw error - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Remove directory tool input streaming started', { - toolCallId, - messageCount: messages.length, - hook: 'onInputStart', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Remove directory received complete input', { - toolCallId, - messageCount: messages.length, - dirPath: input.dirPath, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('Remove directory completed', { - toolCallId, - toolName, - success: output.includes('successfully'), - hook: 'onOutput', - }) - }, + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Remove directory tool input streaming started', { + toolCallId, + messageCount: messages.length, + hook: 'onInputStart', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Remove directory received complete input', { + toolCallId, + messageCount: messages.length, + dirPath: input.dirPath, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('Remove directory completed', { + toolCallId, + toolName, + success: output.includes('successfully'), + hook: 'onOutput', + }) + }, }) -export type RemoveDataDirUITool = InferUITool<typeof removeDataDirTool>; +export type RemoveDataDirUITool = InferUITool<typeof removeDataDirTool> export const archiveDataTool = createTool({ - id: 'archive:data', - description: - 'Compresses files or directories within the data directory into a gzip archive.', - inputSchema: z.object({ - sourcePath: z - .string() - .describe( - 'The source file or directory path (relative to the data/ directory).' - ), - archiveName: z - .string() - .describe( - 'The name of the archive file (relative to the data/ directory, without extension).' - ), - }), - outputSchema: z - .string() - .describe('A confirmation string indicating success.'), - execute: async (input, context) => { - const writer = context?.writer; - const requestCtx = context?.requestContext as DataFileManagerContext | undefined; - const tracingContext = context?.tracingContext; - await writer?.custom({ type: 'data-tool-progress', data: { status: 'in-progress', message: `📦 Archiving: ${input.sourcePath} to ${input.archiveName}.gz`, stage: 'archive:data' }, id: 'archive:data' }); - const archiveSpan = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'archive:data', - input, - metadata: { - 'tool.id': 'archive:data', - 'tool.input.sourcePath': input.sourcePath, - 'tool.input.archiveName': input.archiveName, - 'user.id': requestCtx?.userId, - } - }); + id: 'archive:data', + description: + 'Compresses files or directories within the data directory into a gzip archive.', + inputSchema: z.object({ + sourcePath: z + .string() + .describe( + 'The source file or directory path (relative to the data/ directory).' + ), + archiveName: z + .string() + .describe( + 'The name of the archive file (relative to the data/ directory, without extension).' + ), + }), + outputSchema: z + .string() + .describe('A confirmation string indicating success.'), + execute: async (input, context) => { + const writer = context?.writer + const requestCtx = context?.requestContext as + | DataFileManagerContext + | undefined + const tracingContext = context?.tracingContext + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `📦 Archiving: ${input.sourcePath} to ${input.archiveName}.gz`, + stage: 'archive:data', + }, + id: 'archive:data', + }) + const archiveSpan = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'archive:data', + input, + metadata: { + 'tool.id': 'archive:data', + 'tool.input.sourcePath': input.sourcePath, + 'tool.input.archiveName': input.archiveName, + 'user.id': requestCtx?.userId, + }, + }) - try { - const { sourcePath, archiveName } = input - const sourceFullPath = validateDataPath(sourcePath) - const archiveFullPath = validateDataPath(archiveName + '.gz') - if ( - !sourceFullPath.startsWith(DATA_DIR) || - !archiveFullPath.startsWith(DATA_DIR) - ) { - throw new Error( - `Access denied: Paths are outside the allowed data directory.` - ) - } - const archiveDir = path.dirname(archiveFullPath) - await fs.mkdir(archiveDir, { recursive: true }) + try { + const { sourcePath, archiveName } = input + const sourceFullPath = validateDataPath(sourcePath) + const archiveFullPath = validateDataPath(archiveName + '.gz') + if ( + !sourceFullPath.startsWith(DATA_DIR) || + !archiveFullPath.startsWith(DATA_DIR) + ) { + throw new Error( + `Access denied: Paths are outside the allowed data directory.` + ) + } + const archiveDir = path.dirname(archiveFullPath) + await fs.mkdir(archiveDir, { recursive: true }) - const { createReadStream, createWriteStream } = await import('node:fs') - const gzip = zlib.createGzip() - const sourceStream = createReadStream(sourceFullPath) - const archiveStream = createWriteStream(archiveFullPath) + const { createReadStream, createWriteStream } = + await import('node:fs') + const gzip = zlib.createGzip() + const sourceStream = createReadStream(sourceFullPath) + const archiveStream = createWriteStream(archiveFullPath) - await pipeline(sourceStream, gzip, archiveStream) - log.info(`Archived: ${sourcePath} to ${archiveName}.gz`) - archiveSpan?.update({ - output: { success: true }, - metadata: { - 'tool.output.success': true + await pipeline(sourceStream, gzip, archiveStream) + log.info(`Archived: ${sourcePath} to ${archiveName}.gz`) + archiveSpan?.update({ + output: { success: true }, + metadata: { + 'tool.output.success': true, + }, + }) + archiveSpan?.end() + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: '✅ Archive created successfully', + stage: 'archive:data', + }, + id: 'archive:data', + }) + return `File ${sourcePath} archived to ${archiveName}.gz successfully.` + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error) + archiveSpan?.error({ + error: error instanceof Error ? error : new Error(errorMessage), + endSpan: true, + }) + throw error } - }); - archiveSpan?.end(); - await writer?.custom({ type: 'data-tool-progress', data: { status: 'done', message: '✅ Archive created successfully', stage: 'archive:data' }, id: 'archive:data' }); - return `File ${sourcePath} archived to ${archiveName}.gz successfully.` - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error) - archiveSpan?.error({ - error: error instanceof Error ? error : new Error(errorMessage), - endSpan: true, - }) - throw error - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Archive data tool input streaming started', { - toolCallId, - messageCount: messages.length, - hook: 'onInputStart', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Archive data received complete input', { - toolCallId, - messageCount: messages.length, - sourcePath: input.sourcePath, - archiveName: input.archiveName, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('Archive data completed', { - toolCallId, - toolName, - success: output.includes('successfully'), - hook: 'onOutput', - }) - }, + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Archive data tool input streaming started', { + toolCallId, + messageCount: messages.length, + hook: 'onInputStart', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Archive data received complete input', { + toolCallId, + messageCount: messages.length, + sourcePath: input.sourcePath, + archiveName: input.archiveName, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('Archive data completed', { + toolCallId, + toolName, + success: output.includes('successfully'), + hook: 'onOutput', + }) + }, }) -export type ArchiveDataUITool = InferUITool<typeof archiveDataTool>; +export type ArchiveDataUITool = InferUITool<typeof archiveDataTool> export const backupDataTool = createTool({ - id: 'backup:data', - description: - 'Creates a timestamped backup of a file or directory within the data directory.', - inputSchema: z.object({ - sourcePath: z - .string() - .describe( - 'The source file or directory path (relative to the data/ directory).' - ), - backupDir: z - .string() - .optional() - .describe( - "The backup directory (relative to data/, default: 'backups/')." - ), - }), - outputSchema: z - .string() - .describe('A confirmation string indicating success with backup path.'), - execute: async (input, context) => { - const writer = context?.writer; - const requestCtx = context?.requestContext as DataFileManagerContext | undefined; - const tracingContext = context?.tracingContext; - await writer?.custom({ type: 'data-tool-progress', data: { status: 'in-progress', message: `💾 Creating backup for: ${input.sourcePath}`, stage: 'backup:data' }, id: 'backup:data' }); - const backupSpan = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'backup:data', - input, - metadata: { - 'tool.id': 'backup:data', - 'tool.input.sourcePath': input.sourcePath, - 'tool.input.backupDir': input.backupDir ?? 'backups', - 'user.id': requestCtx?.userId, - } - }); + id: 'backup:data', + description: + 'Creates a timestamped backup of a file or directory within the data directory.', + inputSchema: z.object({ + sourcePath: z + .string() + .describe( + 'The source file or directory path (relative to the data/ directory).' + ), + backupDir: z + .string() + .optional() + .describe( + "The backup directory (relative to data/, default: 'backups/')." + ), + }), + outputSchema: z + .string() + .describe('A confirmation string indicating success with backup path.'), + execute: async (input, context) => { + const writer = context?.writer + const requestCtx = context?.requestContext as + | DataFileManagerContext + | undefined + const tracingContext = context?.tracingContext + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `💾 Creating backup for: ${input.sourcePath}`, + stage: 'backup:data', + }, + id: 'backup:data', + }) + const backupSpan = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'backup:data', + input, + metadata: { + 'tool.id': 'backup:data', + 'tool.input.sourcePath': input.sourcePath, + 'tool.input.backupDir': input.backupDir ?? 'backups', + 'user.id': requestCtx?.userId, + }, + }) - try { - const { sourcePath, backupDir = 'backups' } = input - const sourceFullPath = validateDataPath(sourcePath) - if (!sourceFullPath.startsWith(DATA_DIR)) { - throw new Error( - `Access denied: Source path is outside the allowed data directory.` - ) - } - const timestamp = new Date().toISOString().replace(/[:.]/g, '-') - const sourceName = path.basename(sourcePath) - const backupName = `${sourceName}_${timestamp}` - const backupFullPath = validateDataPath( - path.join(backupDir, backupName) - ) - if (!backupFullPath.startsWith(DATA_DIR)) { - throw new Error( - `Access denied: Backup path is outside the allowed data directory.` - ) - } - const backupParentDir = path.dirname(backupFullPath) - await fs.mkdir(backupParentDir, { recursive: true }) - await fs.cp(sourceFullPath, backupFullPath, { recursive: true }) - const relativeBackupPath = path.relative(DATA_DIR, backupFullPath) - log.info(`Backed up: ${sourcePath} to ${relativeBackupPath}`) - backupSpan?.update({ - output: { backupPath: relativeBackupPath }, - metadata: { - 'tool.output.backupPath': relativeBackupPath + try { + const { sourcePath, backupDir = 'backups' } = input + const sourceFullPath = validateDataPath(sourcePath) + if (!sourceFullPath.startsWith(DATA_DIR)) { + throw new Error( + `Access denied: Source path is outside the allowed data directory.` + ) + } + const timestamp = new Date().toISOString().replace(/[:.]/g, '-') + const sourceName = path.basename(sourcePath) + const backupName = `${sourceName}_${timestamp}` + const backupFullPath = validateDataPath( + path.join(backupDir, backupName) + ) + if (!backupFullPath.startsWith(DATA_DIR)) { + throw new Error( + `Access denied: Backup path is outside the allowed data directory.` + ) + } + const backupParentDir = path.dirname(backupFullPath) + await fs.mkdir(backupParentDir, { recursive: true }) + await fs.cp(sourceFullPath, backupFullPath, { recursive: true }) + const relativeBackupPath = path.relative(DATA_DIR, backupFullPath) + log.info(`Backed up: ${sourcePath} to ${relativeBackupPath}`) + backupSpan?.update({ + output: { backupPath: relativeBackupPath }, + metadata: { + 'tool.output.backupPath': relativeBackupPath, + }, + }) + backupSpan?.end() + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: '✅ Backup created successfully', + stage: 'backup:data', + }, + id: 'backup:data', + }) + return `Backup created: ${sourcePath} → ${relativeBackupPath}` + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error) + backupSpan?.error({ + error: error instanceof Error ? error : new Error(errorMessage), + endSpan: true, + }) + throw error } - }); - backupSpan?.end(); - await writer?.custom({ type: 'data-tool-progress', data: { status: 'done', message: '✅ Backup created successfully', stage: 'backup:data' }, id: 'backup:data' }); - return `Backup created: ${sourcePath} → ${relativeBackupPath}` - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error) - backupSpan?.error({ - error: error instanceof Error ? error : new Error(errorMessage), - endSpan: true, - }) - throw error - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Backup data tool input streaming started', { - toolCallId, - messageCount: messages.length, - hook: 'onInputStart', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Backup data received complete input', { - toolCallId, - messageCount: messages.length, - sourcePath: input.sourcePath, - backupDir: input.backupDir, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('Backup data completed', { - toolCallId, - toolName, - success: output.includes('Backup created'), - hook: 'onOutput', - }) - }, + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Backup data tool input streaming started', { + toolCallId, + messageCount: messages.length, + hook: 'onInputStart', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Backup data received complete input', { + toolCallId, + messageCount: messages.length, + sourcePath: input.sourcePath, + backupDir: input.backupDir, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('Backup data completed', { + toolCallId, + toolName, + success: output.includes('Backup created'), + hook: 'onOutput', + }) + }, }) -export type BackupDataUITool = InferUITool<typeof backupDataTool>; +export type BackupDataUITool = InferUITool<typeof backupDataTool> diff --git a/src/mastra/tools/data-processing-tools.ts b/src/mastra/tools/data-processing-tools.ts index 067b40f4..76526552 100644 --- a/src/mastra/tools/data-processing-tools.ts +++ b/src/mastra/tools/data-processing-tools.ts @@ -1,54 +1,58 @@ -import type { RequestContext } from "@mastra/core/request-context"; -import type { InferUITool } from "@mastra/core/tools"; -import { createTool } from "@mastra/core/tools"; -import { SpanType } from "@mastra/core/observability"; -import excalidrawToSvg from 'excalidraw-to-svg'; -import { XMLBuilder, XMLParser } from 'fast-xml-parser'; -import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; -import svgjson from 'svgjson'; -import { z } from 'zod'; -import { log } from '../config/logger'; +import type { RequestContext } from '@mastra/core/request-context' +import type { InferUITool } from '@mastra/core/tools' +import { createTool } from '@mastra/core/tools' +import { SpanType } from '@mastra/core/observability' +import type { TracingContext } from '@mastra/core/observability' +import excalidrawToSvg from 'excalidraw-to-svg' +import { XMLBuilder, XMLParser } from 'fast-xml-parser' +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import svgjson from 'svgjson' +import { z } from 'zod' +import { log } from '../config/logger' // Type for SVG JSON nodes from svgjson interface SvgJsonNode { - name?: string - attributes?: Record<string, string> - children?: SvgJsonNode[] - text?: string - content?: string + name?: string + attributes?: Record<string, string> + children?: SvgJsonNode[] + text?: string + content?: string } // Define runtime context for these tools export interface DataProcessingContext extends RequestContext { - userId?: string - workspaceId?: string - format?: 'csv' | 'json' | 'xml' | 'svg' | 'excalidraw' + userId?: string + workspaceId?: string + format?: 'csv' | 'json' | 'xml' | 'svg' | 'excalidraw' } // Type definition for XML element info collected by processXMLTool interface XmlElementInfo { - tagName: string - textContent?: string | null - attributes: Record<string, string> - children: XmlElementInfo[] + tagName: string + textContent?: string | null + attributes: Record<string, string> + children: XmlElementInfo[] } // Type for extracted data produced by processXMLTool -type ExtractedData = Record<string, XmlElementInfo[] | Array<{ element: string; value: string }>> +type ExtractedData = Record< + string, + XmlElementInfo[] | Array<{ element: string; value: string }> +> // Type for processSVGTool result interface ProcessSVGResult { - svgJson: SvgJsonNode - paths?: SvgJsonNode[] - textElements?: string[] - styles?: Record<string, string> - dimensions: { - width?: number - height?: number - viewBox?: string - } - elementCount: number + svgJson: SvgJsonNode + paths?: SvgJsonNode[] + textElements?: string[] + styles?: Record<string, string> + dimensions: { + width?: number + height?: number + viewBox?: string + } + elementCount: number } const DATA_DIR = path.join(process.cwd(), 'file: ./data') @@ -57,1996 +61,1430 @@ const DATA_DIR = path.join(process.cwd(), 'file: ./data') * Ensures the given filePath is within the DATA_DIR. */ function validateDataPath(filePath: string): string { - const absolutePath = path.resolve(DATA_DIR, filePath) - if (!absolutePath.startsWith(DATA_DIR)) { - throw new Error( - `Access denied: File path "${filePath}" is outside the allowed data directory.` - ) - } - return absolutePath + const absolutePath = path.resolve(DATA_DIR, filePath) + if (!absolutePath.startsWith(DATA_DIR)) { + throw new Error( + `Access denied: File path "${filePath}" is outside the allowed data directory.` + ) + } + return absolutePath } // Excalidraw element schema const ExcalidrawElementSchema = z.object({ - id: z.string(), - type: z.enum(['rectangle', 'ellipse', 'diamond', 'arrow', 'text', 'line', 'freedraw', 'image']), - x: z.number(), - y: z.number(), - width: z.number(), - height: z.number(), - text: z.string().optional(), - strokeColor: z.string().default('#000000'), - backgroundColor: z.string().default('transparent'), - fillStyle: z.enum(['solid', 'hachure', 'cross-hatch']).default('solid'), - strokeWidth: z.number().default(2), - strokeStyle: z.enum(['solid', 'dashed', 'dotted']).default('solid'), - roughness: z.number().default(1), - opacity: z.number().default(100), - angle: z.number().default(0), - points: z.array(z.array(z.number())).optional(), - startBinding: z.any().optional(), - endBinding: z.any().optional(), - arrowheads: z.any().optional(), - fontSize: z.number().optional(), - fontFamily: z.number().optional(), - textAlign: z.enum(['left', 'center', 'right']).optional(), - verticalAlign: z.enum(['top', 'middle', 'bottom']).optional(), - groupIds: z.array(z.string()).optional(), - frameId: z.string().nullable().optional(), - roundness: z.any().optional(), - seed: z.number().default(Math.floor(Math.random() * 1000000)), - version: z.number().default(1), - versionNonce: z.number().default(Math.floor(Math.random() * 1000000)), - isDeleted: z.boolean().default(false), - boundElements: z.any().optional(), - updated: z.number().default(Date.now()), - link: z.string().nullable().optional(), - locked: z.boolean().default(false), + id: z.string(), + type: z.enum([ + 'rectangle', + 'ellipse', + 'diamond', + 'arrow', + 'text', + 'line', + 'freedraw', + 'image', + ]), + x: z.number(), + y: z.number(), + width: z.number(), + height: z.number(), + text: z.string().optional(), + strokeColor: z.string().default('#000000'), + backgroundColor: z.string().default('transparent'), + fillStyle: z.enum(['solid', 'hachure', 'cross-hatch']).default('solid'), + strokeWidth: z.number().default(2), + strokeStyle: z.enum(['solid', 'dashed', 'dotted']).default('solid'), + roughness: z.number().default(1), + opacity: z.number().default(100), + angle: z.number().default(0), + points: z.array(z.array(z.number())).optional(), + startBinding: z.any().optional(), + endBinding: z.any().optional(), + arrowheads: z.any().optional(), + fontSize: z.number().optional(), + fontFamily: z.number().optional(), + textAlign: z.enum(['left', 'center', 'right']).optional(), + verticalAlign: z.enum(['top', 'middle', 'bottom']).optional(), + groupIds: z.array(z.string()).optional(), + frameId: z.string().nullable().optional(), + roundness: z.any().optional(), + seed: z.number().default(Math.floor(Math.random() * 1000000)), + version: z.number().default(1), + versionNonce: z.number().default(Math.floor(Math.random() * 1000000)), + isDeleted: z.boolean().default(false), + boundElements: z.any().optional(), + updated: z.number().default(Date.now()), + link: z.string().nullable().optional(), + locked: z.boolean().default(false), }) const ExcalidrawSchema = z.object({ - type: z.literal('excalidraw'), - version: z.number(), - source: z.string(), - elements: z.array(ExcalidrawElementSchema), - appState: z.object({ - gridSize: z.number().nullable().optional(), - gridStep: z.number().optional(), - gridModeEnabled: z.boolean().optional(), - viewBackgroundColor: z.string(), - }), - files: z.record(z.string(), z.any()).optional(), + type: z.literal('excalidraw'), + version: z.number(), + source: z.string(), + elements: z.array(ExcalidrawElementSchema), + appState: z.object({ + gridSize: z.number().nullable().optional(), + gridStep: z.number().optional(), + gridModeEnabled: z.boolean().optional(), + viewBackgroundColor: z.string(), + }), + files: z.record(z.string(), z.any()).optional(), }) // Type aliases derived from Zod schemas to avoid using `any` type ExcalidrawElementType = z.infer<typeof ExcalidrawElementSchema> - const CSVRowSchema = z.record(z.string(), z.string()) /** * Reads and parses CSV data from a file */ export const readCSVDataTool = createTool({ - id: 'read:CSVdata', - description: 'Reads and parses CSV data from a file in the data directory', - inputSchema: z.object({ - fileName: z.string().describe('The name of the CSV file (relative to data directory)'), - hasHeaders: z.boolean().optional().default(true), - delimiter: z.string().optional().default(','), - }), - outputSchema: z.object({ - headers: z.array(z.string()).optional(), - rows: z.array(CSVRowSchema), - rawCSV: z.string(), - }), - - execute: async (inputData, context) => { - const writer = context?.writer; - - await writer?.custom({ type: 'data-tool-progress', data: { status: 'in-progress', message: '📖 Reading CSV file: ' + inputData.fileName, stage: 'read:CSVdata' }, id: 'read:CSVdata' }); - - const tracingContext = context?.tracingContext; - const span = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'read_csv_data', - input: inputData, - metadata: { - 'tool.id': 'read:CSVdata', - 'tool.input.fileName': inputData.fileName, - } - }); + id: 'read:CSVdata', + description: 'Reads and parses CSV data from a file in the data directory', + inputSchema: z.object({ + fileName: z + .string() + .describe('The name of the CSV file (relative to data directory)'), + hasHeaders: z.boolean().optional().default(true), + delimiter: z.string().optional().default(','), + }), + outputSchema: z.object({ + headers: z.array(z.string()).optional(), + rows: z.array(CSVRowSchema), + rawCSV: z.string(), + }), - try { - const { fileName, hasHeaders = true, delimiter = ',' } = inputData - const fullPath = validateDataPath(fileName) - const realFullPath = await fs.realpath(fullPath) - - if (!realFullPath.startsWith(DATA_DIR)) { - throw new Error(`Access denied: File path is outside allowed directory`) - } - - const content = await fs.readFile(realFullPath, 'utf-8') - const lines = content.trim().split('\n') - - let headers: string[] = [] - const rows: Array<z.infer<typeof CSVRowSchema>> = [] - - if (hasHeaders && lines.length > 0) { - headers = lines[0].split(delimiter).map(h => h.trim().replace(/"/g, '')) - for (let i = 1; i < lines.length; i++) { - const values = lines[i].split(delimiter).map(v => v.trim().replace(/"/g, '')) - const row: z.infer<typeof CSVRowSchema> = {} - headers.forEach((header, index) => { - if (values[index] !== undefined) { - row[header] = values[index] + execute: async (inputData, context) => { + const writer = context?.writer + + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: '📖 Reading CSV file: ' + inputData.fileName, + stage: 'read:CSVdata', + }, + id: 'read:CSVdata', + }) + + const tracingContext: TracingContext | undefined = + context?.tracingContext + const span = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'read_csv_data', + input: inputData, + metadata: { + 'tool.id': 'read:CSVdata', + 'tool.input.fileName': inputData.fileName, + }, + }) + + try { + const { fileName, hasHeaders = true, delimiter = ',' } = inputData + const fullPath = validateDataPath(fileName) + const realFullPath = await fs.realpath(fullPath) + + if (!realFullPath.startsWith(DATA_DIR)) { + throw new Error( + `Access denied: File path is outside allowed directory` + ) } - }) - rows.push(row) - } - } else { - // No headers, treat all as data rows - for (const line of lines) { - const values = line.split(delimiter).map(v => v.trim().replace(/"/g, '')) - const row: z.infer<typeof CSVRowSchema> = {} - values.forEach((value, index) => { - row[`field_${index}`] = value - }) - rows.push(row) + + const content = await fs.readFile(realFullPath, 'utf-8') + const lines = content.trim().split('\n') + + let headers: string[] = [] + const rows: Array<z.infer<typeof CSVRowSchema>> = [] + + if (hasHeaders && lines.length > 0) { + headers = lines[0] + .split(delimiter) + .map((h) => h.trim().replace(/"/g, '')) + for (let i = 1; i < lines.length; i++) { + const values = lines[i] + .split(delimiter) + .map((v) => v.trim().replace(/"/g, '')) + const row: z.infer<typeof CSVRowSchema> = {} + headers.forEach((header, index) => { + if (values[index] !== undefined) { + row[header] = values[index] + } + }) + rows.push(row) + } + } else { + // No headers, treat all as data rows + for (const line of lines) { + const values = line + .split(delimiter) + .map((v) => v.trim().replace(/"/g, '')) + const row: z.infer<typeof CSVRowSchema> = {} + values.forEach((value, index) => { + row[`field_${index}`] = value + }) + rows.push(row) + } + headers = rows.length > 0 ? Object.keys(rows[0]) : [] + } + + span?.update({ + output: { rowCount: rows.length, headerCount: headers.length }, + metadata: { + 'tool.output.rowCount': rows.length, + 'tool.output.headerCount': headers.length, + }, + }) + span?.end() + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `✅ Read ${rows.length} rows from CSV`, + stage: 'read:CSVdata', + }, + id: 'read:CSVdata', + }) + return { headers, rows, rawCSV: content } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error) + span?.error({ + error: error instanceof Error ? error : new Error(errorMessage), + endSpan: true, + }) + throw error } - headers = rows.length > 0 ? Object.keys(rows[0]) : [] - } - - span?.update({ - output: { rowCount: rows.length, headerCount: headers.length }, - metadata: { 'tool.output.rowCount': rows.length, 'tool.output.headerCount': headers.length } - }); - span?.end(); - await writer?.custom({ type: 'data-tool-progress', data: { status: 'done', message: `✅ Read ${rows.length} rows from CSV`, stage: 'read:CSVdata' }, id: 'read:CSVdata' }); - return { headers, rows, rawCSV: content } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - span?.error({ - error: error instanceof Error ? error : new Error(errorMessage), - endSpan: true, - }); - throw error - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Read CSV tool input streaming started', { - toolCallId, - messages: messages.length, - hook: 'onInputStart', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Read CSV received complete input', { - toolCallId, - messageCount: messages.length, - fileName: input.fileName, - hasHeaders: input.hasHeaders, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('Read CSV completed', { - toolCallId, - toolName, - rowCount: output.rows.length, - headerCount: output.headers?.length || 0, - hook: 'onOutput', - }) - }, + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Read CSV tool input streaming started', { + toolCallId, + messages: messages.length, + hook: 'onInputStart', + }) + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('Read CSV tool received input chunk', { + toolCallId, + inputTextDelta, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Read CSV received complete input', { + toolCallId, + messageCount: messages.length, + fileName: input.fileName, + hasHeaders: input.hasHeaders, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('Read CSV completed', { + toolCallId, + toolName, + rowCount: output.rows.length, + headerCount: output.headers?.length || 0, + hook: 'onOutput', + }) + }, }) /** * Converts CSV data to Excalidraw format */ export const csvToExcalidrawTool = createTool({ - id: 'csv-to-excalidraw', - description: 'Converts CSV data into an Excalidraw diagram format', - inputSchema: z.object({ - csvData: z.string().describe('CSV data as string'), - layoutType: z.enum(['table', 'flowchart', 'mindmap', 'timeline']).optional().default('table'), - title: z.string().optional(), - hasHeaders: z.boolean().optional().default(true), - delimiter: z.string().optional().default(','), - }), - outputSchema: z.object({ - filename: z.string(), - contents: ExcalidrawSchema, - elementCount: z.number(), - layoutInfo: z.object({ - totalWidth: z.number(), - totalHeight: z.number(), - elementSpacing: z.number(), + id: 'csv-to-excalidraw', + description: 'Converts CSV data into an Excalidraw diagram format', + inputSchema: z.object({ + csvData: z.string().describe('CSV data as string'), + layoutType: z + .enum(['table', 'flowchart', 'mindmap', 'timeline']) + .optional() + .default('table'), + title: z.string().optional(), + hasHeaders: z.boolean().optional().default(true), + delimiter: z.string().optional().default(','), + }), + outputSchema: z.object({ + filename: z.string(), + contents: ExcalidrawSchema, + elementCount: z.number(), + layoutInfo: z.object({ + totalWidth: z.number(), + totalHeight: z.number(), + elementSpacing: z.number(), + }), }), - }), - - execute: async (inputData, context) => { - const writer = context?.writer; - - const tracingContext = context?.tracingContext; - const span = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'csv-to-excalidraw', - input: inputData, - metadata: { - 'tool.id': 'csv-to-excalidraw', - 'tool.input.layoutType': inputData.layoutType, - 'tool.input.hasHeaders': inputData.hasHeaders, - } - }); - - await writer?.custom({ type: 'data-tool-progress', data: { status: 'in-progress', message: '🎨 Converting CSV to Excalidraw', stage: 'csv-to-excalidraw' }, id: 'csv-to-excalidraw' }); - const { csvData, layoutType = 'table', title, hasHeaders = true, delimiter = ',' } = inputData - try { - const lines = String(csvData ?? '').trim().length > 0 ? String(csvData).trim().split('\n') : [] - let headers: string[] = [] - let dataRows: string[][] = [] - - if (hasHeaders && lines.length > 0) { - headers = lines[0].split(delimiter).map((h: string) => h.trim().replace(/"/g, '')) - dataRows = lines.slice(1).map((line: string) => line.split(delimiter).map((v: string) => v.trim().replace(/"/g, ''))) - } else { - dataRows = lines.map((line: string) => line.split(delimiter).map((v: string) => v.trim().replace(/"/g, ''))) - headers = dataRows[0]?.map((_, index) => `Field ${index + 1}`) ?? [] - } - - // Typed elements array to avoid 'never' typing issues - const elements: ExcalidrawElementType[] = [] - const spacing = 120 - const elementWidth = 100 - const elementHeight = 40 - const startX = 50 - const startY = 100 - - // Generate unique IDs - const generateId = (): string => Math.random().toString(36).substring(2, 15) - - // Title - const titleText = typeof title === 'string' ? title.trim() : '' - - if (titleText.length > 0) { - elements.push({ - id: generateId(), - type: 'text', - x: startX, - y: startY - 60, - width: titleText.length * 10, - height: 30, - text: titleText, - fontSize: 20, - fontFamily: 1, - textAlign: 'left', - verticalAlign: 'top', - strokeColor: '#000000', - backgroundColor: 'transparent', - fillStyle: 'solid', - strokeWidth: 2, - strokeStyle: 'solid', - roughness: 1, - opacity: 100, - angle: 0, - seed: Math.floor(Math.random() * 1000000), - version: 1, - versionNonce: Math.floor(Math.random() * 1000000), - isDeleted: false, - boundElements: null, - updated: Date.now(), - link: null, - locked: false, - } as ExcalidrawElementType) - } - - if (layoutType === 'table') { - // Create table layout - let currentY = startY - - // Headers row - headers.forEach((header: string, index: number) => { - elements.push({ - id: generateId(), - type: 'rectangle', - x: startX + (index * (elementWidth + 20)), - y: currentY, - width: elementWidth, - height: elementHeight, - strokeColor: '#1976d2', - backgroundColor: '#e3f2fd', - fillStyle: 'solid', - strokeWidth: 2, - strokeStyle: 'solid', - roughness: 1, - opacity: 100, - angle: 0, - seed: Math.floor(Math.random() * 1000000), - version: 1, - versionNonce: Math.floor(Math.random() * 1000000), - isDeleted: false, - boundElements: null, - updated: Date.now(), - link: null, - locked: false, - } as ExcalidrawElementType) - - elements.push({ - id: generateId(), - type: 'text', - x: startX + (index * (elementWidth + 20)) + 10, - y: currentY + 10, - width: elementWidth - 20, - height: elementHeight - 20, - text: header, - fontSize: 12, - fontFamily: 1, - textAlign: 'center', - verticalAlign: 'middle', - strokeColor: '#000000', - backgroundColor: 'transparent', - fillStyle: 'solid', - strokeWidth: 1, - strokeStyle: 'solid', - roughness: 1, - opacity: 100, - angle: 0, - seed: Math.floor(Math.random() * 1000000), - version: 1, - versionNonce: Math.floor(Math.random() * 1000000), - isDeleted: false, - boundElements: null, - updated: Date.now(), - link: null, - locked: false, - } as ExcalidrawElementType) + execute: async (inputData, context) => { + const writer = context?.writer + + const tracingContext: TracingContext | undefined = + context?.tracingContext + const span = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'csv-to-excalidraw', + input: inputData, + metadata: { + 'tool.id': 'csv-to-excalidraw', + 'tool.input.layoutType': inputData.layoutType, + 'tool.input.hasHeaders': inputData.hasHeaders, + }, }) - currentY += elementHeight + 20 - - // Data rows (limit to first 10 rows to avoid overcrowding) - const maxRows = Math.min(dataRows.length, 10) - for (let rowIndex = 0; rowIndex < maxRows; rowIndex++) { - const row = dataRows[rowIndex] - headers.forEach((_header: string, colIndex: number) => { - elements.push({ - id: generateId(), - type: 'rectangle', - x: startX + (colIndex * (elementWidth + 20)), - y: currentY, - width: elementWidth, - height: elementHeight, - strokeColor: '#666666', - backgroundColor: rowIndex % 2 === 0 ? '#ffffff' : '#f5f5f5', - fillStyle: 'solid', - strokeWidth: 1, - strokeStyle: 'solid', - roughness: 1, - opacity: 100, - angle: 0, - seed: Math.floor(Math.random() * 1000000), - version: 1, - versionNonce: Math.floor(Math.random() * 1000000), - isDeleted: false, - boundElements: null, - updated: Date.now(), - link: null, - locked: false, - } as ExcalidrawElementType) - - elements.push({ - id: generateId(), - type: 'text', - x: startX + (colIndex * (elementWidth + 20)) + 5, - y: currentY + 5, - width: elementWidth - 10, - height: elementHeight - 10, - text: row[colIndex] || '', - fontSize: 10, - fontFamily: 1, - textAlign: 'left', - verticalAlign: 'top', - strokeColor: '#000000', - backgroundColor: 'transparent', - fillStyle: 'solid', - strokeWidth: 1, - strokeStyle: 'solid', - roughness: 1, - opacity: 100, - angle: 0, - seed: Math.floor(Math.random() * 1000000), - version: 1, - versionNonce: Math.floor(Math.random() * 1000000), - isDeleted: false, - boundElements: null, - updated: Date.now(), - link: null, - locked: false, - } as ExcalidrawElementType) - }) - currentY += elementHeight + 10 - } - } - - const totalWidth = headers.length * (elementWidth + 20) + 20 - const totalHeight = elements.length > 0 - ? Math.max(...elements.map((el) => (el.y ?? 0) + (el.height ?? 0))) - startY + 100 - : 400 - - const excalidrawData = { - type: 'excalidraw' as const, - version: 2, - source: 'https://excalidraw.com', - elements, - appState: { - gridSize: 20, - viewBackgroundColor: '#ffffff', - }, - files: {}, - } - - const filename = `csv-diagram-${Date.now()}.excalidraw` - - await writer?.custom({ type: 'data-tool-progress', data: { status: 'done', message: `✅ Generated Excalidraw diagram with ${elements.length} elements`, stage: 'csv-to-excalidraw' }, id: 'csv-to-excalidraw' }); - - const result = { - filename, - contents: excalidrawData, - elementCount: elements.length, - layoutInfo: { - totalWidth, - totalHeight, - elementSpacing: spacing, - }, - }; - - span?.update({ - output: result, - metadata: { 'tool.output.elementCount': elements.length, 'tool.output.filename': filename } - }); - span?.end(); - return result; - - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - span?.error({ - error: error instanceof Error ? error : new Error(errorMessage), - endSpan: true, - }); - throw error; - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('CSV to Excalidraw tool input streaming started', { - toolCallId, - hook: 'onInputStart', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('CSV to Excalidraw received complete input', { - toolCallId, - layoutType: input.layoutType, - hasHeaders: input.hasHeaders, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('CSV to Excalidraw completed', { - toolCallId, - toolName, - elementCount: output.elementCount, - filename: output.filename, - hook: 'onOutput', - }) - }, -}) - -/** - * Converts image descriptions to CSV format - */ -export const imageToCSVTool = createTool({ - id: 'image-to-csv', - description: 'Converts image analysis data into structured CSV format', - inputSchema: z.object({ - elements: z.array(z.object({ - id: z.string(), - type: z.string(), - x: z.number(), - y: z.number(), - width: z.number().optional(), - height: z.number().optional(), - text: z.string().optional(), - strokeColor: z.string().optional(), - backgroundColor: z.string().optional(), - description: z.string().optional(), - })).describe('Array of visual elements from image analysis'), - filename: z.string().optional().describe('Optional filename for the CSV output'), - }), - outputSchema: z.object({ - csvContent: z.string(), - filename: z.string(), - elementCount: z.number(), - columns: z.array(z.string()), - }), - - execute: async (inputData, context) => { - const writer = context?.writer; - - const tracingContext = context?.tracingContext; - const span = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'image-to-csv', - input: inputData, - metadata: { - 'tool.id': 'image-to-csv', - 'tool.input.elementCount': inputData.elements.length, - } - }); - - await writer?.custom({ type: 'data-tool-progress', data: { status: 'in-progress', message: '🖼️ Converting image analysis to CSV', stage: 'image-to-csv' }, id: 'image-to-csv' }); - const { elements, filename } = inputData - - const columns = [ - 'id', 'type', 'x', 'y', 'width', 'height', 'text', - 'strokeColor', 'backgroundColor', 'description' - ] - - let csvContent = columns.join(',') + '\n' - - elements.forEach(element => { - const row = columns.map(col => { - const value = (element as Record<string, unknown>)[col] ?? '' - // Escape CSV values containing commas, quotes, or newlines - if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) { - return `"${value.replace(/"/g, '""')}"` - } - return value - }) - csvContent += row.join(',') + '\n' - }) - - const outputFilename = filename ?? `image-analysis-${Date.now()}.csv` - - await writer?.custom({ type: 'data-tool-progress', data: { status: 'done', message: `✅ Converted ${elements.length} elements to CSV`, stage: 'image-to-csv' }, id: 'image-to-csv' }); - - const result = { - csvContent, - filename: outputFilename, - elementCount: elements.length, - columns, - }; - - span?.update({ - output: result, - metadata: { 'tool.output.elementCount': elements.length, 'tool.output.filename': outputFilename } - }); - span?.end(); - return result; - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Image to CSV tool input streaming started', { - toolCallId, - hook: 'onInputStart', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Image to CSV received complete input', { - toolCallId, - elementCount: input.elements.length, - filename: input.filename, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('Image to CSV completed', { - toolCallId, - toolName, - elementCount: output.elementCount, - filename: output.filename, - hook: 'onOutput', - }) - }, -}) - -/** - * Validates and fixes Excalidraw JSON - */ -export const validateExcalidrawTool = createTool({ - id: 'validate-excalidraw', - description: 'Validates and fixes Excalidraw JSON format', - inputSchema: z.object({ - excalidrawData: z.any().describe('Excalidraw JSON data to validate'), - autoFix: z.boolean().optional().default(true), - }), - outputSchema: z.object({ - isValid: z.boolean(), - fixedData: ExcalidrawSchema.optional(), - errors: z.array(z.string()), - warnings: z.array(z.string()), - elementCount: z.number(), - }), - - execute: async (inputData, context) => { - const writer = context?.writer; - - const tracingContext = context?.tracingContext; - const span = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'validate-excalidraw', - input: inputData, - metadata: { - 'tool.id': 'validate-excalidraw', - 'tool.input.autoFix': inputData.autoFix, - } - }); - - await writer?.custom({ type: 'data-tool-progress', data: { status: 'in-progress', message: '🔍 Validating Excalidraw data', stage: 'validate-excalidraw' }, id: 'validate-excalidraw' }); - const { excalidrawData, autoFix = true } = inputData - const errors: string[] = [] - const warnings: string[] = [] - - try { - // Validate against schema - const validation = ExcalidrawSchema.safeParse(excalidrawData) - - if (!validation.success) { - errors.push(...validation.error.issues.map(e => `${e.path.join('.')}: ${e.message}`)) - } + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: '🔁 Converting CSV to Excalidraw', + stage: 'csv-to-excalidraw', + }, + id: 'csv-to-excalidraw', + }) - let fixedData = excalidrawData + try { + const { csvData, delimiter = ',', hasHeaders = true, layoutType = 'table', title } = inputData - if (autoFix && errors.length > 0) { - // Auto-fix common issues - fixedData = { ...excalidrawData } + const lines = String(csvData || '').trim().split('\n').filter((l) => l.length > 0) - // Ensure required fields - fixedData.type ??= 'excalidraw'; - fixedData.version ??= 2; - fixedData.source = fixedData.source ?? 'https://excalidraw.com'; - fixedData.elements ??= []; - fixedData.appState ??= { viewBackgroundColor: '#ffffff' }; + let headers: string[] = [] + const rows: string[][] = [] - // Fix elements - if (Array.isArray(fixedData.elements)) { - fixedData.elements = fixedData.elements.map((element: unknown, index: number) => { - const fixedElement = { ...(element as Partial<ExcalidrawElementType>) } as Partial<ExcalidrawElementType> + if (hasHeaders && lines.length > 0) { + headers = lines[0].split(delimiter).map((h) => h.trim().replace(/"/g, '')) + for (let i = 1; i < lines.length; i++) { + rows.push(lines[i].split(delimiter).map((v) => v.trim().replace(/"/g, ''))) + } + } else { + for (const line of lines) { + rows.push(line.split(delimiter).map((v) => v.trim().replace(/"/g, ''))) + } + headers = rows.length > 0 ? rows[0].map((_, i) => `col${i + 1}`) : [] + } - // Generate ID if missing or empty - if ( - fixedElement.id === undefined || - fixedElement.id === null || - (typeof fixedElement.id === 'string' && fixedElement.id.trim() === '') - ) { - fixedElement.id = `element-${Date.now()}-${index}` - warnings.push(`Generated missing ID for element ${index}`) + const elements: ExcalidrawElementType[] = [] + const generateId = () => Math.random().toString(36).substring(2, 15) + + // Layout parameters + const cellW = 120 + const cellH = 36 + const spacing = 8 + const startX = 50 + let startY = 50 + + // Add title if provided + if (typeof title === 'string' && title.trim().length > 0) { + elements.push({ + id: generateId(), + type: 'text', + x: startX, + y: startY, + width: Math.max(100, title.length * 8), + height: 30, + text: title, + fontSize: 20, + fontFamily: 1, + textAlign: 'left', + verticalAlign: 'top', + strokeColor: '#000000', + backgroundColor: 'transparent', + fillStyle: 'solid', + strokeWidth: 1, + strokeStyle: 'solid', + roughness: 1, + opacity: 100, + angle: 0, + seed: Math.floor(Math.random() * 1000000), + version: 1, + versionNonce: Math.floor(Math.random() * 1000000), + isDeleted: false, + boundElements: null, + updated: Date.now(), + link: null, + locked: false, + } as ExcalidrawElementType) + + startY += 40 } - fixedElement.type = fixedElement.type ?? 'rectangle'; - fixedElement.strokeColor ??= '#000000'; - fixedElement.backgroundColor ??= 'transparent'; - fixedElement.fillStyle = fixedElement.fillStyle ?? 'solid'; - fixedElement.strokeWidth ??= 2; - fixedElement.strokeStyle = fixedElement.strokeStyle ?? 'solid'; - fixedElement.roughness ??= 1; - fixedElement.opacity ??= 100; - fixedElement.angle ??= 0; - fixedElement.seed ??= Math.floor(Math.random() * 1000000); - fixedElement.version ??= 1; - fixedElement.versionNonce ??= Math.floor(Math.random() * 1000000); - fixedElement.isDeleted ??= false; - if (fixedElement.boundElements === undefined) { fixedElement.boundElements = null } - fixedElement.updated ??= Date.now(); - if (fixedElement.link === undefined) { fixedElement.link = null } - fixedElement.locked ??= false; - - // Type-specific fixes - if (fixedElement.type === 'rectangle' || fixedElement.type === 'ellipse') { - fixedElement.width ??= 100; - fixedElement.height ??= 50; + // If has headers, render header row first + let currentY = startY + if (hasHeaders && headers.length > 0) { + for (let col = 0; col < headers.length; col++) { + const x = startX + col * (cellW + spacing) + // cell background + elements.push({ + id: generateId(), + type: 'rectangle', + x, + y: currentY, + width: cellW, + height: cellH, + strokeColor: '#000000', + backgroundColor: '#f3f4f6', + fillStyle: 'solid', + strokeWidth: 1, + strokeStyle: 'solid', + roughness: 1, + opacity: 100, + angle: 0, + seed: Math.floor(Math.random() * 1000000), + version: 1, + versionNonce: Math.floor(Math.random() * 1000000), + isDeleted: false, + boundElements: null, + updated: Date.now(), + link: null, + locked: false, + } as ExcalidrawElementType) + + // header text + elements.push({ + id: generateId(), + type: 'text', + x: x + 8, + y: currentY + 6, + width: cellW - 16, + height: 24, + text: headers[col], + fontSize: 14, + fontFamily: 1, + textAlign: 'left', + verticalAlign: 'top', + strokeColor: '#111827', + backgroundColor: 'transparent', + fillStyle: 'solid', + strokeWidth: 1, + strokeStyle: 'solid', + roughness: 1, + opacity: 100, + angle: 0, + seed: Math.floor(Math.random() * 1000000), + version: 1, + versionNonce: Math.floor(Math.random() * 1000000), + isDeleted: false, + boundElements: null, + updated: Date.now(), + link: null, + locked: false, + } as ExcalidrawElementType) + } + currentY += cellH + spacing } - if (fixedElement.type === 'text') { - fixedElement.fontSize ??= 20; - fixedElement.fontFamily ??= 1; - fixedElement.textAlign ??= 'left'; - fixedElement.verticalAlign ??= 'top'; + // Render data rows + for (let r = 0; r < rows.length; r++) { + const row = rows[r] + for (let col = 0; col < Math.max(row.length, headers.length); col++) { + const x = startX + col * (cellW + spacing) + const value = row[col] ?? '' + + elements.push({ + id: generateId(), + type: 'rectangle', + x, + y: currentY, + width: cellW, + height: cellH, + strokeColor: '#000000', + backgroundColor: '#ffffff', + fillStyle: 'solid', + strokeWidth: 1, + strokeStyle: 'solid', + roughness: 1, + opacity: 100, + angle: 0, + seed: Math.floor(Math.random() * 1000000), + version: 1, + versionNonce: Math.floor(Math.random() * 1000000), + isDeleted: false, + boundElements: null, + updated: Date.now(), + link: null, + locked: false, + } as ExcalidrawElementType) + + elements.push({ + id: generateId(), + type: 'text', + x: x + 8, + y: currentY + 6, + width: cellW - 16, + height: 24, + text: String(value), + fontSize: 14, + fontFamily: 1, + textAlign: 'left', + verticalAlign: 'top', + strokeColor: '#111827', + backgroundColor: 'transparent', + fillStyle: 'solid', + strokeWidth: 1, + strokeStyle: 'solid', + roughness: 1, + opacity: 100, + angle: 0, + seed: Math.floor(Math.random() * 1000000), + version: 1, + versionNonce: Math.floor(Math.random() * 1000000), + isDeleted: false, + boundElements: null, + updated: Date.now(), + link: null, + locked: false, + } as ExcalidrawElementType) + } + currentY += cellH + spacing } - if (fixedElement.type === 'arrow') { - fixedElement.points = fixedElement.points ?? [[0, 0], [100, 0]]; - if (fixedElement.arrowheads === undefined) { fixedElement.arrowheads = 'arrow' } + const totalCols = Math.max(headers.length, rows[0]?.length ?? 0) + const totalWidth = totalCols * cellW + Math.max(0, totalCols - 1) * spacing + const totalHeight = currentY - startY + + const excalidrawData = { + type: 'excalidraw' as const, + version: 2, + source: 'csv-to-excalidraw', + elements, + appState: { + gridSize: 20, + viewBackgroundColor: '#ffffff', + }, + files: {}, } - return fixedElement - }) - } + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `✅ Converted ${elements.length} elements to Excalidraw`, + stage: 'csv-to-excalidraw', + }, + id: 'csv-to-excalidraw', + }) + + const result = { + filename: `csv-to-excalidraw-${Date.now()}.excalidraw.json`, + contents: excalidrawData, + elementCount: elements.length, + layoutInfo: { + totalWidth, + totalHeight, + elementSpacing: spacing, + }, + } - // Re-validate after fixes - const revalidation = ExcalidrawSchema.safeParse(fixedData) - if (revalidation.success) { - errors.length = 0 // Clear errors if fixed successfully - warnings.push('Auto-fixed validation errors') + span?.update({ + output: result, + metadata: { + 'tool.output.elementCount': result.elementCount, + 'tool.output.totalWidth': totalWidth, + 'tool.output.totalHeight': totalHeight, + }, + }) + span?.end() + return result + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error) + span?.error({ + error: error instanceof Error ? error : new Error(errorMessage), + endSpan: true, + }) + throw new Error(`CSV to Excalidraw conversion failed: ${errorMessage}`) } - } - - await writer?.custom({ type: 'data-tool-progress', data: { status: 'done', message: `✅ Validation complete. Valid: ${errors.length === 0}`, stage: 'validate-excalidraw' }, id: 'validate-excalidraw' }); - const result = { - isValid: errors.length === 0, - fixedData: errors.length === 0 ? undefined : fixedData, - errors, - warnings, - elementCount: Array.isArray(fixedData?.elements) ? fixedData.elements.length : 0, - }; - - span?.update({ - output: result, - metadata: { 'tool.output.isValid': result.isValid, 'tool.output.errorCount': errors.length } - }); - span?.end(); - return result; - } catch (error) { - const errorMsg = String(error); - span?.error({ - error: error instanceof Error ? error : new Error(errorMsg), - endSpan: true, - }); - return { - isValid: false, - errors: [`Validation failed: ${errorMsg}`], - warnings, - elementCount: 0, - } - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Validate Excalidraw tool input streaming started', { - toolCallId, - hook: 'onInputStart', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Validate Excalidraw received complete input', { - toolCallId, - autoFix: input.autoFix, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('Validate Excalidraw completed', { - toolCallId, - toolName, - isValid: output.isValid, - errorCount: output.errors.length, - elementCount: output.elementCount, - hook: 'onOutput', - }) - }, + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('CSV to Excalidraw tool input streaming started', { + toolCallId, + hook: 'onInputStart', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('CSV to Excalidraw received complete input', { + toolCallId, + rows: String(input.csvData ?? '').split('\n').length, + layoutType: input.layoutType, + hasHeaders: input.hasHeaders, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('CSV to Excalidraw completed', { + toolCallId, + toolName, + elementCount: output.elementCount, + layoutInfo: output.layoutInfo, + hook: 'onOutput', + }) + }, }) /** - * Processes SVG data and converts to JSON + * Validates data against a schema */ -export const processSVGTool = createTool({ - id: 'process-svg', - description: 'Processes SVG data and converts to structured JSON format', - inputSchema: z.object({ - svgContent: z.string().describe('SVG content as string'), - extractPaths: z.boolean().optional().default(true), - extractText: z.boolean().optional().default(true), - extractStyles: z.boolean().optional().default(true), - }), - outputSchema: z.object({ - svgJson: z.any(), - paths: z.array(z.any()).optional(), - textElements: z.array(z.string()).optional(), - styles: z.record(z.string(), z.string()).optional(), - dimensions: z.object({ - width: z.number().optional(), - height: z.number().optional(), - viewBox: z.string().optional(), +export const validateDataTool = createTool({ + id: 'validate-data', + description: 'Validates data against a specified schema', + inputSchema: z.object({ + data: z.any().describe('Data to validate'), + schemaType: z.enum(['excalidraw', 'csv', 'json', 'xml']), + strict: z.boolean().optional().default(true), + }), + outputSchema: z.object({ + isValid: z.boolean(), + errors: z.array(z.string()), + warnings: z.array(z.string()), + validatedData: z.any().optional(), }), - elementCount: z.number(), - }), - - execute: async (inputData, context) => { - const writer = context?.writer; - - const tracingContext = context?.tracingContext; - const span = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'process-svg', - input: inputData, - metadata: { - 'tool.id': 'process-svg', - 'tool.input.extractPaths': inputData.extractPaths, - 'tool.input.extractText': inputData.extractText, - } - }); - - await writer?.custom({ type: 'data-tool-progress', data: { status: 'in-progress', message: '🖼️ Processing SVG content', stage: 'process-svg' }, id: 'process-svg' }); - const { svgContent, extractPaths = true, extractText = true, extractStyles = true } = inputData - - try { - // Use svgjson to parse SVG - const svgJson = svgjson.svg2json(svgContent) as SvgJsonNode - - const result: ProcessSVGResult = { - svgJson, - elementCount: 0, - dimensions: {}, - } - - if (svgJson?.attributes) { - const attrs = svgJson.attributes - result.dimensions = { - width: attrs.width ? parseFloat(attrs.width) : undefined, - height: attrs.height ? parseFloat(attrs.height) : undefined, - viewBox: attrs.viewBox ?? undefined, - } - } - - // Extract paths if requested - if (extractPaths && svgJson.children) { - const paths: SvgJsonNode[] = [] - function extractPathsRecursive(element: SvgJsonNode) { - if (element.name === 'path') { - paths.push(element) - } - if (element.children) { - element.children.forEach(extractPathsRecursive) - } - } - extractPathsRecursive(svgJson) - result.paths = paths - } - - // Extract text elements if requested - if (extractText && svgJson.children) { - const textElements: string[] = [] - function extractTextRecursive(element: SvgJsonNode) { - if (element.name === 'text' || element.name === 'tspan') { - textElements.push(element.text ?? element.content ?? '') - } - if (element.children) { - element.children.forEach(extractTextRecursive) - } - } - extractTextRecursive(svgJson) - result.textElements = textElements - } - - // Extract styles if requested - if (extractStyles) { - const styles: Record<string, string> = {} - - function extractStylesRecursive(element: SvgJsonNode) { - if (element.attributes) { - const attrs = element.attributes - Object.keys(attrs).forEach(attr => { - if (attr.startsWith('style') || attr.includes('color') || attr.includes('stroke') || attr.includes('fill')) { - styles[attr] = attrs[attr] - } - }) - } - if (element.children) { - element.children.forEach(extractStylesRecursive) - } - } - extractStylesRecursive(svgJson) - result.styles = styles - } - - result.elementCount = svgJson.children ? svgJson.children.length : 0 - - await writer?.custom({ type: 'data-tool-progress', data: { status: 'done', message: `✅ Processed SVG with ${result.elementCount} elements`, stage: 'process-svg' }, id: 'process-svg' }); - span?.update({ - output: result, - metadata: { 'tool.output.elementCount': result.elementCount } - }); - span?.end(); - return result - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - span?.error({ - error: error instanceof Error ? error : new Error(errorMessage), - endSpan: true, - }); - throw new Error(`SVG processing failed: ${errorMessage}`) - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Process SVG tool input streaming started', { - toolCallId, - hook: 'onInputStart', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Process SVG received complete input', { - toolCallId, - extractPaths: input.extractPaths, - extractText: input.extractText, - extractStyles: input.extractStyles, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('Process SVG completed', { - toolCallId, - toolName, - elementCount: output.elementCount, - hook: 'onOutput', - }) - }, -}) + execute: async (inputData, context) => { + const writer = context?.writer + + const tracingContext: TracingContext | undefined = context?.tracingContext + const span = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'validate-data', + input: inputData, + metadata: { + 'tool.id': 'validate-data', + 'tool.input.schemaType': inputData.schemaType, + 'tool.input.strict': inputData.strict, + }, + }) -/** - * Processes XML data using xmldom - */ -export const processXMLTool = createTool({ - id: 'process-xml', - description: 'Processes XML data and extracts structured information', - inputSchema: z.object({ - xmlContent: z.string().describe('XML content as string'), - extractElements: z.array(z.string()).optional().describe('Specific element names to extract'), - extractAttributes: z.array(z.string()).optional().describe('Specific attributes to extract'), - }), - outputSchema: z.object({ - document: z.any(), - elements: z.array(z.object({ - tagName: z.string(), - textContent: z.string().nullable().optional(), - attributes: z.any(), - children: z.array(z.any()), - })), - extractedData: z.any(), - rootElement: z.string(), - }), - - execute: async (inputData, context) => { - const writer = context?.writer; - - const tracingContext = context?.tracingContext; - const span = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'process-xml', - input: inputData, - metadata: { - 'tool.id': 'process-xml', - 'tool.input.extractElements': inputData.extractElements?.join(','), - } - }); - - await writer?.custom({ type: 'data-tool-progress', data: { status: 'in-progress', message: '📄 Processing XML content', stage: 'process-xml' }, id: 'process-xml' }); - const { xmlContent, extractElements = [], extractAttributes = [] } = inputData + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `✅ Validating ${inputData.schemaType} data`, + stage: 'validate-data', + }, + id: 'validate-data', + }) + const { data, schemaType, strict = true } = inputData + const errors: string[] = [] + const warnings: string[] = [] - try { - // Configure fast-xml-parser for secure and detailed parsing - const parserOptions = { - ignoreAttributes: false, - attributeNamePrefix: '@_', - textNodeName: '#text', - parseAttributeValue: true, - trimValues: true, - ignoreDeclaration: true, - removeNSPrefix: false, - allowBooleanAttributes: true, - } - - const parser = new XMLParser(parserOptions) - const xmlDoc = parser.parse(xmlContent) - - const elements: XmlElementInfo[] = [] - const extractedData: ExtractedData = {} - - function traverseElements(obj: Record<string, unknown> | unknown): XmlElementInfo[] { - const result: XmlElementInfo[] = [] - - if (typeof obj !== 'object' || obj === null) { - return result - } + let isValid = false + let validatedData: unknown = undefined - for (const [key, value] of Object.entries(obj)) { - if (key.startsWith('@_')) { - continue // Skip attributes at this level - } - - const tagName = key - const elementInfo: XmlElementInfo = { - tagName, - textContent: null, - attributes: {} as Record<string, string>, - children: [] as XmlElementInfo[], - } - - if (typeof value === 'object' && value !== null) { - // Extract attributes - for (const [attrKey, attrValue] of Object.entries(value)) { - if (attrKey.startsWith('@_')) { - const attrName = attrKey.substring(2) - elementInfo.attributes[attrName] = String(attrValue) - } else if (attrKey === '#text') { - elementInfo.textContent = String(attrValue) - } - } + try { + switch (schemaType) { + case 'excalidraw': { + const schemaToUse = strict + ? ExcalidrawSchema + : ExcalidrawSchema.partial() + const excalidrawValidation = schemaToUse.safeParse(data) + isValid = excalidrawValidation.success + + if (!isValid && excalidrawValidation.error) { + errors.push( + ...excalidrawValidation.error.issues.map( + (e: z.core.$ZodIssue) => + `${e.path.join('.')}: ${e.message}` + ) + ) + if (!strict) { + // When not strict, attempt to accept partial data + warnings.push( + 'Non-strict validation: some fields are missing or invalid; partial data accepted' + ) + isValid = true + validatedData = data + } + } else { + validatedData = data + } + break + } - // Extract children - for (const [childKey, childValue] of Object.entries(value)) { - if (!childKey.startsWith('@_') && childKey !== '#text') { - if (Array.isArray(childValue)) { - childValue.forEach((item) => { - const childElements = traverseElements({ [childKey]: item }) - elementInfo.children.push(...childElements) - }) - } else { - const childElements = traverseElements({ [childKey]: childValue }) - elementInfo.children.push(...childElements) + case 'csv': { + // Basic CSV validation + const csvString = String(data) + const lines = csvString.trim().split('\n') + + if (lines.length === 0) { + errors.push('CSV data is empty') + } else { + const firstLine = lines[0] + if (!firstLine.includes(',') && strict) { + errors.push( + 'No comma separators found in CSV header' + ) + } + + // Check for consistent column count + const headerColumns = firstLine.split(',').length + for (let i = 1; i < lines.length; i++) { + const columns = lines[i].split(',').length + if (columns !== headerColumns) { + // Duplicated checking logic in original code, simplifying + if (strict) { + errors.push( + `Row ${i + 1} has ${columns} columns, expected ${headerColumns}` + ) + } else { + warnings.push( + `Row ${i + 1} has ${columns} columns, expected ${headerColumns}` + ) + } + } + } + + isValid = errors.length === 0 + if (isValid) { + validatedData = data + } else if (!strict && warnings.length > 0) { + // In lenient mode, accept CSV with warnings + isValid = true + validatedData = data + } + } + break } - } - } - } else { - elementInfo.textContent = String(value) - } - result.push(elementInfo) - elements.push(elementInfo) - } + case 'json': + try { + JSON.parse(String(data)) + isValid = true + validatedData = data + } catch (parseError) { + errors.push(`Invalid JSON: ${String(parseError)}`) + } + break + + case 'xml': + try { + const parserOptions = { + ignoreAttributes: false, + attributeNamePrefix: '@_', + textNodeName: '#text', + parseAttributeValue: true, + trimValues: true, + } + const parser = new XMLParser(parserOptions) + const parsed = parser.parse(String(data)) + + if ( + parsed === null || + parsed === undefined || + (typeof parsed === 'object' && + Object.keys(parsed).length === 0) + ) { + if (strict) { + errors.push( + 'XML parsing error: no valid root element found' + ) + } else { + warnings.push( + 'XML parsing error: non-strict mode accepted' + ) + isValid = true + validatedData = data + } + } else { + isValid = true + validatedData = data + } + } catch (parseError) { + if (strict) { + errors.push( + `XML validation failed: ${String(parseError)}` + ) + } else { + warnings.push( + `XML validation error ignored in non-strict mode: ${String(parseError)}` + ) + isValid = true + validatedData = data + } + } + break + } - return result - } + await writer?.write({ + type: 'progress', + data: { message: `✅ Validation complete. Valid: ${isValid}` }, + }) - // Start traversal from root - traverseElements(xmlDoc) + const result = { + isValid, + errors, + warnings, + validatedData, + } - // Extract specific elements if requested - if (extractElements.length > 0) { - extractElements.forEach(elementName => { - const matchingElements = elements.filter(el => el.tagName === elementName) - if (matchingElements.length > 0) { - extractedData[elementName] = matchingElements - } - }) - } - - // Extract specific attributes if requested - if (extractAttributes.length > 0) { - extractAttributes.forEach(attrName => { - elements.forEach(element => { - if (element.attributes[attrName]) { - // Initialize the attribute extraction array with the correct type - if (!(attrName in extractedData)) { - extractedData[attrName] = [] as Array<{ element: string; value: string }> - } - ; (extractedData[attrName] as Array<{ element: string; value: string }>).push({ - element: element.tagName, - value: element.attributes[attrName], - }) + span?.update({ + output: result, + metadata: { + 'tool.output.isValid': isValid, + 'tool.output.errorCount': errors.length, + }, + }) + span?.end() + return result + } catch (error) { + const errorMsg = String(error) + span?.error({ + error: error instanceof Error ? error : new Error(errorMsg), + endSpan: true, + }) + return { + isValid: false, + errors: [`Validation failed: ${errorMsg}`], + warnings, + elementCount: 0, } - }) + } + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Validate data tool input streaming started', { + toolCallId, + hook: 'onInputStart', }) - } - - await writer?.custom({ type: 'data-tool-progress', data: { status: 'done', message: `✅ Processed XML with ${elements.length} elements`, stage: 'process-xml' }, id: 'process-xml' }); - - const rootElement = elements.length > 0 ? elements[0].tagName : 'unknown' - - const result = { - document: xmlDoc, - elements, - extractedData, - rootElement, - }; - - span?.update({ - output: result, - metadata: { 'tool.output.elementCount': elements.length, 'tool.output.rootElement': result.rootElement } - }); - span?.end(); - return result; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - span?.error({ - error: error instanceof Error ? error : new Error(errorMessage), - endSpan: true, - }); - throw new Error(`XML processing failed: ${errorMessage}`) - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Process XML tool input streaming started', { - toolCallId, - hook: 'onInputStart', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Process XML received complete input', { - toolCallId, - extractElements: input.extractElements?.join(','), - extractAttributes: input.extractAttributes?.join(','), - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('Process XML completed', { - toolCallId, - toolName, - elementCount: output.elements.length, - rootElement: output.rootElement, - hook: 'onOutput', - }) - }, + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Validate data received complete input', { + toolCallId, + schemaType: input.schemaType, + strict: input.strict, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('Validate data completed', { + toolCallId, + toolName, + isValid: output.isValid, + errorCount: output.errors.length, + hook: 'onOutput', + }) + }, }) /** - * Converts data between different formats + * Helper function to convert JSON to XML using fast-xml-parser */ -export const convertDataFormatTool = createTool({ - id: 'convert-data-format', - description: 'Converts data between different formats (JSON, CSV, XML)', - inputSchema: z.object({ - inputData: z.any().describe('Input data to convert'), - inputFormat: z.enum(['json', 'csv', 'xml', 'excalidraw']), - outputFormat: z.enum(['json', 'csv', 'xml', 'excalidraw']), - options: z.any().optional().describe('Conversion options'), - }), - outputSchema: z.object({ - convertedData: z.any(), - format: z.string(), - metadata: z.object({ - originalFormat: z.string(), - targetFormat: z.string(), - conversionType: z.string(), - }), - }), - - execute: async (inputData, context) => { - const writer = context?.writer; - - const tracingContext = context?.tracingContext; - const span = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'convert-data-format', - input: inputData, - metadata: { - 'tool.id': 'convert-data-format', - 'tool.input.inputFormat': inputData.inputFormat, - 'tool.input.outputFormat': inputData.outputFormat, - } - }); - - await writer?.custom({ type: 'data-tool-progress', data: { status: 'in-progress', message: `🔄 Converting data from ${inputData.inputFormat} to ${inputData.outputFormat}`, stage: 'convert-data-format' }, id: 'convert-data-format' }); - const { inputData: dataToConvert, inputFormat, outputFormat, options = {} } = inputData - - let convertedData: string | Record<string, unknown> | Array<Record<string, unknown>> | undefined - const metadata = { - originalFormat: inputFormat, - targetFormat: outputFormat, - conversionType: `${inputFormat}-${outputFormat}`, - } +type JSONLike = + | string + | number + | boolean + | null + | JSONLike[] + | { [key: string]: JSONLike } +function jsonToXML(data: JSONLike, rootElement = 'root'): string { try { - switch (`${inputFormat}-${outputFormat}`) { - case 'json-csv': - if (Array.isArray(dataToConvert)) { - const rows = dataToConvert as Array<Record<string, unknown>> - const headers = Object.keys(rows[0] ?? {}) - let csv = headers.join(',') + '\n' - rows.forEach((row: Record<string, unknown>) => { - const values = headers.map(header => { - const value = row[header] - if (value === undefined || value === null) { - return '' - } - if (typeof value === 'string') { - return (value.includes(',') || value.includes('"')) - ? `"${value.replace(/"/g, '""')}"` - : value - } - // Convert non-string values to string - return String(value) - }) - csv += values.join(',') + '\n' - }) - convertedData = csv - } else { - convertedData = JSON.stringify(dataToConvert) - } - break - - case 'csv-json': - { - const lines = String(dataToConvert).trim().split('\n') - const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, '')) - convertedData = [] - - for (let i = 1; i < lines.length; i++) { - const values = lines[i].split(',').map(v => v.trim().replace(/"/g, '')) - const obj: Record<string, string> = {} - headers.forEach((header, index) => { - obj[header] = values[index] ?? '' - }) - convertedData.push(obj) - } - break - } - - case 'json-xml': - // Simple JSON to XML conversion - convertedData = jsonToXML(dataToConvert, options.rootElement ?? 'root') - break - - case 'xml-json': - // This would require more complex parsing - convertedData = { error: 'XML to JSON conversion not implemented in this tool. Use processXMLTool instead.' } - break - - case 'excalidraw-csv': - // Ensure dataToConvert is an object with an elements array before proceeding - { - type GenericElement = Record<string, unknown> - const inputObj = dataToConvert as { elements?: GenericElement[] } | null | undefined - if (inputObj !== null && inputObj !== undefined && Array.isArray(inputObj.elements)) { - const columns = ['id', 'type', 'x', 'y', 'width', 'height', 'text', 'strokeColor', 'backgroundColor'] - let csv = columns.join(',') + '\n' - - const elementsArray = inputObj.elements - elementsArray.forEach((element: GenericElement) => { - const row = columns.map(col => { - const value = (element[col] ?? '') - return typeof value === 'string' && (value.includes(',') || value.includes('"')) - ? `"${value.replace(/"/g, '""')}"` - : value - }) - csv += row.join(',') + '\n' - }) - convertedData = csv - } else { - convertedData = dataToConvert - } - } - break - - default: - convertedData = dataToConvert - metadata.conversionType = 'no-conversion' - } - - await writer?.custom({ type: 'data-tool-progress', data: { status: 'done', message: '✅ Data conversion successful', stage: 'convert-data-format' }, id: 'convert-data-format' }); - - const result = { - convertedData, - format: outputFormat, - metadata, - }; - - span?.update({ - output: result, - metadata: { 'tool.output.success': true, 'tool.output.format': outputFormat } - }); - span?.end(); - return result; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - span?.error({ - error: error instanceof Error ? error : new Error(errorMessage), - endSpan: true, - }); - throw new Error(`Data conversion failed: ${errorMessage}`) - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Convert data format tool input streaming started', { - toolCallId, - hook: 'onInputStart', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Convert data format received complete input', { - toolCallId, - inputFormat: input.inputFormat, - outputFormat: input.outputFormat, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('Convert data format completed', { - toolCallId, - toolName, - success: true, - format: output.format, - hook: 'onOutput', - }) - }, -}) + const builderOptions = { + ignoreAttributes: false, + attributeNamePrefix: '@_', + textNodeName: '#text', + format: true, + indentBy: ' ', + suppressEmptyNode: false, + } -/** - * Validates data against a schema - */ -export const validateDataTool = createTool({ - id: 'validate-data', - description: 'Validates data against a specified schema', - inputSchema: z.object({ - data: z.any().describe('Data to validate'), - schemaType: z.enum(['excalidraw', 'csv', 'json', 'xml']), - strict: z.boolean().optional().default(true), - }), - outputSchema: z.object({ - isValid: z.boolean(), - errors: z.array(z.string()), - warnings: z.array(z.string()), - validatedData: z.any().optional(), - }), - - execute: async (inputData, context) => { - const writer = context?.writer; - - const tracingContext = context?.tracingContext; - const span = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'validate-data', - input: inputData, - metadata: { - 'tool.id': 'validate-data', - 'tool.input.schemaType': inputData.schemaType, - 'tool.input.strict': inputData.strict, - } - }); - - await writer?.custom({ type: 'data-tool-progress', data: { status: 'done', message: `✅ Validating ${inputData.schemaType} data`, stage: 'validate-data' }, id: 'validate-data' }); - const { data, schemaType, strict = true } = inputData - const errors: string[] = [] - const warnings: string[] = [] - - let isValid = false - let validatedData: unknown = undefined + const builder = new XMLBuilder(builderOptions) + const xmlObj = { [rootElement]: data } + return builder.build(xmlObj) + } catch { + // Fallback to simple conversion if fast-xml-parser fails + function objectToXML(currentData: JSONLike, nodeName: string): string { + if (currentData === null || currentData === undefined) { + return '' + } - try { - switch (schemaType) { - case 'excalidraw': - { - const schemaToUse = strict ? ExcalidrawSchema : ExcalidrawSchema.partial() - const excalidrawValidation = schemaToUse.safeParse(data) - isValid = excalidrawValidation.success - - if (!isValid && excalidrawValidation.error) { - errors.push(...excalidrawValidation.error.issues.map((e: z.core.$ZodIssue) => `${e.path.join('.')}: ${e.message}`)) - if (!strict) { - // When not strict, attempt to accept partial data - warnings.push('Non-strict validation: some fields are missing or invalid; partial data accepted') - isValid = true - validatedData = data - } - } else { - validatedData = data + if ( + typeof currentData === 'string' || + typeof currentData === 'number' || + typeof currentData === 'boolean' + ) { + return `<${nodeName}>${currentData}</${nodeName}>` } - break - } - case 'csv': - // Basic CSV validation - { - const csvString = String(data) - const lines = csvString.trim().split('\n') + if (Array.isArray(currentData)) { + const singular = + nodeName.endsWith('s') && nodeName.length > 1 + ? nodeName.slice(0, -1) + : nodeName + return currentData + .map((item) => objectToXML(item, singular)) + .join('') + } - if (lines.length === 0) { - errors.push('CSV data is empty') - } else { - const firstLine = lines[0] - if (!firstLine.includes(',') && strict) { - errors.push('No comma separators found in CSV header') - } - - // Check for consistent column count - const headerColumns = firstLine.split(',').length - for (let i = 1; i < lines.length; i++) { - const columns = lines[i].split(',').length - if (columns !== headerColumns) { - // Duplicated checking logic in original code, simplifying - if (strict) { - errors.push(`Row ${i + 1} has ${columns} columns, expected ${headerColumns}`) - } else { - warnings.push(`Row ${i + 1} has ${columns} columns, expected ${headerColumns}`) - } + if (typeof currentData === 'object') { + let xml = `<${nodeName}>` + for (const [key, value] of Object.entries(currentData)) { + xml += objectToXML(value, key) } - } - - isValid = errors.length === 0 - if (isValid) { - validatedData = data - } else if (!strict && warnings.length > 0) { - // In lenient mode, accept CSV with warnings - isValid = true - validatedData = data - } - } - break - } - - case 'json': - try { - JSON.parse(String(data)) - isValid = true - validatedData = data - } catch (parseError) { - errors.push(`Invalid JSON: ${String(parseError)}`) - } - break - - case 'xml': - try { - const parserOptions = { - ignoreAttributes: false, - attributeNamePrefix: '@_', - textNodeName: '#text', - parseAttributeValue: true, - trimValues: true, - } - const parser = new XMLParser(parserOptions) - const parsed = parser.parse(String(data)) - - if (parsed === null || parsed === undefined || (typeof parsed === 'object' && Object.keys(parsed).length === 0)) { - if (strict) { - errors.push('XML parsing error: no valid root element found') - } else { - warnings.push('XML parsing error: non-strict mode accepted') - isValid = true - validatedData = data - } - } else { - isValid = true - validatedData = data + xml += `</${nodeName}>` + return xml } - } catch (parseError) { - if (strict) { - errors.push(`XML validation failed: ${String(parseError)}`) - } else { - warnings.push(`XML validation error ignored in non-strict mode: ${String(parseError)}`) - isValid = true - validatedData = data - } - } - break - } - - await writer?.write({ type: 'progress', data: { message: `✅ Validation complete. Valid: ${isValid}` } }); - - const result = { - isValid, - errors, - warnings, - validatedData, - }; - - span?.update({ - output: result, - metadata: { 'tool.output.isValid': isValid, 'tool.output.errorCount': errors.length } - }); - span?.end(); - return result; - } catch (error) { - const errorMsg = String(error); - span?.error({ - error: error instanceof Error ? error : new Error(errorMsg), - endSpan: true, - }); - return { - isValid: false, - errors: [`Validation failed: ${errorMsg}`], - warnings, - elementCount: 0, - } - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Validate data tool input streaming started', { - toolCallId, - hook: 'onInputStart', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Validate data received complete input', { - toolCallId, - schemaType: input.schemaType, - strict: input.strict, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('Validate data completed', { - toolCallId, - toolName, - isValid: output.isValid, - errorCount: output.errors.length, - hook: 'onOutput', - }) - }, -}) -/** - * Helper function to convert JSON to XML using fast-xml-parser - */ -type JSONLike = string | number | boolean | null | JSONLike[] | { [key: string]: JSONLike } - -function jsonToXML(data: JSONLike, rootElement = 'root'): string { - try { - const builderOptions = { - ignoreAttributes: false, - attributeNamePrefix: '@_', - textNodeName: '#text', - format: true, - indentBy: ' ', - suppressEmptyNode: false, - } - - const builder = new XMLBuilder(builderOptions) - const xmlObj = { [rootElement]: data } - return builder.build(xmlObj) - } catch { - // Fallback to simple conversion if fast-xml-parser fails - function objectToXML(currentData: JSONLike, nodeName: string): string { - if (currentData === null || currentData === undefined) { - return '' - } - - if (typeof currentData === 'string' || typeof currentData === 'number' || typeof currentData === 'boolean') { - return `<${nodeName}>${currentData}</${nodeName}>` - } - - if (Array.isArray(currentData)) { - const singular = nodeName.endsWith('s') && nodeName.length > 1 ? nodeName.slice(0, -1) : nodeName - return currentData.map((item) => objectToXML(item, singular)).join('') - } - - if (typeof currentData === 'object') { - let xml = `<${nodeName}>` - for (const [key, value] of Object.entries(currentData)) { - xml += objectToXML(value, key) + return '' } - xml += `</${nodeName}>` - return xml - } - return '' + return objectToXML(data, rootElement) } - - return objectToXML(data, rootElement) - } } - /** * Converts Excalidraw diagram to SVG format */ export const excalidrawToSVGTool = createTool({ - id: 'excalidraw-to-svg', - description: 'Converts Excalidraw diagram data to SVG format for rendering and export', - inputSchema: z.object({ - excalidrawData: ExcalidrawSchema.describe('Excalidraw diagram data to convert'), - exportOptions: z.object({ - exportPadding: z.number().optional().default(10).describe('Padding around the diagram in pixels'), - exportBackground: z.boolean().optional().default(true).describe('Whether to include background color'), - viewBackgroundColor: z.string().optional().describe('Background color override'), - exportWithDarkMode: z.boolean().optional().default(false).describe('Export with dark mode theme'), - exportScale: z.number().optional().default(1).describe('Scale factor for export (1 = 100%)'), - }).optional(), - saveToFile: z.boolean().optional().describe('Whether to save SVG to file'), - fileName: z.string().optional().describe('Optional filename for saved SVG'), - }), - outputSchema: z.object({ - svgContent: z.string().describe('Generated SVG content'), - savedFilePath: z.string().optional().describe('Path to saved file if saveToFile was true'), - dimensions: z.object({ - width: z.number(), - height: z.number(), - }).describe('SVG dimensions'), - elementCount: z.number().describe('Number of Excalidraw elements converted'), - }), - - execute: async (inputData, context) => { - const writer = context?.writer; - - const tracingContext = context?.tracingContext; - const span = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'excalidraw-to-svg', - input: inputData, - metadata: { - 'tool.id': 'excalidraw-to-svg', - 'tool.input.elementCount': inputData.excalidrawData.elements.length, - 'tool.input.saveToFile': inputData.saveToFile, - } - }); - - await writer?.custom({ type: 'data-tool-progress', data: { status: 'in-progress', message: '🎨 Converting Excalidraw to SVG', stage: 'excalidraw-to-svg' }, id: 'excalidraw-to-svg' }); + id: 'excalidraw-to-svg', + description: + 'Converts Excalidraw diagram data to SVG format for rendering and export', + inputSchema: z.object({ + excalidrawData: ExcalidrawSchema.describe( + 'Excalidraw diagram data to convert' + ), + exportOptions: z + .object({ + exportPadding: z + .number() + .optional() + .default(10) + .describe('Padding around the diagram in pixels'), + exportBackground: z + .boolean() + .optional() + .default(true) + .describe('Whether to include background color'), + viewBackgroundColor: z + .string() + .optional() + .describe('Background color override'), + exportWithDarkMode: z + .boolean() + .optional() + .default(false) + .describe('Export with dark mode theme'), + exportScale: z + .number() + .optional() + .default(1) + .describe('Scale factor for export (1 = 100%)'), + }) + .optional(), + saveToFile: z + .boolean() + .optional() + .describe('Whether to save SVG to file'), + fileName: z + .string() + .optional() + .describe('Optional filename for saved SVG'), + }), + outputSchema: z.object({ + svgContent: z.string().describe('Generated SVG content'), + savedFilePath: z + .string() + .optional() + .describe('Path to saved file if saveToFile was true'), + dimensions: z + .object({ + width: z.number(), + height: z.number(), + }) + .describe('SVG dimensions'), + elementCount: z + .number() + .describe('Number of Excalidraw elements converted'), + }), + + execute: async (inputData, context) => { + const writer = context?.writer + + const tracingContext: TracingContext | undefined = context?.tracingContext + const span = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'excalidraw-to-svg', + input: inputData, + metadata: { + 'tool.id': 'excalidraw-to-svg', + 'tool.input.elementCount': + inputData.excalidrawData.elements.length, + 'tool.input.saveToFile': inputData.saveToFile, + }, + }) + + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: '🎨 Converting Excalidraw to SVG', + stage: 'excalidraw-to-svg', + }, + id: 'excalidraw-to-svg', + }) - try { - // Validate Excalidraw data - const validation = ExcalidrawSchema.safeParse(inputData.excalidrawData) - if (!validation.success) { - throw new Error(`Invalid Excalidraw data: ${validation.error.message}`) - } - - // Convert to SVG - const svgContent = await excalidrawToSvg( - inputData.excalidrawData, - inputData.exportOptions - ) - - // Extract dimensions from SVG - const widthRegex = /width="(\d+)"/ - const heightRegex = /height="(\d+)"/ - const widthMatch = widthRegex.exec(svgContent) - const heightMatch = heightRegex.exec(svgContent) - const dimensions = { - width: widthMatch ? parseInt(widthMatch[1], 10) : 0, - height: heightMatch ? parseInt(heightMatch[1], 10) : 0, - } - - let savedFilePath: string | undefined - - // Save to file if requested - if (inputData.saveToFile === true) { try { - const providedName = inputData.fileName - const fileName = - typeof providedName === 'string' && providedName.trim().length > 0 - ? providedName.endsWith('.svg') - ? providedName - : `${providedName}.svg` - : `excalidraw-${Date.now()}.svg` - - const fullPath = validateDataPath(fileName) - await fs.mkdir(path.dirname(fullPath), { recursive: true }) - await fs.writeFile(fullPath, svgContent, 'utf-8') - savedFilePath = fileName - - await writer?.custom({ type: 'data-tool-progress', data: { status: 'done', message: `💾 Saved SVG to ${fileName}`, stage: 'excalidraw-to-svg' }, id: 'excalidraw-to-svg' }); + // Validate Excalidraw data + const validation = ExcalidrawSchema.safeParse( + inputData.excalidrawData + ) + if (!validation.success) { + throw new Error( + `Invalid Excalidraw data: ${validation.error.message}` + ) + } + + // Convert to SVG + const svgContent = await excalidrawToSvg( + inputData.excalidrawData, + inputData.exportOptions + ) + + // Extract dimensions from SVG + const widthRegex = /width="(\d+)"/ + const heightRegex = /height="(\d+)"/ + const widthMatch = widthRegex.exec(svgContent) + const heightMatch = heightRegex.exec(svgContent) + const dimensions = { + width: widthMatch ? parseInt(widthMatch[1], 10) : 0, + height: heightMatch ? parseInt(heightMatch[1], 10) : 0, + } + + let savedFilePath: string | undefined + + // Save to file if requested + if (inputData.saveToFile === true) { + try { + const providedName = inputData.fileName + const fileName = + typeof providedName === 'string' && + providedName.trim().length > 0 + ? providedName.endsWith('.svg') + ? providedName + : `${providedName}.svg` + : `excalidraw-${Date.now()}.svg` + + const fullPath = validateDataPath(fileName) + await fs.mkdir(path.dirname(fullPath), { recursive: true }) + await fs.writeFile(fullPath, svgContent, 'utf-8') + savedFilePath = fileName + + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `💾 Saved SVG to ${fileName}`, + stage: 'excalidraw-to-svg', + }, + id: 'excalidraw-to-svg', + }) + } catch (error) { + const errorMsg = + error instanceof Error ? error.message : String(error) + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `⚠️ Failed to save file: ${errorMsg}`, + stage: 'excalidraw-to-svg', + }, + id: 'excalidraw-to-svg', + }) + } + } + + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `✅ Converted ${inputData.excalidrawData.elements.length} elements to SVG`, + stage: 'excalidraw-to-svg', + }, + id: 'excalidraw-to-svg', + }) + + const result = { + svgContent, + savedFilePath, + dimensions, + elementCount: inputData.excalidrawData.elements.length, + } + + span?.update({ + output: result, + metadata: { + 'tool.output.elementCount': result.elementCount, + 'tool.output.width': dimensions.width, + 'tool.output.height': dimensions.height, + }, + }) + span?.end() + return result } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) - await writer?.custom({ type: 'data-tool-progress', data: { status: 'done', message: `⚠️ Failed to save file: ${errorMsg}`, stage: 'excalidraw-to-svg' }, id: 'excalidraw-to-svg' }); - } - } - - await writer?.custom({ type: 'data-tool-progress', data: { status: 'done', message: `✅ Converted ${inputData.excalidrawData.elements.length} elements to SVG`, stage: 'excalidraw-to-svg' }, id: 'excalidraw-to-svg' }); - - const result = { - svgContent, - savedFilePath, - dimensions, - elementCount: inputData.excalidrawData.elements.length, - }; - - span?.update({ - output: result, - metadata: { - 'tool.output.elementCount': result.elementCount, - 'tool.output.width': dimensions.width, - 'tool.output.height': dimensions.height, + const errorMessage = + error instanceof Error ? error.message : String(error) + span?.error({ + error: error instanceof Error ? error : new Error(errorMessage), + endSpan: true, + }) + throw new Error( + `Excalidraw to SVG conversion failed: ${errorMessage}` + ) } - }); - span?.end(); - return result; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - span?.error({ - error: error instanceof Error ? error : new Error(errorMessage), - endSpan: true, - }); - throw new Error(`Excalidraw to SVG conversion failed: ${errorMessage}`) - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Excalidraw to SVG tool input streaming started', { - toolCallId, - hook: 'onInputStart', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Excalidraw to SVG received complete input', { - toolCallId, - elementCount: input.excalidrawData.elements.length, - saveToFile: input.saveToFile, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('Excalidraw to SVG completed', { - toolCallId, - toolName, - elementCount: output.elementCount, - width: output.dimensions.width, - height: output.dimensions.height, - hook: 'onOutput', - }) - }, + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Excalidraw to SVG tool input streaming started', { + toolCallId, + hook: 'onInputStart', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Excalidraw to SVG received complete input', { + toolCallId, + elementCount: input.excalidrawData.elements.length, + saveToFile: input.saveToFile, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('Excalidraw to SVG completed', { + toolCallId, + toolName, + elementCount: output.elementCount, + width: output.dimensions.width, + height: output.dimensions.height, + hook: 'onOutput', + }) + }, }) /** * Converts SVG back to Excalidraw format (simplified conversion) */ export const svgToExcalidrawTool = createTool({ - id: 'svg-to-excalidraw', - description: 'Converts SVG content to Excalidraw diagram format (basic shapes only)', - inputSchema: z.object({ - svgContent: z.string().describe('SVG content to convert'), - title: z.string().optional().describe('Optional title for the diagram'), - }), - outputSchema: z.object({ - excalidrawData: ExcalidrawSchema, - elementCount: z.number(), - warnings: z.array(z.string()).optional(), - }), - - execute: async (inputData, context) => { - const writer = context?.writer; - - const tracingContext = context?.tracingContext; - const span = tracingContext?.currentSpan?.createChildSpan({ - type: SpanType.TOOL_CALL, - name: 'svg-to-excalidraw', - input: inputData, - metadata: { - 'tool.id': 'svg-to-excalidraw', - } - }); - - await writer?.custom({ type: 'data-tool-progress', data: { status: 'in-progress', message: '🔄 Converting SVG to Excalidraw', stage: 'svg-to-excalidraw' }, id: 'svg-to-excalidraw' }); + id: 'svg-to-excalidraw', + description: + 'Converts SVG content to Excalidraw diagram format (basic shapes only)', + inputSchema: z.object({ + svgContent: z.string().describe('SVG content to convert'), + title: z.string().optional().describe('Optional title for the diagram'), + }), + outputSchema: z.object({ + excalidrawData: ExcalidrawSchema, + elementCount: z.number(), + warnings: z.array(z.string()).optional(), + }), - try { - // Parse SVG using svgjson - const svgJson = svgjson.svg2json(inputData.svgContent) as SvgJsonNode - const elements: ExcalidrawElementType[] = [] - const warnings: string[] = [] - const generateId = (): string => Math.random().toString(36).substring(2, 15) - - // Add title if provided - if (typeof inputData.title === 'string' && inputData.title.trim().length > 0) { - elements.push({ - id: generateId(), - type: 'text', - x: 50, - y: 40, - width: inputData.title.length * 10, - height: 30, - text: inputData.title, - fontSize: 20, - fontFamily: 1, - textAlign: 'left', - verticalAlign: 'top', - strokeColor: '#000000', - backgroundColor: 'transparent', - fillStyle: 'solid', - strokeWidth: 2, - strokeStyle: 'solid', - roughness: 1, - opacity: 100, - angle: 0, - seed: Math.floor(Math.random() * 1000000), - version: 1, - versionNonce: Math.floor(Math.random() * 1000000), - isDeleted: false, - boundElements: null, - updated: Date.now(), - link: null, - locked: false, - } as ExcalidrawElementType) - } - - // Convert SVG elements to Excalidraw (simplified) - function convertSvgElement(node: SvgJsonNode, parentX = 0, parentY = 0) { - if (typeof node.name !== 'string' || node.name.length === 0) { - return - } + execute: async (inputData, context) => { + const writer = context?.writer + + const tracingContext: TracingContext | undefined = context?.tracingContext + const span = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'svg-to-excalidraw', + input: inputData, + metadata: { + 'tool.id': 'svg-to-excalidraw', + }, + }) + + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: '🔄 Converting SVG to Excalidraw', + stage: 'svg-to-excalidraw', + }, + id: 'svg-to-excalidraw', + }) + + try { + // Parse SVG using svgjson + const svgJson = svgjson.svg2json( + inputData.svgContent + ) as SvgJsonNode + const elements: ExcalidrawElementType[] = [] + const warnings: string[] = [] + const generateId = (): string => + Math.random().toString(36).substring(2, 15) + + // Add title if provided + if ( + typeof inputData.title === 'string' && + inputData.title.trim().length > 0 + ) { + elements.push({ + id: generateId(), + type: 'text', + x: 50, + y: 40, + width: inputData.title.length * 10, + height: 30, + text: inputData.title, + fontSize: 20, + fontFamily: 1, + textAlign: 'left', + verticalAlign: 'top', + strokeColor: '#000000', + backgroundColor: 'transparent', + fillStyle: 'solid', + strokeWidth: 2, + strokeStyle: 'solid', + roughness: 1, + opacity: 100, + angle: 0, + seed: Math.floor(Math.random() * 1000000), + version: 1, + versionNonce: Math.floor(Math.random() * 1000000), + isDeleted: false, + boundElements: null, + updated: Date.now(), + link: null, + locked: false, + } as ExcalidrawElementType) + } + + // Convert SVG elements to Excalidraw (simplified) + function convertSvgElement( + node: SvgJsonNode, + parentX = 0, + parentY = 0 + ) { + if (typeof node.name !== 'string' || node.name.length === 0) { + return + } + + const attrs = node.attributes ?? {} + const x = parseFloat(attrs.x || '0') + parentX + const y = parseFloat(attrs.y || '0') + parentY + + switch (node.name) { + case 'rect': + elements.push({ + id: generateId(), + type: 'rectangle', + x, + y, + width: parseFloat(attrs.width || '100'), + height: parseFloat(attrs.height || '50'), + strokeColor: attrs.stroke || '#000000', + backgroundColor: attrs.fill || 'transparent', + fillStyle: 'solid', + strokeWidth: parseFloat( + attrs['stroke-width'] || '2' + ), + strokeStyle: 'solid', + roughness: 1, + opacity: parseFloat(attrs.opacity || '1') * 100, + angle: 0, + seed: Math.floor(Math.random() * 1000000), + version: 1, + versionNonce: Math.floor(Math.random() * 1000000), + isDeleted: false, + boundElements: null, + updated: Date.now(), + link: null, + locked: false, + } as ExcalidrawElementType) + break + + case 'circle': + case 'ellipse': { + const cx = parseFloat(attrs.cx ?? '0') + const cy = parseFloat(attrs.cy ?? '0') + const r = parseFloat(attrs.r ?? attrs.rx ?? '25') + elements.push({ + id: generateId(), + type: 'ellipse', + x: cx - r + parentX, + y: cy - r + parentY, + width: r * 2, + height: r * 2, + strokeColor: attrs.stroke || '#000000', + backgroundColor: attrs.fill || 'transparent', + fillStyle: 'solid', + strokeWidth: parseFloat( + attrs['stroke-width'] || '2' + ), + strokeStyle: 'solid', + roughness: 1, + opacity: parseFloat(attrs.opacity || '1') * 100, + angle: 0, + seed: Math.floor(Math.random() * 1000000), + version: 1, + versionNonce: Math.floor(Math.random() * 1000000), + isDeleted: false, + boundElements: null, + updated: Date.now(), + link: null, + locked: false, + } as ExcalidrawElementType) + break + } + + case 'text': { + const textContent = node.text ?? node.content ?? '' + if ( + typeof textContent === 'string' && + textContent.length > 0 + ) { + elements.push({ + id: generateId(), + type: 'text', + x, + y, + width: textContent.length * 8, + height: 20, + text: textContent, + fontSize: parseFloat( + attrs['font-size'] ?? '16' + ), + fontFamily: 1, + textAlign: 'left', + verticalAlign: 'top', + strokeColor: attrs.fill ?? '#000000', + backgroundColor: 'transparent', + fillStyle: 'solid', + strokeWidth: 1, + strokeStyle: 'solid', + roughness: 1, + opacity: parseFloat(attrs.opacity ?? '1') * 100, + angle: 0, + seed: Math.floor(Math.random() * 1000000), + version: 1, + versionNonce: Math.floor( + Math.random() * 1000000 + ), + isDeleted: false, + boundElements: null, + updated: Date.now(), + link: null, + locked: false, + } as ExcalidrawElementType) + } + break + } + + case 'path': + case 'line': + case 'polyline': + case 'polygon': + warnings.push( + `Complex shape '${node.name}' converted to basic rectangle approximation` + ) + elements.push({ + id: generateId(), + type: 'rectangle', + x, + y, + width: 100, + height: 50, + strokeColor: attrs.stroke || '#000000', + backgroundColor: 'transparent', + fillStyle: 'solid', + strokeWidth: parseFloat( + attrs['stroke-width'] || '2' + ), + strokeStyle: 'solid', + roughness: 1, + opacity: parseFloat(attrs.opacity || '1') * 100, + angle: 0, + seed: Math.floor(Math.random() * 1000000), + version: 1, + versionNonce: Math.floor(Math.random() * 1000000), + isDeleted: false, + boundElements: null, + updated: Date.now(), + link: null, + locked: false, + } as ExcalidrawElementType) + break + } - const attrs = node.attributes ?? {} - const x = parseFloat(attrs.x || '0') + parentX - const y = parseFloat(attrs.y || '0') + parentY - - switch (node.name) { - case 'rect': - elements.push({ - id: generateId(), - type: 'rectangle', - x, - y, - width: parseFloat(attrs.width || '100'), - height: parseFloat(attrs.height || '50'), - strokeColor: attrs.stroke || '#000000', - backgroundColor: attrs.fill || 'transparent', - fillStyle: 'solid', - strokeWidth: parseFloat(attrs['stroke-width'] || '2'), - strokeStyle: 'solid', - roughness: 1, - opacity: parseFloat(attrs.opacity || '1') * 100, - angle: 0, - seed: Math.floor(Math.random() * 1000000), - version: 1, - versionNonce: Math.floor(Math.random() * 1000000), - isDeleted: false, - boundElements: null, - updated: Date.now(), - link: null, - locked: false, - } as ExcalidrawElementType) - break - - case 'circle': - case 'ellipse': { - const cx = parseFloat(attrs.cx ?? '0') - const cy = parseFloat(attrs.cy ?? '0') - const r = parseFloat(attrs.r ?? attrs.rx ?? '25') - elements.push({ - id: generateId(), - type: 'ellipse', - x: cx - r + parentX, - y: cy - r + parentY, - width: r * 2, - height: r * 2, - strokeColor: attrs.stroke || '#000000', - backgroundColor: attrs.fill || 'transparent', - fillStyle: 'solid', - strokeWidth: parseFloat(attrs['stroke-width'] || '2'), - strokeStyle: 'solid', - roughness: 1, - opacity: parseFloat(attrs.opacity || '1') * 100, - angle: 0, - seed: Math.floor(Math.random() * 1000000), - version: 1, - versionNonce: Math.floor(Math.random() * 1000000), - isDeleted: false, - boundElements: null, - updated: Date.now(), - link: null, - locked: false, - } as ExcalidrawElementType) - break - } - - case 'text': { - const textContent = node.text ?? node.content ?? '' - if (typeof textContent === 'string' && textContent.length > 0) { - elements.push({ - id: generateId(), - type: 'text', - x, - y, - width: textContent.length * 8, - height: 20, - text: textContent, - fontSize: parseFloat(attrs['font-size'] ?? '16'), - fontFamily: 1, - textAlign: 'left', - verticalAlign: 'top', - strokeColor: attrs.fill ?? '#000000', - backgroundColor: 'transparent', - fillStyle: 'solid', - strokeWidth: 1, - strokeStyle: 'solid', - roughness: 1, - opacity: parseFloat(attrs.opacity ?? '1') * 100, - angle: 0, - seed: Math.floor(Math.random() * 1000000), - version: 1, - versionNonce: Math.floor(Math.random() * 1000000), - isDeleted: false, - boundElements: null, - updated: Date.now(), - link: null, - locked: false, - } as ExcalidrawElementType) + // Process children + if (node.children) { + node.children.forEach((child) => + convertSvgElement(child, x, y) + ) + } + } + + // Process SVG root and children + if (svgJson.children) { + svgJson.children.forEach((child) => convertSvgElement(child)) + } + + const excalidrawData = { + type: 'excalidraw' as const, + version: 2, + source: 'https://excalidraw.com', + elements, + appState: { + gridSize: 20, + viewBackgroundColor: '#ffffff', + }, + files: {}, } - break - } - - case 'path': - case 'line': - case 'polyline': - case 'polygon': - warnings.push(`Complex shape '${node.name}' converted to basic rectangle approximation`) - elements.push({ - id: generateId(), - type: 'rectangle', - x, - y, - width: 100, - height: 50, - strokeColor: attrs.stroke || '#000000', - backgroundColor: 'transparent', - fillStyle: 'solid', - strokeWidth: parseFloat(attrs['stroke-width'] || '2'), - strokeStyle: 'solid', - roughness: 1, - opacity: parseFloat(attrs.opacity || '1') * 100, - angle: 0, - seed: Math.floor(Math.random() * 1000000), - version: 1, - versionNonce: Math.floor(Math.random() * 1000000), - isDeleted: false, - boundElements: null, - updated: Date.now(), - link: null, - locked: false, - } as ExcalidrawElementType) - break - } - // Process children - if (node.children) { - node.children.forEach(child => convertSvgElement(child, x, y)) + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `✅ Converted to ${elements.length} Excalidraw elements`, + stage: 'svg-to-excalidraw', + }, + id: 'svg-to-excalidraw', + }) + + const result = { + excalidrawData, + elementCount: elements.length, + warnings: warnings.length > 0 ? warnings : undefined, + } + + span?.update({ + output: result, + metadata: { 'tool.output.elementCount': elements.length }, + }) + span?.end() + return result + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error) + span?.error({ + error: error instanceof Error ? error : new Error(errorMessage), + endSpan: true, + }) + throw new Error( + `SVG to Excalidraw conversion failed: ${errorMessage}` + ) } - } - - // Process SVG root and children - if (svgJson.children) { - svgJson.children.forEach(child => convertSvgElement(child)) - } - - const excalidrawData = { - type: 'excalidraw' as const, - version: 2, - source: 'https://excalidraw.com', - elements, - appState: { - gridSize: 20, - viewBackgroundColor: '#ffffff', - }, - files: {}, - } - - await writer?.custom({ type: 'data-tool-progress', data: { status: 'done', message: `✅ Converted to ${elements.length} Excalidraw elements`, stage: 'svg-to-excalidraw' }, id: 'svg-to-excalidraw' }); - - const result = { - excalidrawData, - elementCount: elements.length, - warnings: warnings.length > 0 ? warnings : undefined, - }; - - span?.update({ - output: result, - metadata: { 'tool.output.elementCount': elements.length } - }); - span?.end(); - return result; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - span?.error({ - error: error instanceof Error ? error : new Error(errorMessage), - endSpan: true, - }); - throw new Error(`SVG to Excalidraw conversion failed: ${errorMessage}`) - } - }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('SVG to Excalidraw tool input streaming started', { - toolCallId, - hook: 'onInputStart', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('SVG to Excalidraw received complete input', { - toolCallId, - title: input.title, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { - log.info('SVG to Excalidraw completed', { - toolCallId, - toolName, - elementCount: output.elementCount, - warningsCount: output.warnings?.length || 0, - hook: 'onOutput', - }) - }, + }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('SVG to Excalidraw tool input streaming started', { + toolCallId, + hook: 'onInputStart', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('SVG to Excalidraw received complete input', { + toolCallId, + title: input.title, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('SVG to Excalidraw completed', { + toolCallId, + toolName, + elementCount: output.elementCount, + warningsCount: output.warnings?.length || 0, + hook: 'onOutput', + }) + }, }) -export type ReadCSVDataToolUITool = InferUITool<typeof readCSVDataTool>; -export type ImageToCSVUITool = InferUITool<typeof imageToCSVTool>; -export type DataProcessingUITool = InferUITool<typeof convertDataFormatTool>; -export type ExcalidrawValidationUITool = InferUITool<typeof validateExcalidrawTool>; -export type SVGProcessingUITool = InferUITool<typeof processSVGTool>; -export type XMLProcessingUITool = InferUITool<typeof processXMLTool>; -export type DataValidationUITool = InferUITool<typeof validateDataTool>; -export type ExcalidrawToSVGUITool = InferUITool<typeof excalidrawToSVGTool>; -export type SVGToExcalidrawUITool = InferUITool<typeof svgToExcalidrawTool>; +export type ReadCSVDataToolUITool = InferUITool<typeof readCSVDataTool> +export type CsvToExcalidrawUITool = InferUITool<typeof csvToExcalidrawTool> +export type DataValidationUITool = InferUITool<typeof validateDataTool> +export type ExcalidrawToSVGUITool = InferUITool<typeof excalidrawToSVGTool> +export type SVGToExcalidrawUITool = InferUITool<typeof svgToExcalidrawTool> + diff --git a/src/mastra/tools/data-validator.tool.ts b/src/mastra/tools/data-validator.tool.ts index 474d7c3b..9c256e2d 100644 --- a/src/mastra/tools/data-validator.tool.ts +++ b/src/mastra/tools/data-validator.tool.ts @@ -1,4 +1,6 @@ import { SpanType } from '@mastra/core/observability' +import type { TracingContext } from '@mastra/core/observability' + import type { RequestContext } from '@mastra/core/request-context' import type { InferUITool } from '@mastra/core/tools' import { createTool } from '@mastra/core/tools' @@ -172,8 +174,10 @@ export const dataValidatorToolJSON = createTool({ }, execute: async (inputData, context) => { const writer = context?.writer - const requestContext = context?.requestContext as DataValidatorContext | undefined - const tracingContext = context?.tracingContext + const requestContext = context?.requestContext as + | DataValidatorContext + | undefined + const tracingContext: TracingContext | undefined = context?.tracingContext await writer?.custom({ type: 'data-tool-progress', @@ -189,6 +193,7 @@ export const dataValidatorToolJSON = createTool({ type: SpanType.TOOL_CALL, name: 'data-validator', input: inputData, + requestContext: context?.requestContext, metadata: { 'tool.id': 'data-validator-json', 'user.id': requestContext?.userId, @@ -213,7 +218,7 @@ export const dataValidatorToolJSON = createTool({ }) rootSpan?.update({ output: { valid: true }, - metadata: { 'tool.output.valid': true } + metadata: { 'tool.output.valid': true }, }) rootSpan?.end() return { diff --git a/src/mastra/tools/datetime.tool.ts b/src/mastra/tools/datetime.tool.ts index 22c83374..39c9d593 100644 --- a/src/mastra/tools/datetime.tool.ts +++ b/src/mastra/tools/datetime.tool.ts @@ -1,6 +1,7 @@ import type { InferUITool } from '@mastra/core/tools' import { createTool } from '@mastra/core/tools' import { SpanType } from '@mastra/core/observability' +import type { TracingContext } from '@mastra/core/observability' import { z } from 'zod' import { log } from '../config/logger' @@ -87,11 +88,38 @@ export const dateTimeTool = createTool({ input: z.string().optional(), message: z.string().optional(), }), - +onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('DateTime tool input streaming started', { + toolCallId, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputStart', + }) + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('DateTime tool received input chunk', { + toolCallId, + inputTextDelta, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('DateTime tool received input', { + toolCallId, + messageCount: messages.length, + inputData: { operation: input.operation }, + abortSignal: abortSignal?.aborted, + hook: 'onInputAvailable', + }) + }, execute: async (inputData, context) => { const writer = context?.writer - const requestCtx = context?.requestContext as DateTimeToolContext | undefined - const tracingContext = context?.tracingContext + const requestCtx = context?.requestContext as + | DateTimeToolContext + | undefined + const tracingContext: TracingContext | undefined = context?.tracingContext const defaultTimezone = requestCtx?.defaultTimezone ?? 'UTC' const defaultLocale = requestCtx?.defaultLocale ?? 'en-US' const allowFutureDates = requestCtx?.allowFutureDates ?? true @@ -268,7 +296,11 @@ export const dateTimeTool = createTool({ }) span?.update({ - output: { success: true, operation: inputData.operation, result }, + output: { + success: true, + operation: inputData.operation, + result, + }, metadata: { 'tool.output.success': true, 'tool.output.operation': inputData.operation, @@ -302,38 +334,17 @@ export const dateTimeTool = createTool({ } } }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('DateTime tool input streaming started', { - toolCallId, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputStart', - }) - }, - onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { - log.info('DateTime tool received input chunk', { - toolCallId, - inputTextDelta, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputDelta', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('DateTime tool received input', { - toolCallId, - messageCount: messages.length, - inputData: { operation: input.operation }, - abortSignal: abortSignal?.aborted, - hook: 'onInputAvailable', - }) - }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { log.info('DateTime tool completed', { toolCallId, toolName, + abortSignal: abortSignal?.aborted, - outputData: { success: output.success, operation: output.operation }, + outputData: { + success: output.success, + operation: output.operation, + }, hook: 'onOutput', }) }, @@ -368,9 +379,35 @@ export const timeZoneTool = createTool({ operation: z.string(), message: z.string().optional(), }), + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Timezone tool input streaming started', { + toolCallId, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputStart', + }) + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('Timezone tool received input chunk', { + toolCallId, + inputTextDelta, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Timezone tool received input', { + toolCallId, + messageCount: messages.length, + inputData: { operation: input.operation, timezone: input.timezone }, + abortSignal: abortSignal?.aborted, + hook: 'onInputAvailable', + }) + }, execute: async (inputData, context) => { const writer = context?.writer - const tracingContext = context?.tracingContext + const tracingContext: TracingContext | undefined = context?.tracingContext const span = tracingContext?.currentSpan?.createChildSpan({ type: SpanType.TOOL_CALL, @@ -393,7 +430,16 @@ export const timeZoneTool = createTool({ }) try { - let result: any + let result: + | string + | string[] + | { + name: string + offset: string + abbreviation: string + dst: boolean + } + | null switch (inputData.operation) { case 'convert': { @@ -451,7 +497,11 @@ export const timeZoneTool = createTool({ }) span?.update({ - output: { success: true, operation: inputData.operation, result }, + output: { + success: true, + operation: inputData.operation, + result, + }, metadata: { 'tool.output.success': true, 'tool.output.operation': inputData.operation, @@ -483,38 +533,17 @@ export const timeZoneTool = createTool({ } } }, - onInputStart: ({ toolCallId, messages, abortSignal }) => { - log.info('Timezone tool input streaming started', { - toolCallId, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputStart', - }) - }, - onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { - log.info('Timezone tool received input chunk', { - toolCallId, - inputTextDelta, - messageCount: messages.length, - abortSignal: abortSignal?.aborted, - hook: 'onInputDelta', - }) - }, - onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { - log.info('Timezone tool received input', { - toolCallId, - messageCount: messages.length, - inputData: { operation: input.operation, timezone: input.timezone }, - abortSignal: abortSignal?.aborted, - hook: 'onInputAvailable', - }) - }, - onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { log.info('Timezone tool completed', { toolCallId, toolName, + abortSignal: abortSignal?.aborted, - outputData: { success: output.success, operation: output.operation }, + outputData: { + success: output.success, + operation: output.operation, + }, hook: 'onOutput', }) }, diff --git a/src/mastra/tools/downsample.tool.ts b/src/mastra/tools/downsample.tool.ts index 310ed068..16567990 100644 --- a/src/mastra/tools/downsample.tool.ts +++ b/src/mastra/tools/downsample.tool.ts @@ -215,6 +215,44 @@ export const downsampleTool = createTool({ return fallbackResult } }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Downsample tool input streaming started', { + toolCallId, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputStart', + }) + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('Downsample tool received input chunk', { + toolCallId, + inputTextDelta, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Downsample tool received input', { + toolCallId, + messageCount: messages.length, + inputData: { + valuesCount: input.values.length, + target: input.target, + }, + abortSignal: abortSignal?.aborted, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('Downsample tool completed', { + toolCallId, + toolName, + outputData: { sampledCount: output.values.length }, + abortSignal: abortSignal?.aborted, + hook: 'onOutput', + }) + }, }) export type DownsampleUITool = typeof downsampleTool diff --git a/src/mastra/tools/e2b.ts b/src/mastra/tools/e2b.ts index 83d20518..6f4d095f 100644 --- a/src/mastra/tools/e2b.ts +++ b/src/mastra/tools/e2b.ts @@ -107,7 +107,7 @@ export const createSandbox = createTool({ sandboxId: z.string(), }), execute: async (inputData, context) => { - const tracingContext = context?.tracingContext + const tracingContext: TracingContext | undefined = context?.tracingContext const span = tracingContext?.currentSpan?.createChildSpan({ type: SpanType.TOOL_CALL, name: 'createSandbox', @@ -227,7 +227,7 @@ export const writeFile = createTool({ path: z.string().describe('The path where the file was written'), }), execute: async (inputData, context) => { - const tracingContext = context?.tracingContext + const tracingContext: TracingContext | undefined = context?.tracingContext const span = tracingContext?.currentSpan?.createChildSpan({ type: SpanType.TOOL_CALL, name: 'writeFile', @@ -337,7 +337,7 @@ export const writeFiles = createTool({ .describe('Array of file paths that were written'), }), execute: async (inputData, context) => { - const tracingContext = context?.tracingContext + const tracingContext: TracingContext | undefined = context?.tracingContext const span = tracingContext?.currentSpan?.createChildSpan({ type: SpanType.TOOL_CALL, name: 'writeFiles', @@ -456,7 +456,7 @@ export const listFiles = createTool({ path: z.string().describe('The path that was listed'), }), execute: async (inputData, context) => { - const tracingContext = context?.tracingContext + const tracingContext: TracingContext | undefined = context?.tracingContext const span = tracingContext?.currentSpan?.createChildSpan({ type: SpanType.TOOL_CALL, name: 'listFiles', @@ -559,7 +559,7 @@ export const deleteFile = createTool({ path: z.string().describe('The path that was deleted'), }), execute: async (inputData, context) => { - const tracingContext = context?.tracingContext + const tracingContext: TracingContext | undefined = context?.tracingContext const span = tracingContext?.currentSpan?.createChildSpan({ type: SpanType.TOOL_CALL, name: 'deleteFile', diff --git a/src/mastra/tools/resilient-fetch.tool.ts b/src/mastra/tools/resilient-fetch.tool.ts index c0fa3243..48fd2781 100644 --- a/src/mastra/tools/resilient-fetch.tool.ts +++ b/src/mastra/tools/resilient-fetch.tool.ts @@ -1,5 +1,6 @@ import { createTool } from '@mastra/core/tools' import { SpanType } from '@mastra/core/observability' +import type { TracingContext } from '@mastra/core/observability' import { z } from 'zod' import { log } from '../config/logger' import axios from 'axios' @@ -48,6 +49,21 @@ export const resilientFetchTool = createTool({ }), execute: async (input, context) => { const { url, method, data, headers, priority } = input + const tracingContext: TracingContext | undefined = + context?.tracingContext + + // Create child span for resilient fetch + const fetchSpan = tracingContext?.currentSpan?.createChildSpan({ + type: SpanType.TOOL_CALL, + name: 'resilient-fetch', + input, + requestContext: context?.requestContext, + metadata: { + 'tool.id': 'resilient-fetch', + 'tool.input.url': url, + 'tool.input.method': method, + }, + }) await context?.writer?.custom({ type: 'data-tool-progress', @@ -70,6 +86,16 @@ export const resilientFetchTool = createTool({ }) ) + // Update span with success + fetchSpan?.update({ + output: { status: response.status }, + metadata: { + 'tool.output.status': response.status, + 'tool.output.success': true, + }, + }) + fetchSpan?.end() + await context?.writer?.custom({ type: 'data-tool-progress', data: { @@ -96,6 +122,12 @@ export const resilientFetchTool = createTool({ error: errorMessage, }) + // Record error in span + fetchSpan?.error({ + error: error instanceof Error ? error : new Error(errorMessage), + endSpan: true, + }) + await context?.writer?.custom({ type: 'data-tool-progress', data: { diff --git a/src/mastra/tools/spatial-index.tool.ts b/src/mastra/tools/spatial-index.tool.ts index ae43e58b..be5a1eeb 100644 --- a/src/mastra/tools/spatial-index.tool.ts +++ b/src/mastra/tools/spatial-index.tool.ts @@ -181,6 +181,41 @@ export const spatialIndexTool = createTool({ throw err } }, + onInputStart: ({ toolCallId, messages, abortSignal }) => { + log.info('Spatial Index tool input streaming started', { + toolCallId, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputStart', + }) + }, + onInputDelta: ({ inputTextDelta, toolCallId, messages, abortSignal }) => { + log.info('Spatial Index tool received input chunk', { + toolCallId, + inputTextDelta, + messageCount: messages.length, + abortSignal: abortSignal?.aborted, + hook: 'onInputDelta', + }) + }, + onInputAvailable: ({ input, toolCallId, messages, abortSignal }) => { + log.info('Spatial Index tool received input', { + toolCallId, + messageCount: messages.length, + inputData: { action: input.action }, + abortSignal: abortSignal?.aborted, + hook: 'onInputAvailable', + }) + }, + onOutput: ({ output, toolCallId, toolName, abortSignal }) => { + log.info('Spatial Index tool completed', { + toolCallId, + toolName, + outputData: { success: !!output.treeJSON || !!output.results }, + abortSignal: abortSignal?.aborted, + hook: 'onOutput', + }) + }, }) export type SpatialIndexUITool = typeof spatialIndexTool diff --git a/src/mastra/tools/write-note.ts b/src/mastra/tools/write-note.ts index c28aa2ec..b78c7012 100644 --- a/src/mastra/tools/write-note.ts +++ b/src/mastra/tools/write-note.ts @@ -2,11 +2,12 @@ import type { RequestContext } from '@mastra/core/request-context' import type { InferUITool } from '@mastra/core/tools' import { createTool } from '@mastra/core/tools' import { SpanType } from '@mastra/core/observability' +import type { TracingContext } from '@mastra/core/observability' import * as fs from 'node:fs/promises' import * as path from 'node:path' import { z } from 'zod' import { log } from '../config/logger' -import { BaseSpan } from '@mastra/observability'; + export interface WriteNoteContext extends RequestContext { userId?: string @@ -32,7 +33,7 @@ export const writeNoteTool = createTool({ execute: async (inputData, context) => { const writer = context?.writer const abortSignal = context?.abortSignal - const tracingContext = context?.tracingContext + const tracingContext: TracingContext | undefined = context?.tracingContext const requestCtx = context?.requestContext as WriteNoteContext | undefined // Check if operation was already cancelled diff --git a/src/mastra/workflows/AGENTS.md b/src/mastra/workflows/AGENTS.md index 1ed6684f..abc27973 100644 --- a/src/mastra/workflows/AGENTS.md +++ b/src/mastra/workflows/AGENTS.md @@ -1,4 +1,4 @@ -<!-- AGENTS-META {"title":"Mastra Workflows","version":"2.0.0","applies_to":"/src/mastra/workflows","last_updated":"2025-11-26T00:00:00Z","status":"stable"} --> +<!-- AGENTS-META {"title":"Mastra Workflows","version":"2.1.0","applies_to":"/src/mastra/workflows","last_updated":"2026-01-13T00:00:00Z","status":"stable"} --> # Workflows (`/src/mastra/workflows`) @@ -10,7 +10,7 @@ Workflow Engineer — objective: Orchestrate tools and agents into reliable, tes Workflows orchestrate agents and tools into multi-step scenarios (e.g., data ingestion → indexing → RAG retrieval → evaluation) and implement long-running or stateful operations where necessary. -## Key Files (10 Workflows) +## Key Files (11 Workflows) | File | Export | Purpose | Features | | --------------------------------- | --------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------- | @@ -24,6 +24,7 @@ Workflows orchestrate agents and tools into multi-step scenarios (e.g., data ing | `stock-analysis-workflow.ts` | `stockAnalysisWorkflow` | Sequential stock analysis with data enrichment at each step | Sequential with API integrations | | `changelog.ts` | `changelogWorkflow` | Generate changelogs from git diffs using AI | Git integration, Slack notification | | `telephone-game.ts` | `telephoneGameWorkflow` | Interactive telephone game demonstrating user input workflows | User prompts, sequential steps | +| `test-generation-workflow.ts` | `testGenerationWorkflow` | Automated unit test generation and verification using E2B sandboxes | Agent generation, sandbox execution | ## Workflow Patterns Demonstrated @@ -407,6 +408,7 @@ branchingWorkflow.commit(); | Version | Date (UTC) | Changes | | ------- | ---------- | ---------------------------------------------------------------------------- | +| 2.1.0 | 2026-01-13 | Added observability spans to all workflows; documented Test Generation. | | 2.0.0 | 2025-11-26 | Major update: 10 workflows documented with pattern examples and API routes. | | 1.1.0 | 2025-11-19 | Added Content Studio workflow. | | 1.0.0 | 2025-11-14 | Initial version. | diff --git a/src/mastra/workflows/automated-reporting-workflow.ts b/src/mastra/workflows/automated-reporting-workflow.ts index d51b21b6..61498317 100644 --- a/src/mastra/workflows/automated-reporting-workflow.ts +++ b/src/mastra/workflows/automated-reporting-workflow.ts @@ -1,5 +1,6 @@ import { createStep, createWorkflow } from '@mastra/core/workflows'; import { z } from 'zod'; +import { SpanType, getOrCreateSpan } from '@mastra/core/observability'; import { reportAgent } from '../agents/reportAgent'; import { researchAgent } from '../agents/researchAgent'; import { logStepEnd, logStepStart, logError } from '../config/logger'; @@ -34,60 +35,90 @@ const researchTopicStep = createStep({ })), depth: z.string(), }), - execute: async ({ inputData, writer }) => { - const startTime = Date.now(); - logStepStart('research-topic', { topic: inputData.topic }); - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `Researching ${inputData.sections.length} sections for topic: ${inputData.topic}...`, - stage: 'research-topic', + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'research-topic', + input: inputData, + metadata: { + 'workflow.step': 'research-topic', + 'report.topic': inputData.topic, }, - id: 'research-topic', + requestContext, + mastra, }); + const startTime = Date.now(); + logStepStart('research-topic', { topic: inputData.topic }); - const researchedSections = []; - - for (const section of inputData.sections) { + try { await writer?.custom({ type: 'data-tool-progress', data: { status: 'in-progress', - message: `Researching section: ${section}...`, + message: `Researching ${inputData.sections.length} sections for topic: ${inputData.topic}...`, stage: 'research-topic', }, id: 'research-topic', }); - const prompt = `Research the following aspect of "${inputData.topic}": ${section}. + const researchedSections = []; + + for (const section of inputData.sections) { + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `Researching section: ${section}...`, + stage: 'research-topic', + }, + id: 'research-topic', + }); + + const prompt = `Research the following aspect of "${inputData.topic}": ${section}. Provide a ${inputData.depth} summary of key facts, trends, and data points.`; - const result = await researchAgent.generate(prompt); - researchedSections.push({ - name: section, - data: result.text, + const result = await researchAgent.generate(prompt); + researchedSections.push({ + name: section, + data: result.text, + }); + } + + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `Research completed for all sections.`, + stage: 'research-topic', + }, + id: 'research-topic', }); - } - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: `Research completed for all sections.`, - stage: 'research-topic', - }, - id: 'research-topic', - }); + logStepEnd('research-topic', {}, Date.now() - startTime); - logStepEnd('research-topic', {}, Date.now() - startTime); + const output = { + topic: inputData.topic, + sections: researchedSections, + depth: inputData.depth, + }; + + span?.update({ + output, + metadata: { + 'sections.count': researchedSections.length, + }, + }); + span?.end(); - return { - topic: inputData.topic, - sections: researchedSections, - depth: inputData.depth, - }; + return output; + } catch (error) { + logError('research-topic', error); + span?.error({ + error: error instanceof Error ? error : new Error(String(error)), + endSpan: true, + }); + throw error; + } }, }); @@ -103,24 +134,36 @@ const synthesizeReportStep = createStep({ depth: z.string(), }), outputSchema: reportOutputSchema, - execute: async ({ inputData, writer }) => { + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'synthesize-report', + input: inputData, + metadata: { + 'workflow.step': 'synthesize-report', + 'report.topic': inputData.topic, + }, + requestContext, + mastra, + }); const startTime = Date.now(); logStepStart('synthesize-report', { topic: inputData.topic }); - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `Synthesizing final report for ${inputData.topic}...`, - stage: 'synthesize-report', - }, - id: 'synthesize-report', - }); + try { + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `Synthesizing final report for ${inputData.topic}...`, + stage: 'synthesize-report', + }, + id: 'synthesize-report', + }); - const reportId = `rep-${Date.now()}`; - const sectionsContent = inputData.sections.map(s => `## ${s.name}\n${s.data}`).join('\n\n'); + const reportId = `rep-${Date.now()}`; + const sectionsContent = inputData.sections.map(s => `## ${s.name}\n${s.data}`).join('\n\n'); - const prompt = `You are a Professional Report Writer. Synthesize the following research data into a high-quality ${inputData.depth} report about "${inputData.topic}". + const prompt = `You are a Professional Report Writer. Synthesize the following research data into a high-quality ${inputData.depth} report about "${inputData.topic}". Research Data: ${sectionsContent} @@ -138,7 +181,6 @@ const synthesizeReportStep = createStep({ - "fullReport": The complete markdown report. `; - try { const result = await reportAgent.generate(prompt); let output; @@ -164,7 +206,7 @@ const synthesizeReportStep = createStep({ logStepEnd('synthesize-report', {}, Date.now() - startTime); - return { + const finalResult = { reportId, title: output.title, executiveSummary: output.executiveSummary, @@ -175,8 +217,19 @@ const synthesizeReportStep = createStep({ sectionsCount: inputData.sections.length, }, }; + + span?.update({ + output: finalResult, + }); + span?.end(); + + return finalResult; } catch (error) { logError('synthesize-report', error); + span?.error({ + error: error instanceof Error ? error : new Error(String(error)), + endSpan: true, + }); throw error; } }, diff --git a/src/mastra/workflows/changelog.ts b/src/mastra/workflows/changelog.ts index be569e95..0de4a008 100644 --- a/src/mastra/workflows/changelog.ts +++ b/src/mastra/workflows/changelog.ts @@ -1,5 +1,6 @@ //import { delay } from '@mastra/core'; import { createStep, createWorkflow } from '@mastra/core/workflows'; +import { SpanType, getOrCreateSpan } from '@mastra/core/observability'; import chalk from 'chalk'; import execa from 'execa'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; @@ -21,143 +22,158 @@ const stepA1 = createStep({ outputSchema: z.object({ message: z.string(), }), - execute: async ({ inputData, mastra, writer }) => { - const startTime = Date.now(); - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `Starting git diff for channel ${inputData.channelId}...`, - stage: 'stepA1', - }, - id: 'stepA1', - }) - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `Fetching git diff for channel ${inputData.channelId}...`, - stage: 'stepA1', + execute: async ({ inputData, mastra, writer, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'stepA1', + input: inputData, + metadata: { + 'workflow.step': 'stepA1', + 'channel.id': inputData.channelId, }, - id: 'stepA1', - }) + requestContext, + mastra, + }); + const startTime = Date.now(); - // For today try { - // @ts-ignore - await slack.connect(); - } catch (e) { - log.error(e instanceof Error ? e.message : String(e)); - } + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `Starting git diff for channel ${inputData.channelId}...`, + stage: 'stepA1', + }, + id: 'stepA1', + }) - const today = new Date().toISOString().split('T')[0]; + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `Fetching git diff for channel ${inputData.channelId}...`, + stage: 'stepA1', + }, + id: 'stepA1', + }) - if (existsSync(`generated-changelogs/changelog-${today}`)) { - log.info(chalk.red(`Changelog for today already exists`)); - return { - message: readFileSync(`generated-changelogs/changelog-${today}`, 'utf-8'), - }; - } + // For today + try { + // @ts-ignore + await slack.connect(); + } catch (e) { + log.error(e instanceof Error ? e.message : String(e)); + } + + const today = new Date().toISOString().split('T')[0]; + + if (existsSync(`generated-changelogs/changelog-${today}`)) { + log.info(chalk.red(`Changelog for today already exists`)); + const output = { + message: readFileSync(`generated-changelogs/changelog-${today}`, 'utf-8'), + }; + span?.update({ output }); + span?.end(); + return output; + } - log.info(today); - - // For 7 days ago - const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; - - log.info(weekAgo); - const cwd = process.cwd(); - - log.info(cwd); - - const modulePaths = [ - 'packages/core', - 'packages/cli', - 'packages/create-mastra', - 'packages/deployer', - 'packages/evals', - 'packages/rag', - 'packages/memory', - 'packages/mcp', - - // Deployers - 'deployers/cloudflare', - 'deployers/netlify', - 'deployers/vercel', - - // Speech modules - 'speech/azure', - 'speech/deepgram', - 'speech/elevenlabs', - 'speech/google', - 'speech/ibm', - 'speech/murf', - 'speech/openai', - 'speech/playai', - 'speech/replicate', - 'speech/speechify', - - // Storage modules - 'stores/pg', - 'stores/astra', - 'stores/chroma', - 'stores/pinecone', - 'stores/qdrant', - 'stores/upstash', - 'stores/vectorize', - ]; - - const moduleChangelogs = []; - - let generatedText = ''; - - let TOKEN_LIMIT = 80000; - - for (const modulePath of modulePaths) { - const args = [ - '--no-pager', - 'diff', - '--unified=1', // Reduced context to 1 line - '--no-prefix', - '--color=never', - `main@{${weekAgo}}`, - `main@{${today}}`, - '--', - modulePath, - ':!**/node_modules/**', - ':!**/.turbo/**', - ':!**/.next/**', - ':!**/coverage/**', - ':!**/package-lock.json', - ':!**/pnpm-lock.yaml', - ':!**/yarn.lock', - ':!**/*.bin', - ':!**/*.exe', - ':!**/*.dll', - ':!**/*.so', - ':!**/*.dylib', - ':!**/*.class', - ':!**/dist/**', + log.info(today); + + // For 7 days ago + const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + + log.info(weekAgo); + const cwd = process.cwd(); + + log.info(cwd); + + const modulePaths = [ + 'packages/core', + 'packages/cli', + 'packages/create-mastra', + 'packages/deployer', + 'packages/evals', + 'packages/rag', + 'packages/memory', + 'packages/mcp', + + // Deployers + 'deployers/cloudflare', + 'deployers/netlify', + 'deployers/vercel', + + // Speech modules + 'speech/azure', + 'speech/deepgram', + 'speech/elevenlabs', + 'speech/google', + 'speech/ibm', + 'speech/murf', + 'speech/openai', + 'speech/playai', + 'speech/replicate', + 'speech/speechify', + + // Storage modules + 'stores/pg', + 'stores/astra', + 'stores/chroma', + 'stores/pinecone', + 'stores/qdrant', + 'stores/upstash', + 'stores/vectorize', ]; - log.info(`git ${args.join(' ')}`); - try { - const diff = await execa('git', args, { - cwd, - }); + const moduleChangelogs = []; + + let generatedText = ''; + + let TOKEN_LIMIT = 80000; + + for (const modulePath of modulePaths) { + const args = [ + '--no-pager', + 'diff', + '--unified=1', // Reduced context to 1 line + '--no-prefix', + '--color=never', + `main@{${weekAgo}}`, + `main@{${today}}`, + '--', + modulePath, + ':!**/node_modules/**', + ':!**/.turbo/**', + ':!**/.next/**', + ':!**/coverage/**', + ':!**/package-lock.json', + ':!**/pnpm-lock.yaml', + ':!**/yarn.lock', + ':!**/*.bin', + ':!**/*.exe', + ':!**/*.dll', + ':!**/*.so', + ':!**/*.dylib', + ':!**/*.class', + ':!**/dist/**', + ]; + log.info(`git ${args.join(' ')}`); + + try { + const diff = await execa('git', args, { + cwd, + }); - const output = diff.stdout.trim(); + const outputText = diff.stdout.trim(); - if (output) { - // Only generate changelog if there are changes - log.info(`${modulePath} changes length: ${output.length}`); + if (outputText) { + // Only generate changelog if there are changes + log.info(`${modulePath} changes length: ${outputText.length}`); - const modulePrompt = ` - Time: ${weekAgo} - ${today} - Module: ${modulePath} + const modulePrompt = ` +Between ${weekAgo} and ${today} +In module: ${modulePath} - Git diff to generate from: ${output} + Git diff to generate from: ${outputText} # Task 1. Create a structured narrative changelog that highlights key updates and improvements for this module. @@ -170,59 +186,77 @@ const stepA1 = createStep({ - Performance optimizations `; - const agent = mastra?.getAgent('daneChangeLog'); + const agent = mastra?.getAgent('daneChangeLog'); - if (agent === undefined) { - throw new Error('LLM not found'); - } + if (agent === undefined) { + throw new Error('LLM not found'); + } - const result = await agent.generate(modulePrompt); + const result = await agent.generate(modulePrompt); - moduleChangelogs.push({ - module: modulePath, - changelog: result.text, - }); + moduleChangelogs.push({ + module: modulePath, + changelog: result.text, + }); - generatedText += `\n ## ${modulePath}\n${result.text}`; - writeFileSync(`generated-changelogs/changelog-${today}`, generatedText); + generatedText += `\n ## ${modulePath}\n${result.text}`; + writeFileSync(`generated-changelogs/changelog-${today}`, generatedText); - if (result.usage?.totalTokens !== undefined) { - log.info(`Total tokens used: ${result.usage.totalTokens}`); - } + if (result.usage?.totalTokens !== undefined) { + log.info(`Total tokens used: ${result.usage.totalTokens}`); + } - TOKEN_LIMIT -= result.usage?.totalTokens ?? 0; + TOKEN_LIMIT -= result.usage?.totalTokens ?? 0; - if (TOKEN_LIMIT < 20000) { - //await delay(60000); - TOKEN_LIMIT = 80000; + if (TOKEN_LIMIT < 20000) { + //await delay(60000); + TOKEN_LIMIT = 80000; + } } + } catch (e: unknown) { + log.error(`Error processing ${modulePath}: ${e instanceof Error ? e.message : String(e)}`); } - } catch (e: unknown) { - log.error(`Error processing ${modulePath}: ${e instanceof Error ? e.message : String(e)}`); } - } - // Combine all changelogs - const combinedChangelog = moduleChangelogs - .map(({ module, changelog }) => `## ${module}\n${changelog}`) - .join('\n\n'); + // Combine all changelogs + const combinedChangelog = moduleChangelogs + .map(({ module, changelog }) => `## ${module}\n${changelog}`) + .join('\n\n'); - writeFileSync(`generated-changelogs/changelog-${today}`, combinedChangelog); + writeFileSync(`generated-changelogs/changelog-${today}`, combinedChangelog); - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: `Git diff completed for channel ${inputData.channelId}`, - stage: 'stepA1', - }, - id: 'stepA1', - }); + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `Git diff completed for channel ${inputData.channelId}`, + stage: 'stepA1', + }, + id: 'stepA1', + }); + const finalOutput = { + message: combinedChangelog, + }; - return { - message: combinedChangelog, - }; + span?.update({ + output: finalOutput, + metadata: { + 'modules.changed': moduleChangelogs.length, + 'changelog.length': combinedChangelog.length, + }, + }); + span?.end(); + + return finalOutput; + } catch (error) { + log.error(`Error in git-diff step: ${error instanceof Error ? error.message : String(error)}`); + span?.error({ + error: error instanceof Error ? error : new Error(String(error)), + endSpan: true, + }); + throw error; + } }, }); @@ -235,44 +269,55 @@ const stepA2 = createStep({ outputSchema: z.object({ message: z.string(), }), - execute: async ({ inputData, mastra, writer }) => { + execute: async ({ inputData, mastra, writer, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'stepA2', + input: { message_length: inputData.message.length }, + metadata: { + 'workflow.step': 'stepA2', + }, + requestContext, + mastra, + }); const startTime = Date.now(); - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `Starting changelog generation (input length: ${inputData.message.length})...`, - stage: 'stepA2', - }, - id: 'stepA2', - }) - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `Generating changelog from git diff (input length: ${inputData.message.length})...`, - stage: 'stepA2', - }, - id: 'stepA2', - }) + try { + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `Starting changelog generation (input length: ${inputData.message.length})...`, + stage: 'stepA2', + }, + id: 'stepA2', + }) + + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `Generating changelog from git diff (input length: ${inputData.message.length})...`, + stage: 'stepA2', + }, + id: 'stepA2', + }) - const agent = mastra?.getAgent('daneChangeLog'); + const agent = mastra?.getAgent('daneChangeLog'); - if (agent === undefined) { - throw new Error('LLM not found'); - } + if (agent === undefined) { + throw new Error('LLM not found'); + } - const today = new Date().toISOString().split('T')[0]; - const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + const today = new Date().toISOString().split('T')[0]; + const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; - const tools = await slack.listTools(); + const tools = await slack.listTools(); - const channelId = 'default-channel'; // TODO: Pass channelId through workflow properly + const channelId = 'default-channel'; // TODO: Pass channelId through workflow properly - const prompt = ` - Time: ${weekAgo} - ${today} + const prompt = ` +Time from ${weekAgo} to ${today} ${inputData.message} # Task @@ -309,9 +354,8 @@ const stepA2 = createStep({ Finally send this to this slack channel: "${channelId}" with the tool slack_post_message `; - log.info(chalk.green(`Generating...`)); + log.info(chalk.green(`Generating...`)); - try { const result = await agent.generate(prompt, { toolsets: { // slack: tools, @@ -330,10 +374,19 @@ const stepA2 = createStep({ id: 'stepA2', }); - - return { + const output = { message: result.text, }; + + span?.update({ + output, + metadata: { + 'final.changelog.length': result.text.length, + }, + }); + span?.end(); + + return output; } catch (e) { log.error(e instanceof Error ? e.message : String(e)); @@ -347,10 +400,16 @@ const stepA2 = createStep({ id: 'stepA2', }); - - return { - message: e as string, + const errorResult = { + message: String(e), }; + + span?.error({ + error: e instanceof Error ? e : new Error(String(e)), + endSpan: true, + }); + + return errorResult; } }, }); diff --git a/src/mastra/workflows/content-review-workflow.ts b/src/mastra/workflows/content-review-workflow.ts index 26a46464..cb47af6d 100644 --- a/src/mastra/workflows/content-review-workflow.ts +++ b/src/mastra/workflows/content-review-workflow.ts @@ -1,5 +1,5 @@ import { createStep, createWorkflow } from '@mastra/core/workflows'; -import { SpanStatusCode, trace } from '@opentelemetry/api'; +import { SpanType, getOrCreateSpan } from '@mastra/core/observability'; import { z } from 'zod'; import { logError, logStepEnd, logStepStart } from '../config/logger'; @@ -76,37 +76,33 @@ const researchTopicStep = createStep({ description: 'Researches the topic using researchAgent', inputSchema: contentInputSchema, outputSchema: researchOutputSchema, - execute: async ({ inputData, mastra, writer }) => { - const startTime = Date.now(); - logStepStart('research-topic', { topic: inputData.topic }); - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `Starting research for topic: ${inputData.topic}`, - stage: 'research-topic', + execute: async ({ inputData, mastra, writer, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'research-topic', + input: inputData, + metadata: { + 'workflow.step': 'research-topic', + 'content.topic': inputData.topic, }, - id: 'research-topic', - }) - - const tracer = trace.getTracer('content-review'); - const span = tracer.startSpan('research-agent-call', { - attributes: { topic: inputData.topic }, + requestContext, + mastra, }); + const startTime = Date.now(); + logStepStart('research-topic', { topic: inputData.topic }); try { await writer?.custom({ type: 'data-tool-progress', data: { status: 'in-progress', - message: `Researching topic: ${inputData.topic}...`, + message: `Starting research for topic: ${inputData.topic}`, stage: 'research-topic', }, id: 'research-topic', }) - const agent = mastra?.getAgent('researchAgent'); + const agent = mastra?.getAgent?.('researchAgent'); const research = { summary: `Research summary for ${inputData.topic}`, keyPoints: [`Key point 1 about ${inputData.topic}`, `Key point 2 about ${inputData.topic}`], @@ -114,7 +110,7 @@ const researchTopicStep = createStep({ facts: [] as string[], }; - if (agent) { + if (agent !== undefined && agent !== null) { await writer?.custom({ type: 'data-tool-progress', data: { @@ -139,7 +135,9 @@ const researchTopicStep = createStep({ } as any); // Stream partial output to the workflow writer so clients see progress. - await stream.textStream.pipeTo(writer); + if (writer !== undefined && writer !== null) { + await stream.textStream.pipeTo(writer as unknown as WritableStream); + } // Wait for final text and attempt to parse JSON; fall back to defaults if parse fails. const finalText = await stream.text; @@ -150,7 +148,7 @@ const researchTopicStep = createStep({ parsedResearch = null; } - if (parsedResearch) { + if (parsedResearch !== null) { research.summary = parsedResearch.summary; research.keyPoints = parsedResearch.keyPoints; research.sources = parsedResearch.sources ?? []; @@ -176,18 +174,23 @@ const researchTopicStep = createStep({ research, }; - span.setAttribute('keyPointsCount', research.keyPoints.length); - span.setAttribute('hasAgent', Boolean(agent)); - span.setAttribute('responseTimeMs', Date.now() - startTime); - span.end(); + span?.update({ + output: result, + metadata: { + 'keyPoints.count': research.keyPoints.length, + 'hasAgent': agent !== undefined && agent !== null, + }, + }); + span?.end(); logStepEnd('research-topic', { keyPointsCount: research.keyPoints.length }, Date.now() - startTime); return result; } catch (error) { - span.recordException(error instanceof Error ? error : new Error(String(error))); - span.setStatus({ code: SpanStatusCode.ERROR }); - span.end(); logError('research-topic', error, { topic: inputData.topic }); + span?.error({ + error: error instanceof Error ? error : new Error(String(error)), + endSpan: true, + }); await writer?.custom({ type: 'data-tool-progress', @@ -209,44 +212,37 @@ const draftContentStep = createStep({ description: 'Creates initial content draft using copywriterAgent', inputSchema: researchOutputSchema, outputSchema: draftOutputSchema, - execute: async ({ inputData, mastra, writer }) => { - const startTime = Date.now(); - logStepStart('draft-content', { topic: inputData.topic, contentType: inputData.contentType }); - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `Starting content draft for ${inputData.contentType}: ${inputData.topic}`, - stage: 'draft-content', - }, - id: 'draft-content', - }) - - const tracer = trace.getTracer('content-review'); - const span = tracer.startSpan('copywriter-agent-call', { - attributes: { - topic: inputData.topic, - contentType: inputData.contentType, + execute: async ({ inputData, mastra, writer, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'draft-content', + input: inputData, + metadata: { + 'workflow.step': 'draft-content', + 'content.type': inputData.contentType, }, + requestContext, + mastra, }); + const startTime = Date.now(); + logStepStart('draft-content', { topic: inputData.topic, contentType: inputData.contentType }); try { await writer?.custom({ type: 'data-tool-progress', data: { status: 'in-progress', - message: `Drafting content for ${inputData.contentType}: ${inputData.topic}`, + message: `Starting content draft for ${inputData.contentType}: ${inputData.topic}`, stage: 'draft-content', }, id: 'draft-content', - }); + }) - const agent = mastra?.getAgent('copywriterAgent'); + const agent = mastra?.getAgent?.('copywriterAgent'); let content = ''; let structure: string[] = []; - if (agent) { + if (agent !== undefined && agent !== null) { await writer?.custom({ type: 'data-tool-progress', data: { @@ -272,7 +268,9 @@ const draftContentStep = createStep({ }), } as any); - await stream.textStream.pipeTo(writer); + if (writer !== undefined && writer !== null) { + await stream.textStream.pipeTo(writer as unknown as WritableStream); + } const finalText = await stream.text; let draftResult: z.infer<typeof draftOutputSchema>['draft'] | null = null; try { @@ -280,7 +278,7 @@ const draftContentStep = createStep({ } catch { draftResult = null; } - if (draftResult) { + if (draftResult !== null) { content = draftResult.content; structure = draftResult.structure ?? []; } @@ -310,18 +308,23 @@ const draftContentStep = createStep({ draft: { content, wordCount, structure }, }; - span.setAttribute('wordCount', wordCount); - span.setAttribute('hasAgent', Boolean(agent)); - span.setAttribute('responseTimeMs', Date.now() - startTime); - span.end(); + span?.update({ + output: result, + metadata: { + 'word.count': wordCount, + 'hasAgent': agent !== undefined && agent !== null, + }, + }); + span?.end(); logStepEnd('draft-content', { wordCount }, Date.now() - startTime); return result; } catch (error) { - span.recordException(error instanceof Error ? error : new Error(String(error))); - span.setStatus({ code: SpanStatusCode.ERROR }); - span.end(); logError('draft-content', error, { topic: inputData.topic }); + span?.error({ + error: error instanceof Error ? error : new Error(String(error)), + endSpan: true, + }); await writer?.custom({ type: 'data-tool-progress', @@ -343,43 +346,38 @@ const initialReviewStep = createStep({ description: 'Performs initial review of the draft', inputSchema: draftOutputSchema, outputSchema: reviewOutputSchema, - execute: async ({ inputData, mastra, writer }) => { - const startTime = Date.now(); - logStepStart('initial-review', { topic: inputData.topic }); - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `Starting initial review for ${inputData.contentType}: ${inputData.topic}`, - stage: 'initial-review', + execute: async ({ inputData, mastra, writer, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'initial-review', + input: { wordCount: inputData.draft.wordCount }, + metadata: { + 'workflow.step': 'initial-review', }, - id: 'initial-review', - }) - - const tracer = trace.getTracer('content-review'); - const span = tracer.startSpan('editor-agent-review', { - attributes: { wordCount: inputData.draft.wordCount }, + requestContext, + mastra, }); + const startTime = Date.now(); + logStepStart('initial-review', { topic: inputData.topic }); try { await writer?.custom({ type: 'data-tool-progress', data: { status: 'in-progress', - message: `Evaluating content quality for ${inputData.contentType}: ${inputData.topic}...`, + message: `Starting initial review for ${inputData.contentType}: ${inputData.topic}`, stage: 'initial-review', }, id: 'initial-review', }) - const editorAgent = mastra?.getAgent('editorAgent'); - const evaluationAgent = mastra?.getAgent('evaluationAgent'); + const editorAgent = mastra?.getAgent?.('editorAgent'); + const evaluationAgent = mastra?.getAgent?.('evaluationAgent'); let score = 70; let feedback: string[] = []; - if (evaluationAgent) { + if (evaluationAgent !== undefined && evaluationAgent !== null) { const stream = await evaluationAgent.stream( `Evaluate this ${inputData.contentType} about "${inputData.topic}". Rate it 0-100 and provide specific feedback.\n\nContent:\n${inputData.draft.content}`, { @@ -390,7 +388,9 @@ const initialReviewStep = createStep({ } as any ); - await stream.textStream.pipeTo(writer); + if (writer !== undefined && writer !== null) { + await stream.textStream.pipeTo(writer as unknown as WritableStream); + } const finalText = await stream.text; let parsedEval: { score: number; feedback: string[] } | null = null; try { @@ -398,11 +398,11 @@ const initialReviewStep = createStep({ } catch { parsedEval = null; } - if (parsedEval) { + if (parsedEval !== null) { score = parsedEval.score; feedback = parsedEval.feedback; } - } else if (editorAgent) { + } else if (editorAgent !== undefined && editorAgent !== null) { const stream = await editorAgent.stream( `Review this ${inputData.contentType}. Rate quality 0-100 and list improvements.\n\nContent:\n${inputData.draft.content}`, { @@ -413,7 +413,9 @@ const initialReviewStep = createStep({ } as any ); - await stream.textStream.pipeTo(writer); + if (writer !== undefined && writer !== null) { + await stream.textStream.pipeTo(writer as unknown as WritableStream); + } const finalTextEdit = await stream.text; let parsedEdit: { score: number; feedback: string[] } | null = null; try { @@ -421,7 +423,7 @@ const initialReviewStep = createStep({ } catch { parsedEdit = null; } - if (parsedEdit) { + if (parsedEdit !== null) { score = parsedEdit.score; feedback = parsedEdit.feedback; } @@ -429,16 +431,6 @@ const initialReviewStep = createStep({ feedback = ['Consider adding more detail', 'Improve transitions between sections']; } - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `Initial score: ${score}/100 for ${inputData.contentType}: ${inputData.topic}...`, - stage: 'initial-review', - }, - id: 'initial-review', - }); - const approved = score >= inputData.qualityThreshold; const result: z.infer<typeof reviewOutputSchema> = { @@ -456,11 +448,15 @@ const initialReviewStep = createStep({ scoreHistory: [score], }; - span.setAttribute('score', score); - span.setAttribute('approved', approved); - span.setAttribute('feedbackCount', feedback.length); - span.setAttribute('responseTimeMs', Date.now() - startTime); - span.end(); + span?.update({ + output: result, + metadata: { + score, + approved, + 'feedback.count': feedback.length, + }, + }); + span?.end(); await writer?.custom({ type: 'data-tool-progress', @@ -472,14 +468,14 @@ const initialReviewStep = createStep({ id: 'initial-review', }); - logStepEnd('initial-review', { score, approved, iteration: 1 }, Date.now() - startTime); return result; } catch (error) { - span.recordException(error instanceof Error ? error : new Error(String(error))); - span.setStatus({ code: SpanStatusCode.ERROR }); - span.end(); logError('initial-review', error, { topic: inputData.topic }); + span?.error({ + error: error instanceof Error ? error : new Error(String(error)), + endSpan: true, + }); await writer?.custom({ type: 'data-tool-progress', @@ -501,58 +497,50 @@ const refineContentStep = createStep({ description: 'Refines content based on feedback and re-evaluates', inputSchema: reviewOutputSchema, outputSchema: reviewOutputSchema, - execute: async ({ inputData, mastra, writer }) => { - const startTime = Date.now(); + execute: async ({ inputData, mastra, writer, requestContext }) => { const nextIteration = inputData.iteration + 1; - logStepStart('refine-content', { topic: inputData.topic, iteration: nextIteration }); - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `Starting refinement iteration ${nextIteration} for ${inputData.contentType}: ${inputData.topic}`, - stage: 'refine-content', + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'refine-content', + input: { iteration: nextIteration, previousScore: inputData.score }, + metadata: { + 'workflow.step': 'refine-content', + 'iteration': nextIteration, }, - id: 'refine-content', - }) + requestContext, + mastra, + }); + const startTime = Date.now(); + logStepStart('refine-content', { topic: inputData.topic, iteration: nextIteration }); if (nextIteration > MAX_ITERATIONS) { const error = new Error(`Maximum iterations (${MAX_ITERATIONS}) exceeded. Final score: ${inputData.score}`); logError('refine-content', error, { iteration: nextIteration, score: inputData.score }); + span?.error({ error, endSpan: true }); throw error; } - const tracer = trace.getTracer('content-review'); - const span = tracer.startSpan('content-refinement', { - attributes: { - iteration: nextIteration, - previousScore: inputData.score, - }, - }); - try { await writer?.custom({ type: 'data-tool-progress', data: { status: 'in-progress', - message: `Applying feedback and rewriting content for ${inputData.contentType}: ${inputData.topic} (iteration ${nextIteration})`, + message: `Starting refinement iteration ${nextIteration} for ${inputData.contentType}: ${inputData.topic}`, stage: 'refine-content', }, id: 'refine-content', - }); + }) - const copywriterAgent = mastra?.getAgent('copywriterAgent'); - const editorAgent = mastra?.getAgent('editorAgent'); - const evaluationAgent = mastra?.getAgent('evaluationAgent'); + const copywriterAgent = mastra?.getAgent?.('copywriterAgent'); + const editorAgent = mastra?.getAgent?.('editorAgent'); + const evaluationAgent = mastra?.getAgent?.('evaluationAgent'); let refinedContent = inputData.content; - if (copywriterAgent || editorAgent) { + if (copywriterAgent !== undefined && copywriterAgent !== null || editorAgent !== undefined && editorAgent !== null) { const agent = copywriterAgent ?? editorAgent; - if (!agent) { - throw new Error('No copywriter/editor agent available'); - } - const refinePrompt = `Improve this ${inputData.contentType} based on feedback: + if (agent !== undefined && agent !== null) { + const refinePrompt = `Improve this ${inputData.contentType} based on feedback: Feedback: ${inputData.feedback.map(f => `- ${f}`).join('\n')} @@ -562,32 +550,27 @@ ${inputData.content} Rewrite with improvements.`; - const stream = await agent.stream(refinePrompt); - // Stream partial rewrite progress - await stream.textStream.pipeTo(writer); - const rewrittenText = await stream.text; - refinedContent = rewrittenText; + const stream = await agent.stream(refinePrompt); + // Stream partial rewrite progress + if (writer !== undefined && writer !== null) { + await stream.textStream.pipeTo(writer as unknown as WritableStream); + } + const rewrittenText = await stream.text; + refinedContent = rewrittenText; + } } - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `Re-evaluating refined content for iteration ${nextIteration}...`, - stage: 'refine-content', - }, - id: 'refine-content', - }); - let newScore = inputData.score + 5; let newFeedback: string[] = []; - if (evaluationAgent) { + if (evaluationAgent !== undefined && evaluationAgent !== null) { const evalStream = await evaluationAgent.stream( `Re-evaluate this improved ${inputData.contentType}. Rate 0-100 and provide remaining feedback.\n\nContent:\n${refinedContent}` ); - await evalStream.textStream.pipeTo(writer); + if (writer !== undefined && writer !== null) { + await evalStream.textStream.pipeTo(writer as unknown as WritableStream); + } const evalFinalText = await evalStream.text; const parsedEval = (() => { @@ -598,16 +581,18 @@ Rewrite with improvements.`; } })(); - if (parsedEval && typeof parsedEval.score === 'number' && Array.isArray(parsedEval.feedback)) { + if (parsedEval !== null && typeof parsedEval.score === 'number' && Array.isArray(parsedEval.feedback)) { newScore = parsedEval.score; newFeedback = parsedEval.feedback; } - } else if (editorAgent) { + } else if (editorAgent !== undefined && editorAgent !== null) { const editStream = await editorAgent.stream( `Review this refined content. Rate 0-100.\n\nContent:\n${refinedContent}` ); - await editStream.textStream.pipeTo(writer); + if (writer !== undefined && writer !== null) { + await editStream.textStream.pipeTo(writer as unknown as WritableStream); + } const editFinalText = await editStream.text; const parsedEdit = (() => { @@ -618,7 +603,7 @@ Rewrite with improvements.`; } })(); - if (parsedEdit && typeof parsedEdit.score === 'number' && Array.isArray(parsedEdit.feedback)) { + if (parsedEdit !== null && typeof parsedEdit.score === 'number' && Array.isArray(parsedEdit.feedback)) { newScore = parsedEdit.score; newFeedback = parsedEdit.feedback; } @@ -627,16 +612,6 @@ Rewrite with improvements.`; newFeedback = newScore >= inputData.qualityThreshold ? [] : ['Minor improvements still possible']; } - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `Iteration ${nextIteration}: Score ${newScore}/100 for ${inputData.contentType}: ${inputData.topic}...`, - stage: 'refine-content', - }, - id: 'refine-content', - }); - const approved = newScore >= inputData.qualityThreshold; const wordCount = refinedContent.split(/\s+/).length; @@ -655,12 +630,15 @@ Rewrite with improvements.`; scoreHistory: [...inputData.scoreHistory, newScore], }; - span.setAttribute('score', newScore); - span.setAttribute('approved', approved); - span.setAttribute('iteration', nextIteration); - span.setAttribute('improvement', newScore - inputData.score); - span.setAttribute('responseTimeMs', Date.now() - startTime); - span.end(); + span?.update({ + output: result, + metadata: { + 'score': newScore, + approved, + 'improvement': newScore - inputData.score, + }, + }); + span?.end(); await writer?.custom({ type: 'data-tool-progress', @@ -672,14 +650,14 @@ Rewrite with improvements.`; id: 'refine-content', }); - logStepEnd('refine-content', { score: newScore, approved, iteration: nextIteration }, Date.now() - startTime); return result; } catch (error) { - span.recordException(error instanceof Error ? error : new Error(String(error))); - span.setStatus({ code: SpanStatusCode.ERROR }); - span.end(); logError('refine-content', error, { iteration: nextIteration }); + span?.error({ + error: error instanceof Error ? error : new Error(String(error)), + endSpan: true, + }); await writer?.custom({ type: 'data-tool-progress', @@ -701,58 +679,68 @@ const finalizeContentStep = createStep({ description: 'Finalizes and formats the approved content', inputSchema: reviewOutputSchema, outputSchema: finalOutputSchema, - execute: async ({ inputData, writer }) => { - const startTime = Date.now(); - logStepStart('finalize-content', { topic: inputData.topic, finalScore: inputData.score }); - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `Finalizing approved ${inputData.contentType}: ${inputData.topic}`, - stage: 'finalize-content', - }, - id: 'finalize-content', - }) - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `Formatting final ${inputData.contentType} with score ${inputData.score}/${inputData.qualityThreshold}...`, - stage: 'finalize-content', - }, - id: 'finalize-content', - }) - - const result: z.infer<typeof finalOutputSchema> = { - content: inputData.content, - score: inputData.score, - iterations: inputData.iteration, - feedback: inputData.feedback, + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'finalize-content', + input: { finalScore: inputData.score }, metadata: { - topic: inputData.topic, - contentType: inputData.contentType, - targetAudience: inputData.targetAudience, - wordCount: inputData.wordCount, - qualityThreshold: inputData.qualityThreshold, - scoreHistory: inputData.scoreHistory, - generatedAt: new Date().toISOString(), - }, - }; - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: `Finalized ${inputData.contentType}: ${inputData.topic} (${inputData.iteration} iterations)`, - stage: 'finalize-content', + 'workflow.step': 'finalize-content', }, - id: 'finalize-content', + requestContext, + mastra, }); + const startTime = Date.now(); + logStepStart('finalize-content', { topic: inputData.topic, finalScore: inputData.score }); + + try { + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `Finalizing approved ${inputData.contentType}: ${inputData.topic}`, + stage: 'finalize-content', + }, + id: 'finalize-content', + }) - logStepEnd('finalize-content', { iterations: inputData.iteration, finalScore: inputData.score }, Date.now() - startTime); - return result; + const result: z.infer<typeof finalOutputSchema> = { + content: inputData.content, + score: inputData.score, + iterations: inputData.iteration, + feedback: inputData.feedback, + metadata: { + topic: inputData.topic, + contentType: inputData.contentType, + targetAudience: inputData.targetAudience, + wordCount: inputData.wordCount, + qualityThreshold: inputData.qualityThreshold, + scoreHistory: inputData.scoreHistory, + generatedAt: new Date().toISOString(), + }, + }; + + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `Finalized ${inputData.contentType}: ${inputData.topic} (${inputData.iteration} iterations)`, + stage: 'finalize-content', + }, + id: 'finalize-content', + }); + + logStepEnd('finalize-content', { iterations: inputData.iteration, finalScore: inputData.score }, Date.now() - startTime); + + span?.update({ output: result }); + span?.end(); + + return result; + } catch (error) { + logError('finalize-content', error); + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }); + throw error; + } }, }); diff --git a/src/mastra/workflows/content-studio-workflow.ts b/src/mastra/workflows/content-studio-workflow.ts index f35056fe..25cec951 100644 --- a/src/mastra/workflows/content-studio-workflow.ts +++ b/src/mastra/workflows/content-studio-workflow.ts @@ -1,5 +1,6 @@ import { createStep, createWorkflow } from '@mastra/core/workflows'; import { z } from 'zod'; +import { SpanType, getOrCreateSpan } from '@mastra/core/observability'; import { contentStrategistAgent } from '../agents/contentStrategistAgent'; import { editorAgent } from '../agents/editorAgent'; import { evaluationAgent } from '../agents/evaluationAgent'; @@ -98,57 +99,77 @@ const researchStep = createStep({ id: 'research-step', inputSchema: z.object({ topic: z.string() }), outputSchema: researchStepOutputSchema, - execute: async ({ inputData, writer }) => { - const start = Date.now(); - logStepStart('research-step', inputData); - // Emit workflow step start - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: "in-progress", - message: `Starting research phase for topic: ${inputData.topic}...`, - stage: "research-step", + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'research-step', + input: inputData, + metadata: { + 'workflow.step': 'research-step', + 'content.topic': inputData.topic, }, - id: "research-step", + requestContext, + mastra, }); - - const prompt = `Research the topic: "${inputData.topic}". + const start = Date.now(); + logStepStart('research-step', inputData); + try { + // Emit workflow step start + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: "in-progress", + message: `Starting research phase for topic: ${inputData.topic}...`, + stage: "research-step", + }, + id: "research-step", + }); + + const prompt = `Research the topic: "${inputData.topic}". Focus on finding unique angles, trending discussions, and key facts. Return JSON with summary, data, and sources.`; - const stream = await researchAgent.stream(prompt, { - output: researchAgentOutputSchema, - } as any); - await stream.textStream.pipeTo(writer); - const finalText = await stream.text; - - // Ensure we always return a valid object matching the schema - let researchData: z.infer<typeof researchAgentOutputSchema> = { - summary: '', - data: '', - }; - try { - const parsed = JSON.parse(finalText); - // Validate parsed data against the schema (runtime safety) - researchData = researchAgentOutputSchema.parse(parsed); - } catch { - // If parsing or validation fails, keep the default empty object + const stream = await researchAgent.stream(prompt, { + output: researchAgentOutputSchema, + } as any); + await stream.textStream.pipeTo(writer); + const finalText = await stream.text; + + // Ensure we always return a valid object matching the schema + let researchData: z.infer<typeof researchAgentOutputSchema> = { + summary: '', + data: '', + }; + try { + const parsed = JSON.parse(finalText); + // Validate parsed data against the schema (runtime safety) + researchData = researchAgentOutputSchema.parse(parsed); + } catch { + // If parsing or validation fails, keep the default empty object + } + + const output = { topic: inputData.topic, researchData }; + // Emit workflow step complete + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: "done", + message: `Research phase completed for topic: ${inputData.topic}`, + stage: "research-step", + }, + id: "research-step", + }); + + logStepEnd('research-step', output, Date.now() - start); + + span?.update({ output }); + span?.end(); + + return output; + } catch (error) { + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }); + throw error; } - - const output = { topic: inputData.topic, researchData }; - // Emit workflow step complete - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: "done", - message: `Research phase completed for topic: ${inputData.topic}`, - stage: "research-step", - }, - id: "research-step", - }); - - logStepEnd('research-step', output, Date.now() - start); - return output; }, });; @@ -156,55 +177,74 @@ const evaluationStep = createStep({ id: 'evaluation-step', inputSchema: researchStepOutputSchema, outputSchema: evaluationStepOutputSchema, - execute: async ({ inputData, writer }) => { - const start = Date.now(); - logStepStart('evaluation-step', inputData); - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: "in-progress", - message: `Starting evaluation phase for topic: ${inputData.topic}...`, - stage: "evaluation-step", + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'evaluation-step', + input: inputData, + metadata: { + 'workflow.step': 'evaluation-step', }, - id: "evaluation-step", + requestContext, + mastra, }); - const prompt = `Evaluate the relevance of this research to the topic "${inputData.topic}". + const start = Date.now(); + logStepStart('evaluation-step', inputData); + try { + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: "in-progress", + message: `Starting evaluation phase for topic: ${inputData.topic}...`, + stage: "evaluation-step", + }, + id: "evaluation-step", + }); + const prompt = `Evaluate the relevance of this research to the topic "${inputData.topic}". Research Summary: ${inputData.researchData.summary} Return JSON with isRelevant and reason.`; - const stream = await evaluationAgent.stream(prompt, { - output: evaluationAgentOutputSchema, - } as any); - await stream.textStream.pipeTo(writer); - const finalText = await stream.text; - - // Default evaluation in case parsing/validation fails - let evaluation: z.infer<typeof evaluationAgentOutputSchema> = { - isRelevant: false, - reason: '', - }; - - try { - const parsed = JSON.parse(finalText); - // Validate against the schema for runtime safety - evaluation = evaluationAgentOutputSchema.parse(parsed); - } catch { - // Keep the default evaluation object + const stream = await evaluationAgent.stream(prompt, { + output: evaluationAgentOutputSchema, + } as any); + await stream.textStream.pipeTo(writer); + const finalText = await stream.text; + + // Default evaluation in case parsing/validation fails + let evaluation: z.infer<typeof evaluationAgentOutputSchema> = { + isRelevant: false, + reason: '', + }; + + try { + const parsed = JSON.parse(finalText); + // Validate against the schema for runtime safety + evaluation = evaluationAgentOutputSchema.parse(parsed); + } catch { + // Keep the default evaluation object + } + + const output = { ...inputData, evaluation }; + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: "done", + message: `Evaluation phase completed for topic: ${inputData.topic}`, + stage: "evaluation-step", + }, + id: "evaluation-step", + }); + + logStepEnd('evaluation-step', output, Date.now() - start); + + span?.update({ output }); + span?.end(); + + return output; + } catch (error) { + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }); + throw error; } - - const output = { ...inputData, evaluation }; - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: "done", - message: `Evaluation phase completed for topic: ${inputData.topic}`, - stage: "evaluation-step", - }, - id: "evaluation-step", - }); - - logStepEnd('evaluation-step', output, Date.now() - start); - return output; }, });; @@ -212,53 +252,72 @@ const learningStep = createStep({ id: 'learning-step', inputSchema: evaluationStepOutputSchema, outputSchema: learningStepOutputSchema, - execute: async ({ inputData, writer }) => { - const start = Date.now(); - logStepStart('learning-step', inputData); - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: "in-progress", - message: `Starting learning extraction phase for topic: ${inputData.topic}...`, - stage: "learning-step", + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'learning-step', + input: inputData, + metadata: { + 'workflow.step': 'learning-step', }, - id: "learning-step", + requestContext, + mastra, }); - const prompt = `Extract the single most important learning from this research data: + const start = Date.now(); + logStepStart('learning-step', inputData); + try { + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: "in-progress", + message: `Starting learning extraction phase for topic: ${inputData.topic}...`, + stage: "learning-step", + }, + id: "learning-step", + }); + const prompt = `Extract the single most important learning from this research data: ${inputData.researchData.data} Return JSON with learning and followUpQuestion.`; - const stream = await learningExtractionAgent.stream(prompt, { - output: learningAgentOutputSchema, - } as any); - await stream.textStream.pipeTo(writer); - const finalText = await stream.text; - - // Default learning object to satisfy schema if parsing fails - let learning: z.infer<typeof learningAgentOutputSchema> = { - learning: '', - followUpQuestion: '', - }; - try { - const parsed = JSON.parse(finalText); - learning = learningAgentOutputSchema.parse(parsed); - } catch { - // Keep default learning object on error + const stream = await learningExtractionAgent.stream(prompt, { + output: learningAgentOutputSchema, + } as any); + await stream.textStream.pipeTo(writer); + const finalText = await stream.text; + + // Default learning object to satisfy schema if parsing fails + let learning: z.infer<typeof learningAgentOutputSchema> = { + learning: '', + followUpQuestion: '', + }; + try { + const parsed = JSON.parse(finalText); + learning = learningAgentOutputSchema.parse(parsed); + } catch { + // Keep default learning object on error + } + + const output = { ...inputData, learning }; + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: "done", + message: `Learning extraction phase completed for topic: ${inputData.topic}`, + stage: "learning-step", + }, + id: "learning-step", + }); + + logStepEnd('learning-step', output, Date.now() - start); + + span?.update({ output }); + span?.end(); + + return output; + } catch (error) { + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }); + throw error; } - - const output = { ...inputData, learning }; - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: "done", - message: `Learning extraction phase completed for topic: ${inputData.topic}`, - stage: "learning-step", - }, - id: "learning-step", - }); - - logStepEnd('learning-step', output, Date.now() - start); - return output; }, });; @@ -266,20 +325,31 @@ const strategyStep = createStep({ id: 'strategy-step', inputSchema: strategyInputSchema, outputSchema: strategyOutputSchema, - execute: async ({ inputData, writer }) => { - const start = Date.now(); - logStepStart('strategy-step', inputData); - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: "in-progress", - message: `Starting content strategy phase for topic: ${inputData.topic}...`, - stage: "strategy-step", + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'strategy-step', + input: inputData, + metadata: { + 'workflow.step': 'strategy-step', }, - id: "strategy-step", + requestContext, + mastra, }); - - const researchContext = ` + const start = Date.now(); + logStepStart('strategy-step', inputData); + try { + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: "in-progress", + message: `Starting content strategy phase for topic: ${inputData.topic}...`, + stage: "strategy-step", + }, + id: "strategy-step", + }); + + const researchContext = ` Research Summary: ${inputData.researchData.summary} Key Data: ${inputData.researchData.data} Relevance Check: ${inputData.evaluation.isRelevant ? 'Relevant' : 'Not Relevant'} (${inputData.evaluation.reason}) @@ -287,47 +357,55 @@ const strategyStep = createStep({ Follow-up Question: ${inputData.learning.followUpQuestion} `; - const prompt = `Create a content plan for topic: "${inputData.topic}". + const prompt = `Create a content plan for topic: "${inputData.topic}". Use the following research context to inform your strategy: ${researchContext} Return JSON with title, targetAudience, angle, and keyPoints.`; - const stream = await contentStrategistAgent.stream(prompt, { - output: strategyOutputSchema.shape.plan, - } as any); - await stream.textStream.pipeTo(writer); - const finalText = await stream.text; - - // Default plan to satisfy the schema if parsing/validation fails - const defaultPlan: z.infer<typeof strategyOutputSchema>['plan'] = { - title: '', - targetAudience: '', - angle: '', - keyPoints: [], - }; - - let plan: z.infer<typeof strategyOutputSchema>['plan'] = defaultPlan; - try { - const parsed = JSON.parse(finalText); - plan = strategyOutputSchema.shape.plan.parse(parsed); - } catch { - // Keep defaultPlan on error + const stream = await contentStrategistAgent.stream(prompt, { + output: strategyOutputSchema.shape.plan, + } as any); + await stream.textStream.pipeTo(writer); + const finalText = await stream.text; + + // Default plan to satisfy the schema if parsing/validation fails + const defaultPlan: z.infer<typeof strategyOutputSchema>['plan'] = { + title: '', + targetAudience: '', + angle: '', + keyPoints: [], + }; + + let plan: z.infer<typeof strategyOutputSchema>['plan'] = defaultPlan; + try { + const parsed = JSON.parse(finalText); + plan = strategyOutputSchema.shape.plan.parse(parsed); + } catch { + // Keep defaultPlan on error + } + + const output = { plan }; + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: "done", + message: `Content strategy phase completed for topic: ${inputData.topic}`, + stage: "strategy-step", + }, + id: "strategy-step", + }); + + logStepEnd('strategy-step', output, Date.now() - start); + + span?.update({ output }); + span?.end(); + + return output; + } catch (error) { + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }); + throw error; } - - const output = { plan }; - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: "done", - message: `Content strategy phase completed for topic: ${inputData.topic}`, - stage: "strategy-step", - }, - id: "strategy-step", - }); - - logStepEnd('strategy-step', output, Date.now() - start); - return output; }, });; @@ -335,53 +413,72 @@ const hookStep = createStep({ id: 'hook-step', inputSchema: hookInputSchema, outputSchema: hookOutputSchema, - execute: async ({ inputData, writer }) => { - const start = Date.now(); - logStepStart('hook-step', inputData); - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: "in-progress", - message: `Starting hook creation phase for topic: ${inputData.plan.title}...`, - stage: "hook-step", + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'hook-step', + input: inputData, + metadata: { + 'workflow.step': 'hook-step', }, - id: "hook-step", + requestContext, + mastra, }); - - const prompt = `Write 3 distinct hooks for this plan: ${JSON.stringify( - inputData.plan, - )}. + const start = Date.now(); + logStepStart('hook-step', inputData); + try { + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: "in-progress", + message: `Starting hook creation phase for topic: ${inputData.plan.title}...`, + stage: "hook-step", + }, + id: "hook-step", + }); + + const prompt = `Write 3 distinct hooks for this plan: ${JSON.stringify( + inputData.plan, + )}. Return JSON with an array of strings named 'hooks'.`; - const stream = await scriptWriterAgent.stream(prompt, { - output: z.object({ hooks: z.array(z.string()) }), - } as any); - await stream.textStream.pipeTo(writer); - const finalText = await stream.text; - - // Default to an empty hooks array if parsing or validation fails - let hooks: string[] = []; - try { - const parsed = JSON.parse(finalText); - const validated = hookOutputSchema.shape.hooks.parse(parsed); - hooks = validated; - } catch { - // Keep hooks as empty array on error + const stream = await scriptWriterAgent.stream(prompt, { + output: z.object({ hooks: z.array(z.string()) }), + } as any); + await stream.textStream.pipeTo(writer); + const finalText = await stream.text; + + // Default to an empty hooks array if parsing or validation fails + let hooks: string[] = []; + try { + const parsed = JSON.parse(finalText); + const validated = hookOutputSchema.shape.hooks.parse(parsed); + hooks = validated; + } catch { + // Keep hooks as empty array on error + } + + const output = { hooks, plan: inputData.plan }; + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: "done", + message: `Hook creation phase completed for topic: ${inputData.plan.title}`, + stage: "hook-step", + }, + id: "hook-step", + }); + + logStepEnd('hook-step', output, Date.now() - start); + + span?.update({ output }); + span?.end(); + + return output; + } catch (error) { + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }); + throw error; } - - const output = { hooks, plan: inputData.plan }; - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: "done", - message: `Hook creation phase completed for topic: ${inputData.plan.title}`, - stage: "hook-step", - }, - id: "hook-step", - }); - - logStepEnd('hook-step', output, Date.now() - start); - return output; }, }); @@ -389,43 +486,62 @@ const bodyStep = createStep({ id: 'body-step', inputSchema: bodyInputSchema, outputSchema: bodyOutputSchema, - execute: async ({ inputData, writer }) => { - const start = Date.now(); - logStepStart('body-step', inputData); - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: "in-progress", - message: `Starting body script creation phase for topic: ${inputData.plan.title}...`, - stage: "body-step", + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'body-step', + input: inputData, + metadata: { + 'workflow.step': 'body-step', }, - id: "body-step", + requestContext, + mastra, }); - - const prompt = `Write the main body script for this plan: ${JSON.stringify(inputData.plan)}. - Do not include hooks. Return JSON with 'bodyScript'.`; - const stream = await scriptWriterAgent.stream(prompt, { output: z.object({ bodyScript: z.string() }) } as any); - await stream.textStream.pipeTo(writer); - const finalText = await stream.text; - let bodyResult: z.infer<typeof bodyOutputSchema> | null = null; + const start = Date.now(); + logStepStart('body-step', inputData); try { - bodyResult = JSON.parse(finalText) as z.infer<typeof bodyOutputSchema>; - } catch { - bodyResult = null; + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: "in-progress", + message: `Starting body script creation phase for topic: ${inputData.plan.title}...`, + stage: "body-step", + }, + id: "body-step", + }); + + const prompt = `Write the main body script for this plan: ${JSON.stringify(inputData.plan)}. + Do not include hooks. Return JSON with 'bodyScript'.`; + const stream = await scriptWriterAgent.stream(prompt, { output: z.object({ bodyScript: z.string() }) } as any); + await stream.textStream.pipeTo(writer); + const finalText = await stream.text; + let bodyResult: z.infer<typeof bodyOutputSchema> | null = null; + try { + bodyResult = JSON.parse(finalText) as z.infer<typeof bodyOutputSchema>; + } catch { + bodyResult = null; + } + const output = { bodyScript: bodyResult?.bodyScript ?? '', plan: inputData.plan, hooks: inputData.hooks }; + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: "done", + message: `Body script creation phase completed for topic: ${inputData.plan.title}`, + stage: "body-step", + }, + id: "body-step", + }); + + logStepEnd('body-step', output, Date.now() - start); + + span?.update({ output }); + span?.end(); + + return output; + } catch (error) { + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }); + throw error; } - const output = { bodyScript: bodyResult?.bodyScript ?? '', plan: inputData.plan, hooks: inputData.hooks }; - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: "done", - message: `Body script creation phase completed for topic: ${inputData.plan.title}`, - stage: "body-step", - }, - id: "body-step", - }); - - logStepEnd('body-step', output, Date.now() - start); - return output; }, }); @@ -433,62 +549,81 @@ const reviewStep = createStep({ id: 'review-step', inputSchema: reviewInputSchema, outputSchema: reviewOutputSchema, - execute: async ({ inputData, writer }) => { - const start = Date.now(); - logStepStart('review-step', inputData); - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: "in-progress", - message: `Starting review phase for topic: ${inputData.plan.title}...`, - stage: "review-step", + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'review-step', + input: inputData, + metadata: { + 'workflow.step': 'review-step', }, - id: "review-step", + requestContext, + mastra, }); - - const fullScript = `HOOKS:\n${inputData.hooks.join('\n---\n')}\n\nBODY:\n${inputData.bodyScript}`; - - const prompt = `Review this script based on the plan: ${JSON.stringify( - inputData.plan, - )}. + const start = Date.now(); + logStepStart('review-step', inputData); + try { + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: "in-progress", + message: `Starting review phase for topic: ${inputData.plan.title}...`, + stage: "review-step", + }, + id: "review-step", + }); + + const fullScript = `HOOKS:\n${inputData.hooks.join('\n---\n')}\n\nBODY:\n${inputData.bodyScript}`; + + const prompt = `Review this script based on the plan: ${JSON.stringify( + inputData.plan, + )}. Rate 0-100. If < 80, give feedback. Return JSON with score, feedback, approved, and finalScript (which is just the input script for now).`; - const stream = await editorAgent.stream(prompt, { output: reviewOutputSchema } as any); - await stream.textStream.pipeTo(writer); - const finalText = await stream.text; - - // Default values that satisfy the schema - const defaultReview: z.infer<typeof reviewOutputSchema> = { - score: 0, - feedback: '', - approved: false, - finalScript: fullScript, - }; - - let review: z.infer<typeof reviewOutputSchema> = defaultReview; - try { - const parsed = JSON.parse(finalText); - // Validate parsed data against the schema; will throw if invalid - review = reviewOutputSchema.parse(parsed); - } catch { - // Keep defaultReview on error + const stream = await editorAgent.stream(prompt, { output: reviewOutputSchema } as any); + await stream.textStream.pipeTo(writer); + const finalText = await stream.text; + + // Default values that satisfy the schema + const defaultReview: z.infer<typeof reviewOutputSchema> = { + score: 0, + feedback: '', + approved: false, + finalScript: fullScript, + }; + + let review: z.infer<typeof reviewOutputSchema> = defaultReview; + try { + const parsed = JSON.parse(finalText); + // Validate parsed data against the schema; will throw if invalid + review = reviewOutputSchema.parse(parsed); + } catch { + // Keep defaultReview on error + } + + // Ensure the finalScript always contains the full script + review.finalScript = fullScript; + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: "done", + message: `Review phase completed for topic: ${inputData.plan.title}`, + stage: "review-step", + }, + id: "review-step", + }); + + logStepEnd('review-step', review, Date.now() - start); + + span?.update({ output: review }); + span?.end(); + + return review; + } catch (error) { + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }); + throw error; } - - // Ensure the finalScript always contains the full script - review.finalScript = fullScript; - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: "done", - message: `Review phase completed for topic: ${inputData.plan.title}`, - stage: "review-step", - }, - id: "review-step", - }); - - logStepEnd('review-step', review, Date.now() - start); - return review; }, });; @@ -496,69 +631,88 @@ const refineStep = createStep({ id: 'refine-step', inputSchema: refineInputSchema, outputSchema: refineOutputSchema, - execute: async ({ inputData, writer }) => { - const start = Date.now(); - logStepStart('refine-step', inputData); - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: "in-progress", - message: `Starting refinement phase for topic: ${inputData.finalScript.split('\n')[0].slice(0, 50)}...`, - stage: "refine-step", + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'refine-step', + input: inputData, + metadata: { + 'workflow.step': 'refine-step', }, - id: "refine-step", + requestContext, + mastra, }); - - // 1. Refine the script based on feedback - const refinePrompt = `Refine this script based on feedback: "${inputData.feedback}". + const start = Date.now(); + logStepStart('refine-step', inputData); + try { + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: "in-progress", + message: `Starting refinement phase for topic: ${inputData.finalScript.split('\n')[0].slice(0, 50)}...`, + stage: "refine-step", + }, + id: "refine-step", + }); + + // 1. Refine the script based on feedback + const refinePrompt = `Refine this script based on feedback: "${inputData.feedback}". Script: ${inputData.finalScript}`; - const refineStream = await scriptWriterAgent.stream(refinePrompt); - await refineStream.textStream.pipeTo(writer); - const refinedScript = await refineStream.text; + const refineStream = await scriptWriterAgent.stream(refinePrompt); + await refineStream.textStream.pipeTo(writer); + const refinedScript = await refineStream.text; - if (!refinedScript) { - throw new Error('scriptWriterAgent.stream returned an empty text for refine-step'); - } + if (!refinedScript) { + throw new Error('scriptWriterAgent.stream returned an empty text for refine-step'); + } - // 2. Re‑evaluate the refined script - const evalPrompt = `Evaluate this refined script. Rate 0-100. + // 2. Re‑evaluate the refined script + const evalPrompt = `Evaluate this refined script. Rate 0-100. Script: ${refinedScript}`; - const evalStream = await editorAgent.stream(evalPrompt, { - output: reviewOutputSchema, - } as any); - await evalStream.textStream.pipeTo(writer); - const evalFinalText = await evalStream.text; - - // Default review values that satisfy the schema - const defaultReview: z.infer<typeof reviewOutputSchema> = { - score: 0, - feedback: '', - approved: false, - finalScript: refinedScript, - }; - - let review: z.infer<typeof reviewOutputSchema>; - try { - const parsed = JSON.parse(evalFinalText); - review = reviewOutputSchema.parse(parsed); - } catch { - review = defaultReview; + const evalStream = await editorAgent.stream(evalPrompt, { + output: reviewOutputSchema, + } as any); + await evalStream.textStream.pipeTo(writer); + const evalFinalText = await evalStream.text; + + // Default review values that satisfy the schema + const defaultReview: z.infer<typeof reviewOutputSchema> = { + score: 0, + feedback: '', + approved: false, + finalScript: refinedScript, + }; + + let review: z.infer<typeof reviewOutputSchema>; + try { + const parsed = JSON.parse(evalFinalText); + review = reviewOutputSchema.parse(parsed); + } catch { + review = defaultReview; + } + + // Ensure the finalScript is always the refined version + review.finalScript = refinedScript; + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: "done", + message: `Refinement phase completed for topic: ${inputData.finalScript.split('\n')[0].slice(0, 50)}...`, + stage: "refine-step", + }, + id: "refine-step", + }); + + logStepEnd('refine-step', review, Date.now() - start); + + span?.update({ output: review }); + span?.end(); + + return review; + } catch (error) { + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }); + throw error; } - - // Ensure the finalScript is always the refined version - review.finalScript = refinedScript; - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: "done", - message: `Refinement phase completed for topic: ${inputData.finalScript.split('\n')[0].slice(0, 50)}...`, - stage: "refine-step", - }, - id: "refine-step", - }); - - logStepEnd('refine-step', review, Date.now() - start); - return review; }, });; diff --git a/src/mastra/workflows/data-analysis-workflow.ts b/src/mastra/workflows/data-analysis-workflow.ts index c4617051..d88973c3 100644 --- a/src/mastra/workflows/data-analysis-workflow.ts +++ b/src/mastra/workflows/data-analysis-workflow.ts @@ -1,10 +1,11 @@ import { createStep, createWorkflow } from '@mastra/core/workflows'; import { z } from 'zod'; +import { SpanType, getOrCreateSpan } from '@mastra/core/observability'; import { codeArchitectAgent } from '../agents/codingAgents'; import { reportAgent } from '../agents/reportAgent'; import { createSandbox, runCode, writeFile } from '../tools/e2b'; import { logStepEnd, logStepStart, logError } from '../config/logger'; -import { trace, SpanStatusCode } from '@opentelemetry/api'; + const analysisInputSchema = z.object({ data: z.string().describe('CSV or JSON data content'), @@ -33,22 +34,33 @@ const generateAnalysisCodeStep = createStep({ query: z.string(), dataFormat: z.string(), }), - execute: async ({ inputData, writer }) => { + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'generate-analysis-code', + input: inputData, + metadata: { + 'workflow.step': 'generate-analysis-code', + }, + requestContext, + mastra, + }); const startTime = Date.now(); logStepStart('generate-analysis-code', { query: inputData.query }); - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `Planning analysis for query: ${inputData.query}...`, - stage: 'generate-analysis-code', - }, - id: 'generate-analysis-code', - }); + try { + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `Planning analysis for query: ${inputData.query}...`, + stage: 'generate-analysis-code', + }, + id: 'generate-analysis-code', + }); - const dataPreview = inputData.data.slice(0, 1000); - const prompt = `You are a Data Scientist. Generate a Python script to analyze the following data. + const dataPreview = inputData.data.slice(0, 1000); + const prompt = `You are a Data Scientist. Generate a Python script to analyze the following data. Data Format: ${inputData.dataFormat} Data Preview: @@ -66,7 +78,6 @@ const generateAnalysisCodeStep = createStep({ Return ONLY the Python code in a code block.`; - try { const result = await codeArchitectAgent.generate(prompt); const code = result.text.replace(/```python\n([\s\S]*?)\n```/, '$1').trim(); @@ -82,14 +93,22 @@ const generateAnalysisCodeStep = createStep({ logStepEnd('generate-analysis-code', {}, Date.now() - startTime); - return { + const output = { code, data: inputData.data, query: inputData.query, dataFormat: inputData.dataFormat, }; + + span?.update({ + output, + }); + span?.end(); + + return output; } catch (error) { logError('generate-analysis-code', error); + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }); throw error; } }, @@ -111,23 +130,30 @@ const executeAnalysisStep = createStep({ query: z.string(), }), execute: async ({ inputData, writer, mastra, requestContext }) => { - const startTime = Date.now(); - logStepStart('execute-analysis', { query: inputData.query }); - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `Executing analysis in E2B sandbox...`, - stage: 'execute-analysis', + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'execute-analysis', + input: { query: inputData.query }, + metadata: { + 'workflow.step': 'execute-analysis', }, - id: 'execute-analysis', + requestContext, + mastra, }); - - const tracer = trace.getTracer('data-analysis'); - const span = tracer.startSpan('e2b-analysis-execution'); + const startTime = Date.now(); + logStepStart('execute-analysis', { query: inputData.query }); try { + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `Executing analysis in E2B sandbox...`, + stage: 'execute-analysis', + }, + id: 'execute-analysis', + }); + const sandbox = await createSandbox.execute({ timeoutMS: 300_000, }, { mastra, requestContext }); @@ -174,20 +200,22 @@ const executeAnalysisStep = createStep({ id: 'execute-analysis', }); - span.end(); logStepEnd('execute-analysis', { success: true }, Date.now() - startTime); - return { + const result = { stdout: execution.logs.stdout.join('\n'), stderr: execution.logs.stderr.join('\n'), plotGenerated, query: inputData.query, }; + + span?.update({ output: result }); + span?.end(); + + return result; } catch (error) { - span.recordException(error instanceof Error ? error : new Error(String(error))); - span.setStatus({ code: SpanStatusCode.ERROR }); - span.end(); logError('execute-analysis', error); + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }); throw error; } }, @@ -203,21 +231,32 @@ const generateDataReportStep = createStep({ query: z.string(), }), outputSchema: analysisOutputSchema, - execute: async ({ inputData, writer }) => { + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'generate-data-report', + input: { query: inputData.query }, + metadata: { + 'workflow.step': 'generate-data-report', + }, + requestContext, + mastra, + }); const startTime = Date.now(); logStepStart('generate-data-report', { query: inputData.query }); - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `Generating final report and insights...`, - stage: 'generate-data-report', - }, - id: 'generate-data-report', - }); + try { + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `Generating final report and insights...`, + stage: 'generate-data-report', + }, + id: 'generate-data-report', + }); - const prompt = `You are a Senior Analyst. Based on the following analysis execution results, generate a comprehensive report. + const prompt = `You are a Senior Analyst. Based on the following analysis execution results, generate a comprehensive report. Original Query: ${inputData.query} @@ -240,7 +279,6 @@ const generateDataReportStep = createStep({ - "insights": Array of insight strings. `; - try { const result = await reportAgent.generate(prompt); const output = JSON.parse(result.text); @@ -256,13 +294,19 @@ const generateDataReportStep = createStep({ logStepEnd('generate-data-report', {}, Date.now() - startTime); - return { + const finalResult = { report: output.report, insights: output.insights, artifacts: inputData.plotGenerated ? [{ name: 'plot.png', type: 'image/png' }] : [], }; + + span?.update({ output: finalResult }); + span?.end(); + + return finalResult; } catch (error) { logError('generate-data-report', error); + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }); throw error; } }, diff --git a/src/mastra/workflows/document-processing-workflow.ts b/src/mastra/workflows/document-processing-workflow.ts index 62580653..f931aca3 100644 --- a/src/mastra/workflows/document-processing-workflow.ts +++ b/src/mastra/workflows/document-processing-workflow.ts @@ -1,5 +1,5 @@ import { createStep, createWorkflow } from '@mastra/core/workflows'; -import { SpanStatusCode, trace } from '@opentelemetry/api'; +import { SpanType, getOrCreateSpan } from '@mastra/core/observability'; import { z } from 'zod'; import { logError, logStepEnd, logStepStart } from '../config/logger'; @@ -108,15 +108,21 @@ const loadDocumentStep = createStep({ inputSchema: documentInputSchema, outputSchema: loadedContentSchema, retries: 2, - execute: async ({ inputData, writer }) => { + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'load-document', + input: inputData, + metadata: { + 'workflow.step': 'load-document', + 'source.type': inputData.source.type, + }, + requestContext, + mastra, + }); const startTime = Date.now(); logStepStart('load-document', { sourceType: inputData.source.type }); - const tracer = trace.getTracer('document-processing'); - const span = tracer.startSpan('document-loader', { - attributes: { sourceType: inputData.source.type }, - }); - try { await writer?.custom({ type: 'data-tool-progress', @@ -210,11 +216,6 @@ const loadDocumentStep = createStep({ }, }; - span.setAttribute('contentType', contentType); - span.setAttribute('wordCount', wordCount); - span.setAttribute('responseTimeMs', Date.now() - startTime); - span.end(); - await writer?.custom({ type: 'data-tool-progress', data: { @@ -226,12 +227,20 @@ const loadDocumentStep = createStep({ }); logStepEnd('load-document', { contentType, wordCount }, Date.now() - startTime); + + span?.update({ + output: result, + metadata: { + 'content.type': contentType, + 'word.count': wordCount, + }, + }); + span?.end(); + return result; } catch (error) { - span.recordException(error instanceof Error ? error : new Error(String(error))); - span.setStatus({ code: SpanStatusCode.ERROR }); - span.end(); logError('load-document', error, { source: inputData.source }); + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }); await writer?.custom({ type: 'data-tool-progress', @@ -254,15 +263,20 @@ const convertPdfToMarkdownStep = createStep({ inputSchema: loadedContentSchema, outputSchema: markdownContentSchema, retries: 2, - execute: async ({ inputData, mastra, writer }) => { + execute: async ({ inputData, mastra, writer, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'convert-pdf-to-markdown', + input: { contentType: inputData.contentType }, + metadata: { + 'workflow.step': 'convert-pdf-to-markdown', + }, + requestContext, + mastra, + }); const startTime = Date.now(); logStepStart('convert-pdf-to-markdown', { contentType: inputData.contentType }); - const tracer = trace.getTracer('document-processing'); - const span = tracer.startSpan('pdf-to-markdown-converter', { - attributes: { contentType: inputData.contentType }, - }); - try { await writer?.custom({ type: 'data-tool-progress', @@ -277,15 +291,17 @@ const convertPdfToMarkdownStep = createStep({ let markdownContent = inputData.content; let pageCount: number | undefined; + const agent = mastra?.getAgent?.('documentProcessingAgent'); if (inputData.contentType === 'pdf') { - const agent = mastra?.getAgent('documentProcessingAgent'); - if (agent) { + if (agent !== undefined && agent !== null) { const stream = await agent.stream( `Convert the PDF at "${inputData.content}" to markdown format. Extract all text content while preserving structure.`, { output: z.object({ markdown: z.string(), pageCount: z.number().optional() }) } as any ); // Pipe partial text deltas to the workflow writer (if present) - await stream.textStream?.pipeTo?.(writer); + if (writer !== undefined && writer !== null) { + await stream.textStream?.pipeTo?.(writer as unknown as WritableStream); + } // Wait for final aggregated text and attempt to parse JSON const finalText = await stream.text; let parsed: { markdown: string; pageCount?: number } | null = null; @@ -294,7 +310,7 @@ const convertPdfToMarkdownStep = createStep({ } catch { parsed = null; } - if (parsed) { + if (parsed !== null) { markdownContent = parsed.markdown; pageCount = parsed.pageCount; } else { @@ -335,11 +351,6 @@ const convertPdfToMarkdownStep = createStep({ settings: inputData.settings, }; - span.setAttribute('wordCount', result.metadata.wordCount ?? 0); - span.setAttribute('convertedFrom', inputData.contentType); - span.setAttribute('responseTimeMs', Date.now() - startTime); - span.end(); - await writer?.custom({ type: 'data-tool-progress', data: { @@ -351,13 +362,20 @@ const convertPdfToMarkdownStep = createStep({ }); logStepEnd('convert-pdf-to-markdown', { wordCount: result.metadata.wordCount }, Date.now() - startTime); + + span?.update({ + output: result, + metadata: { + 'word.count': result.metadata.wordCount ?? 0, + 'converted.from': inputData.contentType, + }, + }); + span?.end(); + return result; } catch (error) { - // Convert Mastra-specific `span.error` to OpenTelemetry patterns - span.recordException(error instanceof Error ? error : new Error(String(error))); - span.setStatus({ code: SpanStatusCode.ERROR }); - span.end(); logError('convert-pdf-to-markdown', error, { contentType: inputData.contentType }); + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }); await writer?.custom({ type: 'data-tool-progress', @@ -379,14 +397,24 @@ const passTextThroughStep = createStep({ description: 'Passes text/markdown content through without conversion', inputSchema: loadedContentSchema, outputSchema: markdownContentSchema, - execute: async ({ inputData, writer }) => { + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'pass-text-through', + input: { contentType: inputData.contentType }, + metadata: { + 'workflow.step': 'pass-text-through', + }, + requestContext, + mastra, + }); const startTime = Date.now(); await writer?.custom({ type: 'data-tool-progress', data: { status: 'in-progress', - message: `Processing text content ${startTime}, ${inputData.content}...`, + message: `Processing text content ${startTime}, ${inputData.content.slice(0, 50)}...`, stage: 'pass-text-through', }, id: 'pass-text-through', @@ -406,12 +434,15 @@ const passTextThroughStep = createStep({ type: 'data-tool-progress', data: { status: "done", // must be "done" for the UI to show the step as completed - message: `Text passed through successfully ${inputData.content}`, + message: `Text passed through successfully`, stage: 'pass-text-through', }, id: 'pass-text-through', }); + span?.update({ output: result }); + span?.end(); + return result; }, }); @@ -421,17 +452,20 @@ const chunkDocumentStep = createStep({ description: 'Chunks document for RAG using configured strategy', inputSchema: markdownContentSchema, outputSchema: chunkedContentSchema, - execute: async ({ inputData, writer }) => { - const startTime = Date.now(); - logStepStart('chunk-document', { strategy: inputData.settings.chunkStrategy }); - - const tracer = trace.getTracer('document-processing'); - const span = tracer.startSpan('document-chunker', { - attributes: { - strategy: inputData.settings.chunkStrategy, - contentLength: inputData.content.length, + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'chunk-document', + input: { strategy: inputData.settings.chunkStrategy, contentLength: inputData.content.length }, + metadata: { + 'workflow.step': 'chunk-document', + 'chunk.strategy': inputData.settings.chunkStrategy, }, + requestContext, + mastra, }); + const startTime = Date.now(); + logStepStart('chunk-document', { strategy: inputData.settings.chunkStrategy }); try { const { chunkSize, chunkOverlap, chunkStrategy } = inputData.settings; @@ -555,7 +589,7 @@ const chunkDocumentStep = createStep({ id: 'chunk-document', }); - const avgChunkSize = chunks.reduce((sum, c) => sum + c.content.length, 0) / chunks.length; + const avgChunkSize = chunks.length > 0 ? chunks.reduce((sum, c) => sum + c.content.length, 0) / chunks.length : 0; const result: z.infer<typeof chunkedContentSchema> = { chunks, @@ -570,11 +604,6 @@ const chunkDocumentStep = createStep({ settings: { indexName: inputData.settings.indexName }, }; - span.setAttribute('totalChunks', chunks.length); - span.setAttribute('avgChunkSize', Math.round(avgChunkSize)); - span.setAttribute('responseTimeMs', Date.now() - startTime); - span.end(); - await writer?.custom({ type: 'data-tool-progress', data: { @@ -590,12 +619,20 @@ const chunkDocumentStep = createStep({ { totalChunks: chunks.length, avgChunkSize: Math.round(avgChunkSize) }, Date.now() - startTime ); + + span?.update({ + output: result, + metadata: { + 'total.chunks': chunks.length, + 'avg.chunk.size': Math.round(avgChunkSize), + }, + }); + span?.end(); + return result; } catch (error) { - span.recordException(error instanceof Error ? error : new Error(String(error))); - span.setStatus({ code: SpanStatusCode.ERROR }); - span.end(); logError('chunk-document', error, { strategy: inputData.settings.chunkStrategy }); + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }); await writer?.custom({ type: 'data-tool-progress', @@ -617,17 +654,20 @@ const indexChunksStep = createStep({ description: 'Indexes chunks into vector store', inputSchema: chunkedContentSchema, outputSchema: indexedDocumentSchema, - execute: async ({ inputData, mastra, writer }) => { - const startTime = Date.now(); - logStepStart('index-chunks', { totalChunks: inputData.totalChunks }); - - const tracer = trace.getTracer('document-processing'); - const span = tracer.startSpan('vector-indexer', { - attributes: { - totalChunks: inputData.totalChunks, - indexName: inputData.settings.indexName, + execute: async ({ inputData, mastra, writer, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'index-chunks', + input: { totalChunks: inputData.totalChunks, indexName: inputData.settings.indexName }, + metadata: { + 'workflow.step': 'index-chunks', + 'index.name': inputData.settings.indexName, }, + requestContext, + mastra, }); + const startTime = Date.now(); + logStepStart('index-chunks', { totalChunks: inputData.totalChunks }); try { await writer?.custom({ @@ -643,8 +683,8 @@ const indexChunksStep = createStep({ const documentId = `doc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const vectorIds: string[] = []; - const agent = mastra?.getAgent('knowledgeIndexingAgent'); - if (agent !== undefined) { + const agent = mastra?.getAgent?.('knowledgeIndexingAgent'); + if (agent !== undefined && agent !== null) { for (let i = 0; i < inputData.chunks.length; i++) { vectorIds.push(`vec-${documentId}-${i}`); @@ -694,12 +734,6 @@ const indexChunksStep = createStep({ }, }; - span.setAttribute('documentId', documentId); - span.setAttribute('chunksCount', inputData.totalChunks); - span.setAttribute('indexed', true); - span.setAttribute('responseTimeMs', Date.now() - startTime); - span.end(); - await writer?.custom({ type: 'data-tool-progress', data: { @@ -711,12 +745,20 @@ const indexChunksStep = createStep({ }); logStepEnd('index-chunks', { documentId, chunksCount: inputData.totalChunks }, Date.now() - startTime); + + span?.update({ + output: result, + metadata: { + 'document.id': documentId, + 'chunks.count': inputData.totalChunks, + }, + }); + span?.end(); + return result; } catch (error) { - span.recordException(error instanceof Error ? error : new Error(String(error))); - span.setStatus({ code: SpanStatusCode.ERROR }); - span.end(); logError('index-chunks', error, { totalChunks: inputData.totalChunks }); + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }); await writer?.custom({ type: 'data-tool-progress', diff --git a/src/mastra/workflows/financial-analysis-workflow.ts b/src/mastra/workflows/financial-analysis-workflow.ts index 45278cab..c4d4b4b1 100644 --- a/src/mastra/workflows/financial-analysis-workflow.ts +++ b/src/mastra/workflows/financial-analysis-workflow.ts @@ -1,5 +1,6 @@ import { createStep, createWorkflow } from '@mastra/core/workflows' import { z } from 'zod' +import { SpanType, getOrCreateSpan } from '@mastra/core/observability' import { logError, logStepEnd, logStepStart } from '../config/logger' const financialDataSchema = z.object({ @@ -55,7 +56,17 @@ const fetchFinancialDataStep = createStep({ }), outputSchema: financialDataSchema, retries: 3, - execute: async ({ inputData, writer }) => { + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'fetch-financial-data', + input: inputData, + metadata: { + 'workflow.step': 'fetch-financial-data', + }, + requestContext, + mastra, + }) const startTime = Date.now() logStepStart('fetch-financial-data', { symbol: inputData.symbol }) @@ -97,8 +108,11 @@ const fetchFinancialDataStep = createStep({ { symbol: result.symbol }, Date.now() - startTime ) + span?.update({ output: result }) + span?.end() return result } catch (error) { + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }) logError('fetch-financial-data', error, { symbol: inputData.symbol, }) @@ -126,7 +140,17 @@ const performTechnicalAnalysisStep = createStep({ financial: financialDataSchema, technical: technicalAnalysisSchema, }), - execute: async ({ inputData, writer }) => { + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'perform-technical-analysis', + input: inputData, + metadata: { + 'workflow.step': 'perform-technical-analysis', + }, + requestContext, + mastra, + }) const startTime = Date.now() logStepStart('perform-technical-analysis', { symbol: inputData.symbol }) @@ -172,8 +196,12 @@ const performTechnicalAnalysisStep = createStep({ { symbol: inputData.symbol }, Date.now() - startTime ) - return { financial: inputData, technical } + const output = { financial: inputData, technical } + span?.update({ output }) + span?.end() + return output } catch (error) { + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }) logError('perform-technical-analysis', error, { symbol: inputData.symbol, }) @@ -201,9 +229,20 @@ const generateFinancialReportStep = createStep({ technical: technicalAnalysisSchema, }), outputSchema: reportSchema, - execute: async ({ inputData, writer }) => { + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'generate-financial-report', + input: inputData, + metadata: { + 'workflow.step': 'generate-financial-report', + }, + requestContext, + mastra, + }) const startTime = Date.now() - const symbol = inputData.financial.symbol + const { financial } = inputData + const { symbol } = financial logStepStart('generate-financial-report', { symbol }) try { @@ -243,8 +282,11 @@ const generateFinancialReportStep = createStep({ { symbol, recommendation: report.recommendation }, Date.now() - startTime ) + span?.update({ output: report }) + span?.end() return report } catch (error) { + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }) logError('generate-financial-report', error, { symbol }) await writer?.custom({ diff --git a/src/mastra/workflows/financial-report-workflow.ts b/src/mastra/workflows/financial-report-workflow.ts index 0f53d3f1..681782bb 100644 --- a/src/mastra/workflows/financial-report-workflow.ts +++ b/src/mastra/workflows/financial-report-workflow.ts @@ -1,5 +1,5 @@ import { createStep, createWorkflow } from '@mastra/core/workflows'; -import { SpanStatusCode, trace } from '@opentelemetry/api'; +import { SpanType, getOrCreateSpan } from '@mastra/core/observability'; import { z } from 'zod'; import { logError, logStepEnd, logStepStart } from '../config/logger'; @@ -141,26 +141,32 @@ const fetchPriceDataStep = createStep({ }), }), retries: 3, - execute: async ({ inputData, writer }) => { - const startTime = Date.now(); - logStepStart('fetch-price-data', { symbols: inputData.symbols }); - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `Starting price fetch for ${inputData.symbols.length} symbols...`, - stage: 'fetch-price-data', + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'fetch-price-data', + input: inputData, + metadata: { + 'workflow.step': 'fetch-price-data', + symbols: inputData.symbols, }, - id: 'fetch-price-data', - }); - - const tracer = trace.getTracer('financial-report'); - const parentSpan = tracer.startSpan('parallel-price-fetch', { - attributes: { symbols: inputData.symbols }, + requestContext, + mastra, }); + const startTime = Date.now(); + logStepStart('fetch-price-data', { symbols: inputData.symbols }); try { + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `Starting price fetch for ${inputData.symbols.length} symbols...`, + stage: 'fetch-price-data', + }, + id: 'fetch-price-data', + }); + const priceData: Array<z.infer<typeof priceDataSchema>> = []; const apiKey = process.env.POLYGON_API_KEY; @@ -177,10 +183,6 @@ const fetchPriceDataStep = createStep({ for (let i = 0; i < inputData.symbols.length; i++) { const symbol = inputData.symbols[i]; - const symbolSpan = tracer.startSpan(`price-fetch-${symbol}`, { - attributes: { symbol, service: 'polygon' }, - }); - try { if (apiKey) { const url = `https://api.polygon.io/v2/snapshot/locale/us/markets/stocks/tickers/${symbol}?apiKey=${apiKey}`; @@ -212,14 +214,7 @@ const fetchPriceDataStep = createStep({ }); } - symbolSpan.setAttribute('symbol', symbol); - symbolSpan.setAttribute('fetched', true); - symbolSpan.setAttribute('responseTimeMs', Date.now() - startTime); - symbolSpan.end(); } catch (error) { - symbolSpan.recordException(error instanceof Error ? error : new Error(String(error))); - symbolSpan.setStatus({ code: SpanStatusCode.ERROR }); - symbolSpan.end(); priceData.push({ symbol, timestamp: new Date().toISOString() }); } await writer?.custom({ @@ -233,11 +228,6 @@ const fetchPriceDataStep = createStep({ }); } - parentSpan.setAttribute('symbolsCount', priceData.length); - parentSpan.setAttribute('responseTimeMs', Date.now() - startTime); - parentSpan.end(); - - await writer?.custom({ type: 'data-tool-progress', data: { @@ -250,7 +240,7 @@ const fetchPriceDataStep = createStep({ logStepEnd('fetch-price-data', { symbolsCount: priceData.length }, Date.now() - startTime); - return { + const output = { priceData, metadata: { symbols: inputData.symbols, @@ -259,10 +249,19 @@ const fetchPriceDataStep = createStep({ includeTechnicals: inputData.includeTechnicals, }, }; + + span?.update({ + output, + metadata: { + symbolsCount: priceData.length, + responseTimeMs: Date.now() - startTime, + }, + }); + span?.end(); + + return output; } catch (error) { - parentSpan.recordException(error instanceof Error ? error : new Error(String(error))); - parentSpan.setStatus({ code: SpanStatusCode.ERROR }); - parentSpan.end(); + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }); logError('fetch-price-data', error, { symbols: inputData.symbols }); await writer?.custom({ @@ -294,7 +293,18 @@ const fetchCompanyMetricsStep = createStep({ }), }), retries: 3, - execute: async ({ inputData, writer }) => { + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'fetch-company-metrics', + input: inputData, + metadata: { + 'workflow.step': 'fetch-company-metrics', + symbols: inputData.symbols, + }, + requestContext, + mastra, + }); const startTime = Date.now(); logStepStart('fetch-company-metrics', { symbols: inputData.symbols }); @@ -308,11 +318,6 @@ const fetchCompanyMetricsStep = createStep({ id: 'fetch-company-metrics', }) - const tracer = trace.getTracer('financial-report'); - const span = tracer.startSpan('parallel-metrics-fetch', { - attributes: { symbols: inputData.symbols }, - }); - try { const companyMetrics: Array<z.infer<typeof companyMetricsSchema>> = []; const apiKey = process.env.FINNHUB_API_KEY; @@ -372,10 +377,6 @@ const fetchCompanyMetricsStep = createStep({ }); } - span.setAttribute('symbolsCount', companyMetrics.length); - span.setAttribute('responseTimeMs', Date.now() - startTime); - span.end(); - await writer?.custom({ type: 'data-tool-progress', data: { @@ -388,7 +389,7 @@ const fetchCompanyMetricsStep = createStep({ logStepEnd('fetch-company-metrics', { symbolsCount: companyMetrics.length }, Date.now() - startTime); - return { + const output = { companyMetrics, metadata: { symbols: inputData.symbols, @@ -397,10 +398,19 @@ const fetchCompanyMetricsStep = createStep({ includeTechnicals: inputData.includeTechnicals, }, }; + + span?.update({ + output, + metadata: { + symbolsCount: companyMetrics.length, + responseTimeMs: Date.now() - startTime, + } + }); + span?.end(); + + return output; } catch (error) { - span.recordException(error instanceof Error ? error : new Error(String(error))); - span.setStatus({ code: SpanStatusCode.ERROR }); - span.end(); + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }); logError('fetch-company-metrics', error, { symbols: inputData.symbols }); await writer?.custom({ @@ -432,7 +442,18 @@ const fetchNewsSentimentStep = createStep({ }), }), retries: 3, - execute: async ({ inputData, writer }) => { + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'fetch-news-sentiment', + input: inputData, + metadata: { + 'workflow.step': 'fetch-news-sentiment', + symbols: inputData.symbols, + }, + requestContext, + mastra, + }); const startTime = Date.now(); logStepStart('fetch-news-sentiment', { symbols: inputData.symbols }); @@ -448,11 +469,6 @@ const fetchNewsSentimentStep = createStep({ id: 'fetch-news-sentiment', }) - const tracer = trace.getTracer('financial-report'); - const span = tracer.startSpan('parallel-news-fetch', { - attributes: { symbols: inputData.symbols }, - }); - try { const newsSentiment: Array<z.infer<typeof newsSentimentSchema>> = []; const apiKey = process.env.FINNHUB_API_KEY; @@ -462,12 +478,7 @@ const fetchNewsSentimentStep = createStep({ newsSentiment.push({ symbol, articles: [], overallSentiment: 'neutral' }); }); - span.setAttribute('skipped', true); - span.setAttribute('skipReason', 'includeNews=false'); - span.setAttribute('responseTimeMs', Date.now() - startTime); - span.end(); - - return { + const output = { newsSentiment, metadata: { symbols: inputData.symbols, @@ -476,6 +487,18 @@ const fetchNewsSentimentStep = createStep({ includeTechnicals: inputData.includeTechnicals, }, }; + + span?.update({ + output, + metadata: { + skipped: true, + skipReason: 'includeNews=false', + responseTimeMs: Date.now() - startTime, + } + }); + span?.end(); + + return output; } await writer?.custom({ @@ -538,10 +561,6 @@ const fetchNewsSentimentStep = createStep({ }); } - span.setAttribute('symbolsCount', newsSentiment.length); - span.setAttribute('responseTimeMs', Date.now() - startTime); - span.end(); - await writer?.custom({ type: 'data-tool-progress', data: { @@ -554,7 +573,7 @@ const fetchNewsSentimentStep = createStep({ logStepEnd('fetch-news-sentiment', { symbolsCount: newsSentiment.length }, Date.now() - startTime); - return { + const finalOutput = { newsSentiment, metadata: { symbols: inputData.symbols, @@ -563,10 +582,19 @@ const fetchNewsSentimentStep = createStep({ includeTechnicals: inputData.includeTechnicals, }, }; + + span?.update({ + output: finalOutput, + metadata: { + symbolsCount: newsSentiment.length, + responseTimeMs: Date.now() - startTime, + } + }); + span?.end(); + + return finalOutput; } catch (error) { - span.recordException(error instanceof Error ? error : new Error(String(error))); - span.setStatus({ code: SpanStatusCode.ERROR }); - span.end(); + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }); logError('fetch-news-sentiment', error, { symbols: inputData.symbols }); await writer?.custom({ @@ -589,15 +617,21 @@ const mergeDataStep = createStep({ description: 'Merges parallel fetch results into unified structure', inputSchema: parallelResultsSchema, outputSchema: mergedDataSchema, - execute: async ({ inputData, writer }) => { + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'merge-data', + input: inputData, + metadata: { + 'workflow.step': 'merge-data', + symbolsCount: inputData.metadata.symbols.length, + }, + requestContext, + mastra, + }); const startTime = Date.now(); logStepStart('merge-data', { symbolsCount: inputData.metadata.symbols.length }); - const tracer = trace.getTracer('financial-report'); - const span = tracer.startSpan('data-merge', { - attributes: { symbolsCount: inputData.metadata.symbols.length }, - }); - try { await writer?.custom({ type: 'data-tool-progress', @@ -627,10 +661,6 @@ const mergeDataStep = createStep({ }, }; - span.setAttribute('totalSymbols', stocks.length); - span.setAttribute('responseTimeMs', Date.now() - startTime); - span.end(); - await writer?.custom({ type: 'data-tool-progress', data: { @@ -642,11 +672,19 @@ const mergeDataStep = createStep({ }) logStepEnd('merge-data', { totalSymbols: stocks.length }, Date.now() - startTime); + + span?.update({ + output: result, + metadata: { + totalSymbols: stocks.length, + responseTimeMs: Date.now() - startTime, + } + }); + span?.end(); + return result; } catch (error) { - span.recordException(error instanceof Error ? error : new Error(String(error))); - span.setStatus({ code: SpanStatusCode.ERROR }); - span.end(); + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }); logError('merge-data', error); await writer?.custom({ @@ -669,15 +707,21 @@ const analyzeDataStep = createStep({ description: 'Analyzes merged data using stockAnalysisAgent', inputSchema: mergedDataSchema, outputSchema: analysisResultSchema, - execute: async ({ inputData, mastra, writer }) => { + execute: async ({ inputData, mastra, writer, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'analyze-data', + input: inputData, + metadata: { + 'workflow.step': 'analyze-data', + symbols: inputData.metadata.symbols, + }, + requestContext, + mastra, + }); const startTime = Date.now(); logStepStart('analyze-data', { totalSymbols: inputData.metadata.totalSymbols }); - const tracer = trace.getTracer('financial-report'); - const span = tracer.startSpan('stock-analysis', { - attributes: { symbols: inputData.metadata.symbols }, - }); - try { await writer?.custom({ type: 'data-tool-progress', @@ -772,27 +816,21 @@ const analyzeDataStep = createStep({ metadata: inputData.metadata, }; - span.setAttribute('marketTrend', marketTrend); - span.setAttribute('recommendationsCount', recommendations.length); - span.setAttribute('responseTimeMs', Date.now() - startTime); - span.end(); + logStepEnd('analyze-data', { marketTrend, recommendationsCount: recommendations.length }, Date.now() - startTime); - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: `Generated ${recommendations.length} recommendations.`, - stage: 'analyze-data', - }, - id: 'analyze-data', - }) + span?.update({ + output: result, + metadata: { + marketTrend, + recommendationsCount: recommendations.length, + responseTimeMs: Date.now() - startTime, + } + }); + span?.end(); - logStepEnd('analyze-data', { marketTrend, recommendationsCount: recommendations.length }, Date.now() - startTime); return result; } catch (error) { - span.recordException(error instanceof Error ? error : new Error(String(error))); - span.setStatus({ code: SpanStatusCode.ERROR }); - span.end(); + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }); logError('analyze-data', error); await writer?.custom({ @@ -815,15 +853,21 @@ const generateReportStep = createStep({ description: 'Generates comprehensive financial report using reportAgent', inputSchema: analysisResultSchema, outputSchema: finalReportSchema, - execute: async ({ inputData, mastra, writer }) => { + execute: async ({ inputData, mastra, writer, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'generate-report', + input: inputData, + metadata: { + 'workflow.step': 'generate-report', + symbolsCount: inputData.metadata.totalSymbols, + }, + requestContext, + mastra, + }); const startTime = Date.now(); logStepStart('generate-report', { symbols: inputData.metadata.symbols }); - const tracer = trace.getTracer('financial-report'); - const span = tracer.startSpan('report-generation', { - attributes: { symbolsCount: inputData.metadata.totalSymbols }, - }); - try { await writer?.custom({ type: 'data-tool-progress', @@ -901,14 +945,29 @@ Provide a concise summary and detailed report.`; }\n\n## Recommendations\n\n${inputData.analysis.recommendations.map(r => `- **${r.symbol}**: ${r.action.toUpperCase()} - ${r.reason}`).join('\n')}`; } + logStepEnd('generate-report', { reportId }, Date.now() - startTime); + const result: z.infer<typeof finalReportSchema> = { reportId, generatedAt: new Date().toISOString(), summary, report, data: { - stocks: inputData.stocks, - analysis: inputData.analysis, + stocks: inputData.stocks.map(s => ({ + symbol: s.symbol, + price: s.price, + metrics: s.metrics, + sentiment: s.sentiment, + })), + analysis: { + topPerformers: inputData.analysis.topPerformers, + worstPerformers: inputData.analysis.worstPerformers, + bullishStocks: inputData.analysis.bullishStocks, + bearishStocks: inputData.analysis.bearishStocks, + averageChange: inputData.analysis.averageChange, + marketTrend: inputData.analysis.marketTrend, + recommendations: inputData.analysis.recommendations, + }, }, metadata: { symbols: inputData.metadata.symbols, @@ -917,27 +976,19 @@ Provide a concise summary and detailed report.`; }, }; - span.setAttribute('reportId', reportId); - span.setAttribute('summaryLength', summary.length); - span.setAttribute('responseTimeMs', Date.now() - startTime); - span.end(); - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: `Report generated (${reportId}).`, - stage: 'generate-report', - }, - id: 'generate-report', - }) + span?.update({ + output: result, + metadata: { + reportId, + summaryLength: summary.length, + responseTimeMs: Date.now() - startTime, + } + }); + span?.end(); - logStepEnd('generate-report', { reportId }, Date.now() - startTime); return result; } catch (error) { - span.recordException(error instanceof Error ? error : new Error(String(error))); - span.setStatus({ code: SpanStatusCode.ERROR }); - span.end(); + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }); logError('generate-report', error); await writer?.custom({ diff --git a/src/mastra/workflows/governed-rag-answer.workflow.ts b/src/mastra/workflows/governed-rag-answer.workflow.ts index 6f709bed..39881804 100644 --- a/src/mastra/workflows/governed-rag-answer.workflow.ts +++ b/src/mastra/workflows/governed-rag-answer.workflow.ts @@ -1,4 +1,5 @@ import { createStep, createWorkflow } from '@mastra/core/workflows' +import { SpanType, getOrCreateSpan } from '@mastra/core/observability' import { z } from 'zod' import { answererAgent, answererOutputSchema } from '../agents/answerer.agent' @@ -36,7 +37,17 @@ const authenticationStep = createStep({ accessFilter: accessFilterSchema, question: z.string(), }), - execute: async ({ inputData }) => { + execute: async ({ inputData, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'authentication', + input: { hasJwt: !!inputData.jwt }, + metadata: { + 'workflow.step': 'authentication', + }, + requestContext, + mastra, + }) const startTime = Date.now() logStepStart('authentication', { hasJwt: !!inputData.jwt }) @@ -63,8 +74,11 @@ const authenticationStep = createStep({ }, Date.now() - startTime ) + span?.update({ output }) + span?.end() return output } catch (error) { + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }) logError('authentication', error, { question: inputData.question }) throw new Error( `Authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}` @@ -86,7 +100,17 @@ const retrievalStep = createStep({ contexts: z.array(documentContextSchema), question: z.string(), }), - execute: async ({ inputData, mastra }) => { + execute: async ({ inputData, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'retrieval-and-rerank', + input: { question: inputData.question }, + metadata: { + 'workflow.step': 'retrieval-and-rerank', + }, + requestContext, + mastra, + }) const startTime = Date.now() const requestId = `REQ-${Date.now()}-${Math.random().toString(36).substr(2, 5)}` @@ -346,10 +370,13 @@ const retrievalStep = createStep({ { contextsFound: 0 }, Date.now() - startTime ) - return { + const output = { contexts: [], question: inputData.question, } + span?.update({ output }) + span?.end() + return output } // Rerank contexts for relevance @@ -403,6 +430,7 @@ const retrievalStep = createStep({ return output } } catch (error) { + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }) logError('retrieval-and-rerank', error, { accessFilter: inputData.accessFilter, }) @@ -426,7 +454,17 @@ const answerStep = createStep({ contexts: z.array(documentContextSchema), question: z.string(), }), - execute: async ({ inputData }) => { + execute: async ({ inputData, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'answer-generation', + input: { question: inputData.question, contextsCount: inputData.contexts.length }, + metadata: { + 'workflow.step': 'answer-generation', + }, + requestContext, + mastra, + }) const startTime = Date.now() logStepStart('answer-generation', { contextsCount: inputData.contexts.length, @@ -476,8 +514,11 @@ const answerStep = createStep({ { citationsCount: answer.citations?.length ?? 0 }, Date.now() - startTime ) + span?.update({ output }) + span?.end() return output } catch (error) { + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }) logError('answer-generation', error, { contextsCount: inputData.contexts.length, }) @@ -506,7 +547,17 @@ const verifyStep = createStep({ }) ), }), - execute: async ({ inputData }) => { + execute: async ({ inputData, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'answer-verification', + input: { question: inputData.question }, + metadata: { + 'workflow.step': 'answer-verification', + }, + requestContext, + mastra, + }) const startTime = Date.now() logStepStart('answer-verification', { citationsCount: inputData.answer.citations?.length || 0, @@ -560,6 +611,8 @@ const verifyStep = createStep({ }, Date.now() - startTime ) + span?.update({ output, metadata: { insufficientEvidence: true } }) + span?.end() return output } @@ -583,8 +636,11 @@ const verifyStep = createStep({ { verified: true, citationsCount: output.citations.length }, Date.now() - startTime ) + span?.update({ output }) + span?.end() return output } catch (error) { + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }) logError('answer-verification', error, { question: inputData.question, }) diff --git a/src/mastra/workflows/governed-rag-index.workflow.ts b/src/mastra/workflows/governed-rag-index.workflow.ts index 35ecf200..30f29fbf 100644 --- a/src/mastra/workflows/governed-rag-index.workflow.ts +++ b/src/mastra/workflows/governed-rag-index.workflow.ts @@ -1,4 +1,5 @@ import { createStep, createWorkflow } from '@mastra/core/workflows' +import { SpanType, getOrCreateSpan } from '@mastra/core/observability' import { z } from 'zod' import { @@ -40,7 +41,17 @@ const indexDocumentsStep = createStep({ }) ), }), - execute: async ({ inputData, writer }) => { + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'index-documents', + input: { totalDocuments: inputData.documents.length }, + metadata: { + 'workflow.step': 'index-documents', + }, + requestContext, + mastra, + }) const startTime = Date.now() const totalDocs = inputData.documents.length logStepStart('index-documents', { totalDocuments: totalDocs }) @@ -172,8 +183,12 @@ const indexDocumentsStep = createStep({ Date.now() - startTime ) + span?.update({ output: results }) + span?.end() + return results } catch (error) { + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }) logError('index-documents', error, { totalDocuments: totalDocs }) await writer?.custom({ diff --git a/src/mastra/workflows/learning-extraction-workflow.ts b/src/mastra/workflows/learning-extraction-workflow.ts index 080411f6..f8d30802 100644 --- a/src/mastra/workflows/learning-extraction-workflow.ts +++ b/src/mastra/workflows/learning-extraction-workflow.ts @@ -1,5 +1,5 @@ import { createStep, createWorkflow } from '@mastra/core/workflows'; -import { SpanStatusCode, trace } from '@opentelemetry/api'; +import { SpanType, getOrCreateSpan } from '@mastra/core/observability'; import { z } from 'zod'; import { logError, logStepEnd, logStepStart } from '../config/logger'; @@ -113,7 +113,18 @@ const extractLearningsStep = createStep({ description: 'Extracts learnings from content using learningExtractionAgent', inputSchema: learningInputSchema, outputSchema: extractionResultSchema, - execute: async ({ inputData, mastra, writer }) => { + execute: async ({ inputData, mastra, writer, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'extract-learnings', + input: inputData, + metadata: { + 'workflow.step': 'extract-learnings', + 'content.contentType': inputData.contentType, + }, + requestContext, + mastra, + }); const startTime = Date.now(); logStepStart('extract-learnings', { contentType: inputData.contentType, depth: inputData.extractionDepth }); @@ -127,14 +138,6 @@ const extractLearningsStep = createStep({ id: 'extract-learnings', }); - const tracer = trace.getTracer('learning-extraction'); - const span = tracer.startSpan('learning-extraction', { - attributes: { - contentType: inputData.contentType, - contentLength: inputData.content.length, - }, - }); - try { await writer?.custom({ type: 'data-tool-progress', @@ -263,19 +266,28 @@ Also provide an overall summary.`; }, }; - if (typeof learnings?.length === 'number') {span.setAttribute('learningsCount', learnings.length);} - if (typeof criticalCount === 'number') {span.setAttribute('criticalCount', criticalCount);} - if (typeof actionableCount === 'number') {span.setAttribute('actionableCount', actionableCount);} - span.setAttribute('responseTimeMs', Date.now() - startTime); - span.end(); + // Record metrics as span metadata (batch update) + const metrics = { + responseTimeMs: Date.now() - startTime, + learningsCount: Array.isArray(learnings) ? learnings.length : undefined, + criticalCount: typeof criticalCount === 'number' ? criticalCount : undefined, + actionableCount: typeof actionableCount === 'number' ? actionableCount : undefined, + }; + + span?.update({ + output: result, + metadata: metrics, + }); + span?.end(); logStepEnd('extract-learnings', { learningsCount: learnings.length }, Date.now() - startTime); return result; } catch (error) { - span.recordException(error instanceof Error ? error : new Error(String(error))); - span.setStatus({ code: SpanStatusCode.ERROR }); - span.end(); + span?.error({ + error: error instanceof Error ? error : new Error(String(error)), + endSpan: true, + }); logError('extract-learnings', error, { contentType: inputData.contentType }); await writer?.custom({ @@ -300,7 +312,18 @@ const humanApprovalStep = createStep({ outputSchema: approvalResultSchema, suspendSchema: suspendDataSchema, resumeSchema: resumeDataSchema, - execute: async ({ inputData, resumeData, suspend, writer }) => { + execute: async ({ inputData, resumeData, suspend, writer, requestContext, mastra }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'human-approval', + input: { learningsCount: inputData.learnings.length, requireApproval: inputData.requireApproval }, + metadata: { + 'workflow.step': 'human-approval', + 'hasResumeData': !!resumeData, + }, + requestContext, + mastra, + }); const startTime = Date.now(); logStepStart('human-approval', { learningsCount: inputData.learnings.length, hasResumeData: !!resumeData }); @@ -314,11 +337,6 @@ const humanApprovalStep = createStep({ id: 'human-approval', }); - const tracer = trace.getTracer('learning-extraction'); - const span = tracer.startSpan('human-approval-check', { - attributes: { learningsCount: inputData.learnings.length, requireApproval: inputData.requireApproval }, - }); - try { if (!inputData.requireApproval) { await writer?.custom({ @@ -345,10 +363,15 @@ const humanApprovalStep = createStep({ }, }; - span.setAttribute('autoApproved', true); - span.setAttribute('learningsCount', inputData.learnings.length); - span.setAttribute('responseTimeMs', Date.now() - startTime); - span.end(); + span?.update({ + output: result, + metadata: { + autoApproved: true, + learningsCount: inputData.learnings.length, + responseTimeMs: Date.now() - startTime, + }, + }); + span?.end(); return result; @@ -365,10 +388,17 @@ const humanApprovalStep = createStep({ id: 'human-approval', }); - span.setAttribute('suspended', true); - span.setAttribute('awaitingApproval', true); - span.setAttribute('responseTimeMs', Date.now() - startTime); - span.end(); + if (span) { + // Record suspension metadata via span.update to avoid direct attribute mutation + span.update({ + metadata: { + suspended: true, + awaitingApproval: true, + responseTimeMs: Date.now() - startTime, + }, + }); + span.end(); + } logStepEnd('human-approval', { status: 'suspended' }, Date.now() - startTime); @@ -438,19 +468,32 @@ const humanApprovalStep = createStep({ }, }; - if (typeof resumeData?.approved === 'boolean') {span.setAttribute('approved', resumeData.approved);} - if (Array.isArray(resumeData?.approvedLearnings)) {span.setAttribute('approvedLearningsCount', resumeData.approvedLearnings.length);} - if (Array.isArray(resumeData?.rejectedLearnings)) {span.setAttribute('rejectedLearningsCount', resumeData.rejectedLearnings.length);} - span.setAttribute('responseTimeMs', Date.now() - startTime); - span.end(); + const attrUpdates: Record<string, any> = {}; + if (typeof resumeData?.approved === 'boolean') { attrUpdates.approved = resumeData.approved; } + if (Array.isArray(resumeData?.approvedLearnings)) { attrUpdates.approvedLearningsCount = resumeData.approvedLearnings.length; } + if (Array.isArray(resumeData?.rejectedLearnings)) { attrUpdates.rejectedLearningsCount = resumeData.rejectedLearnings.length; } + if (Object.keys(attrUpdates).length > 0) { + span?.update({ + metadata: attrUpdates, + }); + } + + span?.update({ + output: result, + metadata: { + responseTimeMs: Date.now() - startTime, + }, + }); + span?.end(); logStepEnd('human-approval', { approved: resumeData.approved, approvedCount: approvedLearnings.length }, Date.now() - startTime); return result; } catch (error) { - span.recordException(error instanceof Error ? error : new Error(String(error))); - span.setStatus({ code: SpanStatusCode.ERROR }); - span.end(); + span?.error({ + error: error instanceof Error ? error : new Error(String(error)), + endSpan: true, + }); logError('human-approval', error); await writer?.custom({ @@ -473,7 +516,17 @@ const validateLearningsStep = createStep({ description: 'Validates approved learnings using evaluationAgent', inputSchema: approvalResultSchema, outputSchema: validatedLearningsSchema, - execute: async ({ inputData, mastra, writer }) => { + execute: async ({ inputData, mastra, writer, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'validate-learnings', + input: { learningsCount: inputData.learnings.length }, + metadata: { + 'workflow.step': 'validate-learnings', + }, + requestContext, + mastra, + }); const startTime = Date.now(); logStepStart('validate-learnings', { learningsCount: inputData.learnings.length }); @@ -487,14 +540,6 @@ const validateLearningsStep = createStep({ id: 'validate-learnings', }); - // Use OpenTelemetry directly; don't rely on Mastra tracing shims. - const tracer = trace.getTracer('learning-extraction'); - const span = tracer.startSpan('learning-validation', { - attributes: { - learningsCount: inputData.learnings.length, - }, - }); - try { await writer?.custom({ type: 'data-tool-progress', @@ -599,20 +644,25 @@ const validateLearningsStep = createStep({ }, }; - // Record result metrics on the span - span.setAttribute('totalValidated', result.validationSummary.totalValidated); - span.setAttribute('highQuality', highQuality); - span.setAttribute('needsReview', needsReview); - span.setAttribute('responseTimeMs', Date.now() - startTime); - span.end(); + span?.update({ + output: result, + metadata: { + totalValidated: result.validationSummary.totalValidated, + highQuality, + needsReview, + responseTimeMs: Date.now() - startTime, + }, + }); + span?.end(); logStepEnd('validate-learnings', result.validationSummary, Date.now() - startTime); return result; } catch (error) { - span.recordException(error instanceof Error ? error : new Error(String(error))); - span.setStatus({ code: SpanStatusCode.ERROR }); - span.end(); + span?.error({ + error: error instanceof Error ? error : new Error(String(error)), + endSpan: true, + }); logError('validate-learnings', error); @@ -636,7 +686,17 @@ const generateLearningReportStep = createStep({ description: 'Generates final learning extraction report', inputSchema: validatedLearningsSchema, outputSchema: finalOutputSchema, - execute: async ({ inputData, mastra, writer }) => { + execute: async ({ inputData, mastra, writer, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'generate-learning-report', + input: { learningsCount: inputData.learnings.length }, + metadata: { + 'workflow.step': 'generate-learning-report', + }, + requestContext, + mastra, + }); const startTime = Date.now(); logStepStart('generate-learning-report', { learningsCount: inputData.learnings.length }); @@ -650,14 +710,6 @@ const generateLearningReportStep = createStep({ id: 'generate-learning-report', }); - // Use OpenTelemetry directly; avoid Mastra-specific tracing shims. - const tracer = trace.getTracer('learning-extraction'); - const span = tracer.startSpan('learning-report-generation', { - attributes: { - learningsCount: inputData.learnings.length, - }, - }); - try { await writer?.custom({ type: 'data-tool-progress', @@ -776,17 +828,22 @@ const generateLearningReportStep = createStep({ }, }; - // Record useful metrics on the span. - span.setAttribute('reportLength', report.length); - span.setAttribute('responseTimeMs', Date.now() - startTime); - span.end(); + span?.update({ + output: result, + metadata: { + reportLength: report.length, + responseTimeMs: Date.now() - startTime, + }, + }); + span?.end(); logStepEnd('generate-learning-report', { reportLength: report.length }, Date.now() - startTime); return result; } catch (error) { - span.recordException(error instanceof Error ? error : new Error(String(error))); - span.setStatus({ code: SpanStatusCode.ERROR }); - span.end(); + span?.error({ + error: error instanceof Error ? error : new Error(String(error)), + endSpan: true, + }); logError('generate-learning-report', error); diff --git a/src/mastra/workflows/marketingCampaignWorkflow.ts b/src/mastra/workflows/marketingCampaignWorkflow.ts index 549273ce..1f51b5e3 100644 --- a/src/mastra/workflows/marketingCampaignWorkflow.ts +++ b/src/mastra/workflows/marketingCampaignWorkflow.ts @@ -1,4 +1,5 @@ import { createStep, createWorkflow } from '@mastra/core/workflows' +import { SpanType, getOrCreateSpan } from '@mastra/core/observability' import { z } from 'zod' const planningStep = createStep({ @@ -11,9 +12,27 @@ const planningStep = createStep({ outputSchema: z.object({ plan: z.string(), }), - execute: async ({ inputData }) => { - return { - plan: `Campaign plan for ${inputData.topic} targeting ${inputData.targetAudience}`, + execute: async ({ inputData, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'campaign-planning', + input: inputData, + metadata: { + 'workflow.step': 'campaign-planning', + }, + requestContext, + mastra, + }) + try { + const result = { + plan: `Campaign plan for ${inputData.topic} targeting ${inputData.targetAudience}`, + } + span?.update({ output: result }) + span?.end() + return result + } catch (error) { + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }) + throw error } }, }) @@ -27,9 +46,27 @@ const contentStep = createStep({ outputSchema: z.object({ content: z.string(), }), - execute: async ({ inputData }) => { - return { - content: `Content based on: ${inputData.plan}`, + execute: async ({ inputData, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'content-creation', + input: inputData, + metadata: { + 'workflow.step': 'content-creation', + }, + requestContext, + mastra, + }) + try { + const result = { + content: `Content based on: ${inputData.plan}`, + } + span?.update({ output: result }) + span?.end() + return result + } catch (error) { + span?.error({ error: error instanceof Error ? error : new Error(String(error)), endSpan: true }) + throw error } }, }) diff --git a/src/mastra/workflows/repo-ingestion-workflow.ts b/src/mastra/workflows/repo-ingestion-workflow.ts index 14ee66ab..2da5157a 100644 --- a/src/mastra/workflows/repo-ingestion-workflow.ts +++ b/src/mastra/workflows/repo-ingestion-workflow.ts @@ -1,5 +1,6 @@ import type { RequestContext } from '@mastra/core/request-context'; import { createStep, createWorkflow } from '@mastra/core/workflows'; +import { SpanType, getOrCreateSpan } from '@mastra/core/observability'; import { readFile } from 'node:fs/promises'; import { glob } from 'glob'; import * as path from 'node:path'; @@ -48,6 +49,16 @@ const scanStep = createStep({ inputSchema: scanInputSchema, outputSchema: scanOutputSchema, execute: async ({ inputData, mastra, requestContext, writer }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'scan-repo', + input: inputData, + metadata: { + 'workflow.step': 'scan-repo', + }, + requestContext, + mastra, + }); const { repoPath, githubRepo, githubBranch, globPattern } = inputData; let { limit } = inputData; @@ -133,7 +144,10 @@ const scanStep = createStep({ id: 'scan-repo', }); - return { files, githubRepo: githubRepoValue, githubBranch }; + const result = { files, githubRepo: githubRepoValue, githubBranch }; + span?.update({ output: result }); + span?.end(); + return result; } if (repoPathValue.length > 0) { @@ -158,10 +172,14 @@ const scanStep = createStep({ id: 'scan-repo', }); - return { files: limitedFiles, repoPath: repoPathValue }; + const result = { files: limitedFiles, repoPath: repoPathValue }; + span?.update({ output: result }); + span?.end(); + return result; } const error = new Error('No repo path provided'); + span?.error({ error, endSpan: true }); await writer?.custom({ type: 'data-tool-progress', data: { @@ -180,6 +198,16 @@ const ingestStep = createStep({ inputSchema: ingestInputSchema, outputSchema: ingestOutputSchema, execute: async ({ inputData, writer, requestContext, mastra }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'ingest-files', + input: { filesCount: inputData.files.length }, + metadata: { + 'workflow.step': 'ingest-files', + }, + requestContext, + mastra, + }); const { files, repoPath, githubRepo, githubBranch } = inputData; let processedFiles = 0; let totalChunks = 0; @@ -298,7 +326,10 @@ const ingestStep = createStep({ id: 'ingest-files', }); - return { processedFiles, totalChunks, errors }; + const output = { processedFiles, totalChunks, errors }; + span?.update({ output }); + span?.end(); + return output; }, }); diff --git a/src/mastra/workflows/research-synthesis-workflow.ts b/src/mastra/workflows/research-synthesis-workflow.ts index cc4df237..d4f829b6 100644 --- a/src/mastra/workflows/research-synthesis-workflow.ts +++ b/src/mastra/workflows/research-synthesis-workflow.ts @@ -1,5 +1,5 @@ import { createStep, createWorkflow } from '@mastra/core/workflows'; -import { SpanStatusCode, trace } from '@opentelemetry/api'; +import { SpanType, getOrCreateSpan } from '@mastra/core/observability'; import { z } from 'zod'; import { logError, logStepEnd, logStepStart } from '../config/logger'; @@ -65,29 +65,54 @@ const initializeResearchStep = createStep({ topic: z.string(), maxSources: z.number(), })), - execute: async ({ inputData, writer }) => { + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'initialize-research', + input: { topicsCount: inputData.topics.length }, + metadata: { + 'workflow.step': 'initialize-research', + }, + requestContext, + mastra, + }); const startTime = Date.now(); logStepStart('initialize-research', { topicsCount: inputData.topics.length }); - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: "in-progress", - message: `Researching topic: ${inputData.topics}...`, - stage: 'initialize-research', - }, - id: 'initialize-research', - }); + try { + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: "in-progress", + message: `Researching topic: ${inputData.topics}...`, + stage: 'initialize-research', + }, + id: 'initialize-research', + }); - const topics = inputData.topics.map(topic => ({ - topic, - maxSources: inputData.maxSourcesPerTopic, - })); + const topics = inputData.topics.map(topic => ({ + topic, + maxSources: inputData.maxSourcesPerTopic, + })); + span?.update({ + output: topics, + metadata: { + responseTimeMs: Date.now() - startTime, + }, + }); + span?.end(); - logStepEnd('initialize-research', { topicsCount: topics.length }, Date.now() - startTime); + logStepEnd('initialize-research', { topicsCount: topics.length }, Date.now() - startTime); - return topics; + return topics; + } catch (error) { + span?.error({ + error: error instanceof Error ? error : new Error(String(error)), + endSpan: true, + }); + throw error; + } }, }); @@ -100,7 +125,18 @@ const researchTopicStep = createStep({ }), outputSchema: topicResearchSchema, retries: 2, - execute: async ({ inputData, mastra, writer }) => { + execute: async ({ inputData, mastra, writer, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'research-topic-item', + input: inputData, + metadata: { + 'workflow.step': 'research-topic-item', + 'topic': inputData.topic, + }, + requestContext, + mastra, + }); const startTime = Date.now(); logStepStart('research-topic-item', { topic: inputData.topic }); @@ -114,14 +150,6 @@ const researchTopicStep = createStep({ id: 'research-topic-item', }); - const tracer = trace.getTracer('research-synthesis'); - const span = tracer.startSpan('research-topic', { - attributes: { - topic: inputData.topic, - maxSources: inputData.maxSources, - }, - }); - try { await writer?.custom({ type: 'data-tool-progress', @@ -194,18 +222,24 @@ const researchTopicStep = createStep({ id: 'research-topic-item', }); - span.setAttribute('findingsCount', result.keyFindings.length); - span.setAttribute('sourcesCount', result.sources.length); - span.setAttribute('confidence', result.confidence); - span.setAttribute('responseTimeMs', Date.now() - startTime); - span.end(); + span?.update({ + output: result, + metadata: { + findingsCount: result.keyFindings.length, + sourcesCount: result.sources.length, + confidence: result.confidence, + responseTimeMs: Date.now() - startTime, + }, + }); + span?.end(); logStepEnd('research-topic-item', { topic: inputData.topic, confidence: result.confidence }, Date.now() - startTime); return result; } catch (error) { - span.recordException(error instanceof Error ? error : new Error(String(error))); - span.setStatus({ code: SpanStatusCode.ERROR }); - span.end(); + span?.error({ + error: error instanceof Error ? error : new Error(String(error)), + endSpan: true, + }); logError('research-topic-item', error, { topic: inputData.topic }); await writer?.custom({ @@ -237,17 +271,19 @@ const synthesizeResearchStep = createStep({ synthesisType: z.string(), }), outputSchema: synthesizedResearchSchema, - execute: async ({ inputData, mastra, writer }) => { - const startTime = Date.now(); - logStepStart('synthesize-research', { topicsCount: inputData.topics.length }); - - const tracer = trace.getTracer('research-synthesis'); - const span = tracer.startSpan('research-synthesis', { - attributes: { - topicsCount: inputData.topics.length, - synthesisType: inputData.synthesisType, + execute: async ({ inputData, mastra, writer, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'synthesize-research', + input: { topicsCount: inputData.topics.length, synthesisType: inputData.synthesisType }, + metadata: { + 'workflow.step': 'synthesize-research', }, + requestContext, + mastra, }); + const startTime = Date.now(); + logStepStart('synthesize-research', { topicsCount: inputData.topics.length }); try { await writer?.custom({ @@ -374,18 +410,24 @@ Provide: }, }; - span.setAttribute('themesCount', synthesis.commonThemes.length); - span.setAttribute('insightsCount', synthesis.keyInsights.length); - span.setAttribute('responseTimeMs', Date.now() - startTime); - span.end(); + span?.update({ + output: result, + metadata: { + themesCount: synthesis.commonThemes.length, + insightsCount: synthesis.keyInsights.length, + responseTimeMs: Date.now() - startTime, + }, + }); + span?.end(); logStepEnd('synthesize-research', { themesCount: synthesis.commonThemes.length }, Date.now() - startTime); return result; } catch (error) { - span.recordException(error instanceof Error ? error : new Error(String(error))); - span.setStatus({ code: SpanStatusCode.ERROR }); - span.end(); + span?.error({ + error: error instanceof Error ? error : new Error(String(error)), + endSpan: true, + }); logError('synthesize-research', error); await writer?.custom({ @@ -408,16 +450,19 @@ const generateResearchReportStep = createStep({ description: 'Generates final research report using reportAgent', inputSchema: synthesizedResearchSchema, outputSchema: reportOutputSchema, - execute: async ({ inputData, mastra, writer }) => { - const startTime = Date.now(); - logStepStart('generate-research-report', { topicsCount: inputData.metadata.topicsCount }); - - const tracer = trace.getTracer('research-synthesis'); - const span = tracer.startSpan('research-report-generation', { - attributes: { - topicsCount: inputData.metadata.topicsCount, + execute: async ({ inputData, mastra, writer, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'generate-research-report', + input: { topicsCount: inputData.metadata.topicsCount }, + metadata: { + 'workflow.step': 'generate-research-report', }, + requestContext, + mastra, }); + const startTime = Date.now(); + logStepStart('generate-research-report', { topicsCount: inputData.metadata.topicsCount }); try { await writer?.custom({ @@ -525,17 +570,23 @@ ${inputData.synthesis.gaps?.map(g => `- ${g}`).join('\n') ?? 'No significant gap }, }; - span.setAttribute('reportLength', report.length); - span.setAttribute('responseTimeMs', Date.now() - startTime); - span.end(); + span?.update({ + output: result, + metadata: { + reportLength: report.length, + responseTimeMs: Date.now() - startTime, + }, + }); + span?.end(); logStepEnd('generate-research-report', { reportLength: report.length }, Date.now() - startTime); return result; } catch (error) { - span.recordException(error instanceof Error ? error : new Error(String(error))); - span.setStatus({ code: SpanStatusCode.ERROR }); - span.end(); + span?.error({ + error: error instanceof Error ? error : new Error(String(error)), + endSpan: true, + }); logError('generate-research-report', error); await writer?.custom({ diff --git a/src/mastra/workflows/safe-refactoring-workflow.ts b/src/mastra/workflows/safe-refactoring-workflow.ts index dcc1444c..f4c61a8c 100644 --- a/src/mastra/workflows/safe-refactoring-workflow.ts +++ b/src/mastra/workflows/safe-refactoring-workflow.ts @@ -1,4 +1,5 @@ import { createStep, createWorkflow } from '@mastra/core/workflows'; +import { SpanType, getOrCreateSpan } from '@mastra/core/observability'; import { z } from 'zod'; import { refactoringAgent } from '../agents/codingAgents'; import { createSandbox, runCommand, writeFile } from '../tools/e2b'; @@ -33,7 +34,18 @@ const generateRefactorStep = createStep({ refactoredCode: z.string(), explanation: z.string(), }), - execute: async ({ inputData, writer }) => { + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'generate-refactor', + input: inputData, + metadata: { + 'workflow.step': 'generate-refactor', + 'file.path': inputData.filePath, + }, + requestContext, + mastra, + }); const startTime = Date.now(); logStepStart('generate-refactor', { filePath: inputData.filePath }); @@ -80,14 +92,28 @@ const generateRefactorStep = createStep({ id: 'generate-refactor', }); - logStepEnd('generate-refactor', {}, Date.now() - startTime); - - return { + const res = { ...inputData, refactoredCode: output.refactoredCode, explanation: output.explanation, }; + + span?.update({ + output: res, + metadata: { + responseTimeMs: Date.now() - startTime, + }, + }); + span?.end(); + + logStepEnd('generate-refactor', {}, Date.now() - startTime); + + return res; } catch (error) { + span?.error({ + error: error instanceof Error ? error : new Error(String(error)), + endSpan: true, + }); logError('generate-refactor', error); throw error; } @@ -107,6 +133,16 @@ const verifyRefactorStep = createStep({ }), outputSchema: refactorOutputSchema, execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'verify-refactor', + input: { filePath: inputData.filePath, language: inputData.language }, + metadata: { + 'workflow.step': 'verify-refactor', + }, + requestContext, + mastra, + }); const startTime = Date.now(); logStepStart('verify-refactor', { filePath: inputData.filePath }); @@ -188,9 +224,7 @@ const verifyRefactorStep = createStep({ id: 'verify-refactor', }); - logStepEnd('verify-refactor', { success: execResult.success }, Date.now() - startTime); - - return { + const res = { refactoredCode: inputData.refactoredCode, explanation: inputData.explanation, verificationResult: { @@ -200,7 +234,24 @@ const verifyRefactorStep = createStep({ }, }; + span?.update({ + output: res, + metadata: { + success: execResult.success, + responseTimeMs: Date.now() - startTime, + }, + }); + span?.end(); + + logStepEnd('verify-refactor', { success: execResult.success }, Date.now() - startTime); + + return res; + } catch (error) { + span?.error({ + error: error instanceof Error ? error : new Error(String(error)), + endSpan: true, + }); logError('verify-refactor', error); // Fail gracefully returning the unverified code return { diff --git a/src/mastra/workflows/spec-generation-workflow.ts b/src/mastra/workflows/spec-generation-workflow.ts index 2dfcf370..57ece0f6 100644 --- a/src/mastra/workflows/spec-generation-workflow.ts +++ b/src/mastra/workflows/spec-generation-workflow.ts @@ -1,5 +1,6 @@ import type { RequestContext } from '@mastra/core/request-context'; import { createStep, createWorkflow } from '@mastra/core/workflows'; +import { SpanType, getOrCreateSpan } from '@mastra/core/observability'; import { z } from 'zod'; export type UserTier = 'free' | 'pro' | 'enterprise'; @@ -51,6 +52,17 @@ const planStep = createStep({ outputSchema: planOutputSchema, execute: async ({ inputData, mastra, requestContext, writer }) => { const { request, context, githubRepo, githubIssue } = inputData; + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'create-plan', + input: inputData, + metadata: { + 'workflow.step': 'create-plan', + }, + requestContext, + mastra, + }); + const startTime = Date.now(); const agent = mastra.getAgent('code-architect'); // Use code-architect for planning const hasGithubRepo = typeof githubRepo === 'string' && githubRepo.length > 0; @@ -122,10 +134,20 @@ const planStep = createStep({ id: 'create-plan', }); - return { + const res = { plan: parsed.plan ?? text, documentsNeeded: parsed.documentsNeeded ?? ['PRD', 'Architecture', 'Tasks'] }; + + span?.update({ + output: res, + metadata: { + responseTimeMs: Date.now() - startTime, + }, + }); + span?.end(); + + return res; } await writer?.custom({ @@ -138,10 +160,20 @@ const planStep = createStep({ id: 'create-plan', }); - return { + const resText = { plan: text, documentsNeeded: ['PRD', 'Architecture', 'Tasks'] }; + + span?.update({ + output: resText, + metadata: { + responseTimeMs: Date.now() - startTime, + }, + }); + span?.end(); + + return resText; } catch (e) { await writer?.custom({ type: 'data-tool-progress', @@ -153,10 +185,21 @@ const planStep = createStep({ id: 'create-plan', }); - return { + const resFail = { plan: result.text, documentsNeeded: ['PRD', 'Architecture', 'Tasks'] }; + + span?.update({ + output: resFail, + metadata: { + responseTimeMs: Date.now() - startTime, + error: e instanceof Error ? e.message : 'Unknown error', + }, + }); + span?.end(); + + return resFail; } } catch (e) { await writer?.custom({ @@ -169,6 +212,11 @@ const planStep = createStep({ id: 'create-plan', }); + span?.error({ + error: e instanceof Error ? e : new Error(String(e)), + endSpan: true, + }); + throw e; } }, @@ -187,8 +235,19 @@ const prdStep = createStep({ request: z.string().optional(), }), outputSchema: prdOutputSchema, - execute: async ({ inputData, mastra, writer }) => { + execute: async ({ inputData, mastra, writer, requestContext }) => { const { request, plan, documentsNeeded } = inputData; + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'generate-prd', + input: { planLength: plan.length, documentsNeeded }, + metadata: { + 'workflow.step': 'generate-prd', + }, + requestContext, + mastra, + }); + const startTime = Date.now(); const agent = mastra.getAgent('code-architect'); await writer?.custom({ @@ -232,7 +291,14 @@ const prdStep = createStep({ }, id: 'generate-prd', }); - return { prd: '' }; + const resSkipped = { prd: '' }; + span?.update({ + output: resSkipped, + metadata: { skipped: true }, + }); + span?.end(); + + return resSkipped; } try { @@ -250,7 +316,14 @@ const prdStep = createStep({ id: 'generate-prd', }); - return { prd: result?.text || '# PRD\n\n(Generated PRD content)' }; + const res = { prd: result?.text || '# PRD\n\n(Generated PRD content)' }; + span?.update({ + output: res, + metadata: { responseTimeMs: Date.now() - startTime }, + }); + span?.end(); + + return res; } catch (e) { await writer?.custom({ type: 'data-tool-progress', @@ -262,6 +335,11 @@ const prdStep = createStep({ id: 'generate-prd', }); + span?.error({ + error: e instanceof Error ? e : new Error(String(e)), + endSpan: true, + }); + throw e; } }, @@ -273,8 +351,19 @@ const archStep = createStep({ id: 'generate-architecture', inputSchema: z.object({ prd: z.string() }), outputSchema: archOutputSchema, - execute: async ({ inputData, mastra, writer }) => { + execute: async ({ inputData, mastra, writer, requestContext }) => { const { prd } = inputData; + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'generate-architecture', + input: { prdLength: prd.length }, + metadata: { + 'workflow.step': 'generate-architecture', + }, + requestContext, + mastra, + }); + const startTime = Date.now(); const agent = mastra.getAgent('code-architect'); await writer?.custom({ @@ -320,7 +409,14 @@ const archStep = createStep({ id: 'generate-architecture', }); - return { architecture: result?.text || '# Architecture\n\n(Generated Architecture content)', prd: inputData.prd }; + const res = { architecture: result?.text || '# Architecture\n\n(Generated Architecture content)', prd: inputData.prd }; + span?.update({ + output: res, + metadata: { responseTimeMs: Date.now() - startTime }, + }); + span?.end(); + + return res; } catch (e) { await writer?.custom({ type: 'data-tool-progress', @@ -332,6 +428,11 @@ const archStep = createStep({ id: 'generate-architecture', }); + span?.error({ + error: e instanceof Error ? e : new Error(String(e)), + endSpan: true, + }); + throw e; } }, @@ -343,8 +444,19 @@ const tasksStep = createStep({ id: 'generate-tasks', inputSchema: z.object({ architecture: z.string(), prd: z.string() }), outputSchema: tasksOutputSchema, - execute: async ({ inputData, mastra, writer }) => { + execute: async ({ inputData, mastra, writer, requestContext }) => { const { architecture, prd } = inputData; + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'generate-tasks', + input: { architectureLength: architecture.length, prdLength: prd.length }, + metadata: { + 'workflow.step': 'generate-tasks', + }, + requestContext, + mastra, + }); + const startTime = Date.now(); const agent = mastra.getAgent('code-architect'); // Using code-architect for task breakdown as well await writer?.custom({ @@ -391,7 +503,14 @@ const tasksStep = createStep({ id: 'generate-tasks', }); - return { tasks: result?.text || '# Tasks\n\n- [ ] Task 1' }; + const res = { tasks: result?.text || '# Tasks\n\n- [ ] Task 1' }; + span?.update({ + output: res, + metadata: { responseTimeMs: Date.now() - startTime }, + }); + span?.end(); + + return res; } catch (e) { await writer?.custom({ type: 'data-tool-progress', @@ -403,6 +522,11 @@ const tasksStep = createStep({ id: 'generate-tasks', }); + span?.error({ + error: e instanceof Error ? e : new Error(String(e)), + endSpan: true, + }); + throw e; } }, diff --git a/src/mastra/workflows/stock-analysis-workflow.ts b/src/mastra/workflows/stock-analysis-workflow.ts index 919c07b8..df445320 100644 --- a/src/mastra/workflows/stock-analysis-workflow.ts +++ b/src/mastra/workflows/stock-analysis-workflow.ts @@ -1,5 +1,5 @@ import { createStep, createWorkflow } from '@mastra/core/workflows' -import { SpanType } from '@mastra/core/observability' +import { SpanType, getOrCreateSpan } from '@mastra/core/observability' import { z } from 'zod' import { logError, logStepEnd, logStepStart } from '../config/logger' @@ -73,9 +73,6 @@ const reportDataSchema = z.object({ generatedAt: z.string(), }) - - - const fetchStockDataStep = createStep({ id: 'fetch-stock-data', description: 'Fetches real-time stock data from Polygon.io', @@ -87,24 +84,20 @@ const fetchStockDataStep = createStep({ }), outputSchema: stockDataSchema, retries: 3, - execute: async (params) => { - const { inputData, writer } = params as FetchStockDataParams - const context = (params as FetchStockDataParams).context - const startTime = Date.now() - logStepStart('fetch-stock-data', { symbol: inputData.symbol }) - - const tracingContext = context?.tracingContext - const span = tracingContext?.currentSpan?.createChildSpan({ + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ type: SpanType.WORKFLOW_STEP, - name: 'polygon-snapshot-fetch', + name: 'fetch-stock-data', input: { symbol: inputData.symbol }, metadata: { 'workflow.step': 'fetch-stock-data', 'stock.symbol': inputData.symbol, - 'api.service': 'polygon', - 'api.endpoint': '/v2/snapshot', }, + requestContext, + mastra, }) + const startTime = Date.now() + logStepStart('fetch-stock-data', { symbol: inputData.symbol }) try { await writer?.custom({ @@ -196,21 +189,20 @@ const getCompanyNewsStep = createStep({ newsData: newsDataSchema, }), retries: 3, - execute: async ({ inputData, writer, context }) => { - const startTime = Date.now() - logStepStart('get-company-news', { symbol: inputData.symbol }) - - const tracingContext = context?.tracingContext - const span = tracingContext?.currentSpan?.createChildSpan({ + execute: async ({ inputData, writer, requestContext, mastra }) => { + const span = getOrCreateSpan({ type: SpanType.WORKFLOW_STEP, - name: 'finnhub-news-fetch', + name: 'get-company-news', input: { symbol: inputData.symbol }, metadata: { - symbol: inputData.symbol, - service: 'finnhub', - endpoint: '/company-news', + 'workflow.step': 'get-company-news', + 'stock.symbol': inputData.symbol, }, + requestContext, + mastra, }) + const startTime = Date.now() + logStepStart('get-company-news', { symbol: inputData.symbol }) try { await writer?.custom({ @@ -353,20 +345,20 @@ const runAnalysisStep = createStep({ newsData: newsDataSchema, }), outputSchema: analysisDataSchema, - execute: async ({ inputData, mastra, writer, context }) => { - const startTime = Date.now() - logStepStart('run-analysis', { symbol: inputData.stockData.symbol }) - - const tracingContext = context?.tracingContext - const span = tracingContext?.currentSpan?.createChildSpan({ + execute: async ({ inputData, mastra, writer, requestContext }) => { + const span = getOrCreateSpan({ type: SpanType.WORKFLOW_STEP, - name: 'stock-analysis-agent-call', + name: 'run-analysis', input: { symbol: inputData.stockData.symbol }, metadata: { - symbol: inputData.stockData.symbol, - agent: 'stockAnalysisAgent', + 'workflow.step': 'run-analysis', + 'stock.symbol': inputData.stockData.symbol, }, + requestContext, + mastra, }) + const startTime = Date.now() + logStepStart('run-analysis', { symbol: inputData.stockData.symbol }) try { await writer?.custom({ @@ -512,20 +504,20 @@ const generateReportStep = createStep({ description: 'Generates final analysis report using reportAgent', inputSchema: analysisDataSchema, outputSchema: reportDataSchema, - execute: async ({ inputData, mastra, writer, context }) => { - const startTime = Date.now() - logStepStart('generate-report', { symbol: inputData.symbol }) - - const tracingContext = context?.tracingContext - const span = tracingContext?.currentSpan?.createChildSpan({ + execute: async ({ inputData, mastra, writer, requestContext }) => { + const span = getOrCreateSpan({ type: SpanType.WORKFLOW_STEP, - name: 'report-agent-call', + name: 'generate-report', input: { symbol: inputData.symbol }, metadata: { - symbol: inputData.symbol, - agent: 'reportAgent', + 'workflow.step': 'generate-report', + 'stock.symbol': inputData.symbol, }, + requestContext, + mastra, }) + const startTime = Date.now() + logStepStart('generate-report', { symbol: inputData.symbol }) try { await writer?.custom({ diff --git a/src/mastra/workflows/telephone-game.ts b/src/mastra/workflows/telephone-game.ts index 5e85fe5c..c948e1d8 100644 --- a/src/mastra/workflows/telephone-game.ts +++ b/src/mastra/workflows/telephone-game.ts @@ -1,6 +1,7 @@ import { Agent } from '@mastra/core/agent'; import { createStep, createWorkflow } from '@mastra/core/workflows'; import { z } from 'zod'; +import { SpanType, getOrCreateSpan } from '@mastra/core/observability'; import { googleAIFlashLite, pgMemory } from '../config'; const telephone = new Agent({ @@ -23,30 +24,55 @@ const stepA1 = createStep({ outputSchema: z.object({ message: z.string(), }), - execute: async ({ inputData, writer }) => { - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `Starting message: ${inputData.message}`, - stage: 'stepA1', + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'stepA1', + input: inputData, + metadata: { + 'workflow.step': 'stepA1', }, - id: 'stepA1', + requestContext, + mastra, }); + try { + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `Starting message: ${inputData.message}`, + stage: 'stepA1', + }, + id: 'stepA1', + }); - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: `Message received: ${inputData.message}`, - stage: 'stepA1', - }, - id: 'stepA1', - }); + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `Message received: ${inputData.message}`, + stage: 'stepA1', + }, + id: 'stepA1', + }); + + const result = { + message: inputData.message, + }; + + span?.update({ + output: result, + }); + span?.end(); - return { - message: inputData.message, - }; + return result; + } catch (error) { + span?.error({ + error: error instanceof Error ? error : new Error(String(error)), + endSpan: true, + }); + throw error; + } }, }); @@ -59,30 +85,55 @@ const stepA2 = createStep({ outputSchema: z.object({ message: z.string(), }), - execute: async ({ inputData, writer }) => { - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `Passing message through: ${inputData.message}`, - stage: 'stepA2', + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'stepA2', + input: inputData, + metadata: { + 'workflow.step': 'stepA2', }, - id: 'stepA2', + requestContext, + mastra, }); + try { + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `Passing message through: ${inputData.message}`, + stage: 'stepA2', + }, + id: 'stepA2', + }); - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: `Message passed through: ${inputData.message}`, - stage: 'stepA2', - }, - id: 'stepA2', - }); + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `Message passed through: ${inputData.message}`, + stage: 'stepA2', + }, + id: 'stepA2', + }); + + const result = { + message: inputData.message, + }; - return { - message: inputData.message, - }; + span?.update({ + output: result, + }); + span?.end(); + + return result; + } catch (error) { + span?.error({ + error: error instanceof Error ? error : new Error(String(error)), + endSpan: true, + }); + throw error; + } }, }); @@ -95,30 +146,55 @@ const stepB2 = createStep({ outputSchema: z.object({ message: z.string(), }), - execute: async ({ inputData, writer }) => { - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `Checking message: ${inputData.message}`, - stage: 'stepB2', + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'stepB2', + input: inputData, + metadata: { + 'workflow.step': 'stepB2', }, - id: 'stepB2', + requestContext, + mastra, }); + try { + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `Checking message: ${inputData.message}`, + stage: 'stepB2', + }, + id: 'stepB2', + }); - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: `Message checked: ${inputData.message}`, - stage: 'stepB2', - }, - id: 'stepB2', - }); + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `Message checked: ${inputData.message}`, + stage: 'stepB2', + }, + id: 'stepB2', + }); + + const result = { + message: inputData.message, + }; - return { - message: inputData.message, - }; + span?.update({ + output: result, + }); + span?.end(); + + return result; + } catch (error) { + span?.error({ + error: error instanceof Error ? error : new Error(String(error)), + endSpan: true, + }); + throw error; + } }, }); @@ -131,37 +207,62 @@ const stepC2 = createStep({ outputSchema: z.object({ message: z.string(), }), - execute: async ({ inputData, writer }) => { - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `Modifying message: ${inputData.message}`, - stage: 'stepC2', + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'stepC2', + input: inputData, + metadata: { + 'workflow.step': 'stepC2', }, - id: 'stepC2', + requestContext, + mastra, }); + try { + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `Modifying message: ${inputData.message}`, + stage: 'stepC2', + }, + id: 'stepC2', + }); - const result = await telephone.generate(` + const result = await telephone.generate(` You are playing a game of telephone. Here is the message the previous person sent ${inputData.message}. But you want to change the message. Only return the message `); - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: `Message modified: ${result.text}`, - stage: 'stepC2', - }, - id: 'stepC2', - }); + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `Message modified: ${result.text}`, + stage: 'stepC2', + }, + id: 'stepC2', + }); - return { + const output = { message: result.text, }; + + span?.update({ + output, + }); + span?.end(); + + return output; + } catch (error) { + span?.error({ + error: error instanceof Error ? error : new Error(String(error)), + endSpan: true, + }); + throw error; + } }, }); @@ -174,28 +275,51 @@ const stepD2 = createStep({ outputSchema: z.object({ message: z.string(), }), - execute: async ({ inputData, writer }) => { - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `Finalizing message: ${inputData.message}`, - stage: 'stepD2', + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'stepD2', + input: inputData, + metadata: { + 'workflow.step': 'stepD2', }, - id: 'stepD2', + requestContext, + mastra, }); + try { + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `Finalizing message: ${inputData.message}`, + stage: 'stepD2', + }, + id: 'stepD2', + }); - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'done', - message: `Message finalized: ${inputData.message}`, - stage: 'stepD2', - }, - id: 'stepD2', - }); + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'done', + message: `Message finalized: ${inputData.message}`, + stage: 'stepD2', + }, + id: 'stepD2', + }); + + span?.update({ + output: inputData, + }); + span?.end(); - return inputData; + return inputData; + } catch (error) { + span?.error({ + error: error instanceof Error ? error : new Error(String(error)), + endSpan: true, + }); + throw error; + } }, }); diff --git a/src/mastra/workflows/test-generation-workflow.ts b/src/mastra/workflows/test-generation-workflow.ts index 2b6cdadc..89da3f88 100644 --- a/src/mastra/workflows/test-generation-workflow.ts +++ b/src/mastra/workflows/test-generation-workflow.ts @@ -1,5 +1,6 @@ import { createStep, createWorkflow } from '@mastra/core/workflows'; import { z } from 'zod'; +import { SpanType, getOrCreateSpan } from '@mastra/core/observability'; import { testEngineerAgent } from '../agents/codingAgents'; import { createSandbox, runCommand, writeFile } from '../tools/e2b'; import { logStepEnd, logStepStart, logError } from '../config/logger'; @@ -31,23 +32,35 @@ const generateTestsStep = createStep({ testCode: z.string(), testFilePath: z.string(), }), - execute: async ({ inputData, writer }) => { + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'generate-tests', + input: inputData, + metadata: { + 'workflow.step': 'generate-tests', + 'file.path': inputData.filePath, + }, + requestContext, + mastra, + }); const startTime = Date.now(); logStepStart('generate-tests', { filePath: inputData.filePath }); - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `Generating tests for ${inputData.filePath}...`, - stage: 'generate-tests', - }, - id: 'generate-tests', - }); + try { + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `Generating tests for ${inputData.filePath}...`, + stage: 'generate-tests', + }, + id: 'generate-tests', + }); - const testFilePath = inputData.filePath.replace(/\.(ts|js|py)$/, '.test.$1'); // Simple heuristic + const testFilePath = inputData.filePath.replace(/\.(ts|js|py)$/, '.test.$1'); // Simple heuristic - const prompt = `Generate unit tests for the following code. + const prompt = `Generate unit tests for the following code. File Path: ${inputData.filePath} Target Test File Path: ${testFilePath} @@ -64,11 +77,10 @@ const generateTestsStep = createStep({ - "testFilePath": The recommended path for the test file. `; - try { const result = await testEngineerAgent.generate(prompt); const parsed = JSON.parse(result.text); - const output = parsed ?? { + const outputData = parsed ?? { testCode: '', testFilePath }; @@ -85,13 +97,24 @@ const generateTestsStep = createStep({ logStepEnd('generate-tests', {}, Date.now() - startTime); - return { + const finalResult = { ...inputData, - testCode: output.testCode, - testFilePath: output.testFilePath ?? testFilePath, + testCode: outputData.testCode, + testFilePath: outputData.testFilePath ?? testFilePath, }; + + span?.update({ + output: finalResult, + }); + span?.end(); + + return finalResult; } catch (error) { logError('generate-tests', error); + span?.error({ + error: error instanceof Error ? error : new Error(String(error)), + endSpan: true, + }); throw error; } }, @@ -109,20 +132,31 @@ const runTestsStep = createStep({ }), outputSchema: testGenOutputSchema, execute: async ({ inputData, writer, mastra, requestContext }) => { - const startTime = Date.now(); - logStepStart('run-tests', { testFilePath: inputData.testFilePath }); - - await writer?.custom({ - type: 'data-tool-progress', - data: { - status: 'in-progress', - message: `Running tests in E2B sandbox...`, - stage: 'run-tests', + const span = getOrCreateSpan({ + type: SpanType.WORKFLOW_STEP, + name: 'run-tests', + input: inputData, + metadata: { + 'workflow.step': 'run-tests', + 'test.file.path': inputData.testFilePath, }, - id: 'run-tests', + requestContext, + mastra, }); + const startTime = Date.now(); + logStepStart('run-tests', { testFilePath: inputData.testFilePath }); try { + await writer?.custom({ + type: 'data-tool-progress', + data: { + status: 'in-progress', + message: `Running tests in E2B sandbox...`, + stage: 'run-tests', + }, + id: 'run-tests', + }); + // 1. Create Sandbox const sandbox = await createSandbox.execute({ timeoutMS: 300_000, @@ -177,7 +211,7 @@ const runTestsStep = createStep({ logStepEnd('run-tests', { success: execution.success }, Date.now() - startTime); - return { + const finalResult = { testCode: inputData.testCode, testFilePath: inputData.testFilePath, verificationResult: { @@ -187,9 +221,19 @@ const runTestsStep = createStep({ }, }; + span?.update({ + output: finalResult, + metadata: { + 'test.success': execution.success, + }, + }); + span?.end(); + + return finalResult; + } catch (error) { logError('run-tests', error); - return { + const errorResult = { testCode: inputData.testCode, testFilePath: inputData.testFilePath, verificationResult: { @@ -198,6 +242,13 @@ const runTestsStep = createStep({ stderr: error instanceof Error ? error.message : String(error), }, }; + + span?.error({ + error: error instanceof Error ? error : new Error(String(error)), + endSpan: true, + }); + + return errorResult; } }, }); diff --git a/src/mastra/workflows/weather-workflow.ts b/src/mastra/workflows/weather-workflow.ts index 13d5525b..c387e811 100644 --- a/src/mastra/workflows/weather-workflow.ts +++ b/src/mastra/workflows/weather-workflow.ts @@ -1,7 +1,7 @@ import { createStep, createWorkflow } from '@mastra/core/workflows' import { z } from 'zod' import { log } from '../config/logger' -import { SpanType } from '@mastra/core/observability' +import { SpanType, getOrCreateSpan } from '@mastra/core/observability' const forecastSchema = z.object({ date: z.string(), @@ -41,9 +41,8 @@ const fetchWeather = createStep({ city: z.string().describe('The city to get weather for'), }), outputSchema: forecastSchema, - execute: async (inputData: any, context: any) => { - const tracingContext = context?.tracingContext - const span = tracingContext?.currentSpan?.createChildSpan({ + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ type: SpanType.WORKFLOW_STEP, name: 'fetch-weather', input: { city: inputData.city }, @@ -51,15 +50,20 @@ const fetchWeather = createStep({ 'workflow.step': 'fetch-weather', 'weather.city': inputData.city, }, + requestContext, + mastra, }) - try { + if (!inputData?.city) { + span?.error({ error: new Error('City not provided in input data'), endSpan: true }) throw new Error('City not provided in input data') } + log.info(`Fetching weather for ${inputData.city}`) + // Emit workflow progress start - await context?.writer?.custom({ + await writer?.custom({ type: 'data-tool-progress', data: { status: 'in-progress', @@ -117,7 +121,7 @@ const fetchWeather = createStep({ } // Emit workflow progress completion - await context?.writer?.custom({ + await writer?.custom({ type: 'data-tool-progress', data: { status: 'done', @@ -138,8 +142,7 @@ const fetchWeather = createStep({ return forecast } catch (error) { span?.error({ - error: - error instanceof Error ? error : new Error(String(error)), + error: error instanceof Error ? error : new Error(String(error)), endSpan: true, }) throw error @@ -154,9 +157,8 @@ const planActivities = createStep({ outputSchema: z.object({ activities: z.string(), }), - execute: async (inputData: any, context: any) => { - const tracingContext = context?.tracingContext - const span = tracingContext?.currentSpan?.createChildSpan({ + execute: async ({ inputData, writer, mastra, requestContext }) => { + const span = getOrCreateSpan({ type: SpanType.WORKFLOW_STEP, name: 'plan-activities', input: { @@ -168,17 +170,19 @@ const planActivities = createStep({ 'weather.location': inputData.location, 'weather.condition': inputData.condition, }, + requestContext, + mastra, }) - try { const forecast = inputData if (!forecast?.date) { + span?.error({ error: new Error('Forecast data not found'), endSpan: true }) throw new Error('Forecast data not found') } // Emit workflow progress start - await context?.writer?.custom({ + await writer?.custom({ type: 'data-tool-progress', data: { status: 'in-progress', @@ -188,8 +192,10 @@ const planActivities = createStep({ id: 'plan-activities', }) - const agent = context?.mastra?.getAgent('weatherAgent') + const mastraInst = mastra ?? (globalThis as unknown as Record<string, unknown>).mastra + const agent = mastraInst?.getAgent?.('weatherAgent') if (agent === undefined || agent === null) { + span?.error({ error: new Error('Weather agent not found'), endSpan: true }) throw new Error('Weather agent not found') } @@ -249,7 +255,7 @@ const planActivities = createStep({ textStream?: ReadableStream<unknown> } - if (context?.writer !== undefined && context?.writer !== null) { + if (writer !== undefined && writer !== null) { if ( fullStream !== undefined && typeof (fullStream as unknown as { pipeTo?: unknown }) @@ -257,7 +263,7 @@ const planActivities = createStep({ ) { await ( fullStream as unknown as ReadableStream<unknown> - ).pipeTo(context?.writer as unknown as WritableStream) + ).pipeTo(writer as unknown as WritableStream) } else if ( textStream !== undefined && typeof (textStream as unknown as { pipeTo?: unknown }) @@ -265,7 +271,7 @@ const planActivities = createStep({ ) { await ( textStream as unknown as ReadableStream<unknown> - ).pipeTo(context?.writer as unknown as WritableStream) + ).pipeTo(writer as unknown as WritableStream) } } @@ -276,7 +282,7 @@ const planActivities = createStep({ } // Emit workflow progress completion - await context?.writer?.custom({ + await writer?.custom({ type: 'data-tool-progress', data: { status: 'done', @@ -293,11 +299,11 @@ const planActivities = createStep({ }, }) span?.end() + return result } catch (error) { span?.error({ - error: - error instanceof Error ? error : new Error(String(error)), + error: error instanceof Error ? error : new Error(String(error)), endSpan: true, }) throw error