refactor: migrate agent system from XML tool calling to OpenAI SDK with native function calling

- Replace custom XML-based tool parsing (XMLToolExecutor) with OpenAI SDK's
  native tool_calls via `openai` npm package (dangerouslyAllowBrowser)
- Consolidate 4 LLM providers (OpenAI, Claude, Gemini, ClaudeOpenRouter)
  into a single OpenAI SDK-based LLMProvider compatible with any
  OpenAI-style API (OpenAI, OpenRouter, Ollama, vLLM)
- Move agentic tool execution loop from ChatBox into AgentCore
- Update Message type to support tool roles, tool_calls, and tool_call_id
- Update system prompt to remove XML formatting instructions (~45% smaller)
- Remove AttemptCompletionTool (replaced by stop_reason detection),
  ThinkTool, and ThinkingTool
- Polish tool descriptions for OpenAI function calling schema compliance
- Normalize base URLs by stripping /chat/completions suffix
This commit is contained in:
Xiaohan-Tian
2026-04-05 18:59:25 -07:00
parent 597fb9a292
commit 2cd976ff96
32 changed files with 697 additions and 2310 deletions
+2 -2
View File
@@ -51,7 +51,7 @@ This project investigates how AI-human collaboration can enhance creative music-
- In **Settings ⚙️ → General → LLM Provider**, select **OpenAI Compatible**. - In **Settings ⚙️ → General → LLM Provider**, select **OpenAI Compatible**.
- In **OpenAI Compatible Server → Key**, paste your key. (Note: on nonlocalhost, your key isn't persisted by default for security; you can enable "Persist API Keys on Non-Localhost" in Settings to persist them, though this may increase XSS risk.) - In **OpenAI Compatible Server → Key**, paste your key. (Note: on nonlocalhost, your key isn't persisted by default for security; you can enable "Persist API Keys on Non-Localhost" in Settings to persist them, though this may increase XSS risk.)
- In **OpenAI Compatible Server → Model**, enter `qwen/qwen3-30b-a3b:free` or `qwen/qwen3-235b-a22b:free`. (Note: these are free models [qwen3-30b-a3b](https://openrouter.ai/qwen/qwen3-30b-a3b:free) and [qwen3-235b-a22b](https://openrouter.ai/qwen/qwen3-235b-a22b:free); nonfree models may require billing; some model providers may retain your data, check their privacy policies; this project is not affiliated with OpenRouter or any model provider.) - In **OpenAI Compatible Server → Model**, enter `qwen/qwen3-30b-a3b:free` or `qwen/qwen3-235b-a22b:free`. (Note: these are free models [qwen3-30b-a3b](https://openrouter.ai/qwen/qwen3-30b-a3b:free) and [qwen3-235b-a22b](https://openrouter.ai/qwen/qwen3-235b-a22b:free); nonfree models may require billing; some model providers may retain your data, check their privacy policies; this project is not affiliated with OpenRouter or any model provider.)
- In **OpenAI Compatible Server → Base URL**, enter `https://openrouter.ai/api/v1/chat/completions`. - In **OpenAI Compatible Server → Base URL**, enter `https://openrouter.ai/api/v1`.
(Alternatively, you can use the official OpenAI API, other OpenAIcompatible services, or your own hosted LLM server. e.g., Ollama, vLLM) (Alternatively, you can use the official OpenAI API, other OpenAIcompatible services, or your own hosted LLM server. e.g., Ollama, vLLM)
@@ -219,7 +219,7 @@ OpenRouter is a platform that provides unified access to a wide range of languag
- `Anthropic: claude-4-sonnet` (`anthropic/claude-sonnet-4`: [Link](https://openrouter.ai/anthropic/claude-sonnet-4)) - `Anthropic: claude-4-sonnet` (`anthropic/claude-sonnet-4`: [Link](https://openrouter.ai/anthropic/claude-sonnet-4))
- `Qwen: qwen3-30b-a3b` (FREE MODEL: `qwen/qwen3-30b-a3b:free`: [Link](https://openrouter.ai/qwen/qwen3-30b-a3b:free)) - `Qwen: qwen3-30b-a3b` (FREE MODEL: `qwen/qwen3-30b-a3b:free`: [Link](https://openrouter.ai/qwen/qwen3-30b-a3b:free))
- `Qwen: qwen3-235b-a22b` (FREE MODEL: `qwen/qwen3-235b-a22b:free`: [Link](https://openrouter.ai/qwen/qwen3-235b-a22b:free)) - `Qwen: qwen3-235b-a22b` (FREE MODEL: `qwen/qwen3-235b-a22b:free`: [Link](https://openrouter.ai/qwen/qwen3-235b-a22b:free))
6. Input the base URL `https://openrouter.ai/api/v1/chat/completions` **OpenAI Compatible Server → Base URL**. 6. Input the base URL `https://openrouter.ai/api/v1` **OpenAI Compatible Server → Base URL**.
### About the agent and LLM providers ### About the agent and LLM providers
+25 -3
View File
@@ -1,15 +1,16 @@
{ {
"name": "K.G.Studio", "name": "K.G.Studio",
"version": "0.5.4-build.20250830", "version": "0.8.0-build.20260123",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "K.G.Studio", "name": "K.G.Studio",
"version": "0.5.4-build.20250830", "version": "0.8.0-build.20260123",
"dependencies": { "dependencies": {
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"idb": "^8.0.3", "idb": "^8.0.3",
"openai": "^6.33.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
@@ -10879,6 +10880,27 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/openai": {
"version": "6.33.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.33.0.tgz",
"integrity": "sha512-xAYN1W3YsDXJWA5F277135YfkEk6H7D3D6vWwRhJ3OEkzRgcyK8z/P5P9Gyi/wB4N8kK9kM5ZjprfvyHagKmpw==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -14064,7 +14086,7 @@
"version": "8.18.3", "version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
+1
View File
@@ -16,6 +16,7 @@
"dependencies": { "dependencies": {
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"idb": "^8.0.3", "idb": "^8.0.3",
"openai": "^6.33.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
@@ -4,6 +4,6 @@ You selected OpenAI Compatible as your LLM Provider, but no Base URL is configur
How to fix: How to fix:
- Go to **Settings ⚙️ → General → OpenAI Compatible Server** - Go to **Settings ⚙️ → General → OpenAI Compatible Server**
- Enter the **Base URL** for your provider (e.g., `https://openrouter.ai/api/v1/chat/completions` for OpenRouter, `http://localhost:11434/api/chat` for Ollama, etc.) - Enter the **Base URL** for your provider (e.g., `https://openrouter.ai/api/v1` for OpenRouter, `http://localhost:11434/api/chat` for Ollama, etc.)
After updating your settings, try your request again. After updating your settings, try your request again.
+1 -1
View File
@@ -35,7 +35,7 @@ OpenRouter is a platform that provides unified access to a wide range of languag
- `Anthropic: claude-4-sonnet` (`anthropic/claude-sonnet-4`: [Link](https://openrouter.ai/anthropic/claude-sonnet-4)) - `Anthropic: claude-4-sonnet` (`anthropic/claude-sonnet-4`: [Link](https://openrouter.ai/anthropic/claude-sonnet-4))
- `Qwen: qwen3-30b-a3b` (FREE MODEL: `qwen/qwen3-30b-a3b:free`: [Link](https://openrouter.ai/qwen/qwen3-30b-a3b:free)) - `Qwen: qwen3-30b-a3b` (FREE MODEL: `qwen/qwen3-30b-a3b:free`: [Link](https://openrouter.ai/qwen/qwen3-30b-a3b:free))
- `Qwen: qwen3-235b-a22b` (FREE MODEL: `qwen/qwen3-235b-a22b:free`: [Link](https://openrouter.ai/qwen/qwen3-235b-a22b:free)) - `Qwen: qwen3-235b-a22b` (FREE MODEL: `qwen/qwen3-235b-a22b:free`: [Link](https://openrouter.ai/qwen/qwen3-235b-a22b:free))
6. Input the base URL `https://openrouter.ai/api/v1/chat/completions` of the OpenAI Compatible Server in **OpenAI Compatible Server → Base URL**. 6. Input the base URL `https://openrouter.ai/api/v1` of the OpenAI Compatible Server in **OpenAI Compatible Server → Base URL**.
### Basic DAW Operations ### Basic DAW Operations
+1 -1
View File
@@ -7,7 +7,7 @@ Welcome to **K.G.Studio Musician Assistant** — your inbrowser AI partner fo
- In **Settings ⚙️ → General → LLM Provider**, select **OpenAI Compatible**. - In **Settings ⚙️ → General → LLM Provider**, select **OpenAI Compatible**.
- In **OpenAI Compatible Server → Key**, paste your key. Note: on nonlocalhost, your key isnt persisted; youll reenter it after refresh. Keep it safe. - In **OpenAI Compatible Server → Key**, paste your key. Note: on nonlocalhost, your key isnt persisted; youll reenter it after refresh. Keep it safe.
- In **OpenAI Compatible Server → Model**, enter `qwen/qwen3-30b-a3b:free`. Note: nonfree models may require billing. - In **OpenAI Compatible Server → Model**, enter `qwen/qwen3-30b-a3b:free`. Note: nonfree models may require billing.
- In **OpenAI Compatible Server → Base URL**, enter `https://openrouter.ai/api/v1/chat/completions`. - In **OpenAI Compatible Server → Base URL**, enter `https://openrouter.ai/api/v1`.
*(Alternatively, you can use the official OpenAI API, or any OpenAIcompatible service)* *(Alternatively, you can use the official OpenAI API, or any OpenAIcompatible service)*
+1 -1
View File
@@ -18,7 +18,7 @@
}, },
"claude_openrouter": { "claude_openrouter": {
"api_key": "", "api_key": "",
"base_url": "https://openrouter.ai/api/v1/chat/completions", "base_url": "https://openrouter.ai/api/v1",
"model": "anthropic/claude-sonnet-4.5" "model": "anthropic/claude-sonnet-4.5"
}, },
"openai_compatible": { "openai_compatible": {
+24 -242
View File
@@ -14,177 +14,28 @@ You should leverage this extensive musical training to provide creative, musical
TOOL USE TOOL USE
You have access to a set of tools that are executed upon the user's approval. You can use one tool per message, and will receive the result of that tool use in the user's response. You use tools step-by-step to accomplish a given task, with each tool use informed by the result of the previous tool use. Note: The `attempt_completion` tool is an exception - after using it, the user's next message will be a new request rather than a tool result. You have access to tools for reading and editing music. Tools are invoked via native function calling — you call them by name with structured parameters, and their results are returned to you automatically. Use tools step-by-step to accomplish a given task, with each tool call informed by the result of the previous one. When you have finished the task, respond with a final text message summarizing what you did.
# Tool Use Formatting
Tool use is formatted using XML-style tags. The tool name is enclosed in opening and closing tags, and each parameter is similarly enclosed within its own set of tags. Here's the structure:
<tool_name>
<parameter1_name>value1</parameter1_name>
<parameter2_name>value2</parameter2_name>
...
</tool_name>
For example:
<read_music>
<start_beat>0</start_beat>
<length>8</length>
</read_music>
Always adhere to this format for the tool use to ensure proper parsing and execution.
# Tools # Tools
## read_music ## read_music
Description: Read a given part of the music. The output is the selected part of the music in ABC notation. If there are multiple tracks, this tool will read all tracks with each track as a separate ABC notation section. Read existing musical content from the project. The output is in ABC notation. If there are multiple tracks, all tracks are returned as separate ABC notation sections, with track names (e.g., "Melody", "Bass", "Chords") providing arrangement context.
Parameters:
- start_beat: (required) The start beat of the region to read.
- length: (optional) The length of the region to read. If you want to read the entire music, you can omit this parameter.
Usage:
<read_music>
<start_beat>start from beat</start_beat>
<length>length of the region to read (optional)</length>
</read_music>
## remove_notes ## remove_notes
Description: Remove notes from the given range in the current region. Remove notes from a given beat range in the current region.
Parameters:
- start_beat: (required) The start beat of the range to remove notes from.
- end_beat: (required) The end beat of the range to remove notes from.
Usage:
<remove_notes>
<start_beat>start from beat</start_beat>
<end_beat>end at beat</end_beat>
</remove_notes>
## add_notes ## add_notes
Description: Add notes to the current region. Add notes to the current region. Pitches use scientific pitch notation with support for sharps and flats (e.g., `C4`, `F#3`, `Bb2`). **Important**: the `start_beat` parameter is always the **absolute** beat position in the project timeline — not relative to the current region's start. For example, to place a note at beat 6, set `start_beat` to 6 regardless of where the current region begins.
Parameters:
- notes: (required) The notes to add. Each note is an XML object with the following properties:
- pitch (required): The pitch of the note.
- start_beat (required): The start beat of the note. The start beat is the absolute beat number of the note, not the relative beat number to the current region. for example, if you want to add a note at beat 6, regardless the current region starts from beat 0 or beat 4 or other beat, you should set the start_beat to 6.
- length (required): The length of the note.
Usage:
<add_notes>
<notes>
<note>
<pitch>pitch of the first note you want to add, e.g. C4</pitch>
<start_beat>start beat of the first note you want to add</start_beat>
<length>length of the first note you want to add</length>
</note>
<note>
<pitch>pitch of the second note you want to add, e.g. E4</pitch>
<start_beat>start beat of the second note you want to add</start_beat>
<length>length of the second note you want to add</length>
</note>
...
</notes>
</add_notes>
## attempt_completion To create a melodic line, use sequential `start_beat` values for each note. To create a chord, give multiple notes the same `start_beat`.
Description: After each tool use, the user will respond with the result of that tool use, i.e. if it succeeded or failed, along with any reasons for failure. Once you've received the results of tool uses and can confirm that the task is complete, use this tool to present the result of your work to the user.
IMPORTANT NOTE: This tool CANNOT be used until you've confirmed from the user that any previous tool uses were successful. Failure to do so will result in code corruption and system failure. Before using this tool, you must ask yourself in <thinking></thinking> tags if you've confirmed from the user that any previous tool uses were successful. If not, then DO NOT use this tool.
Parameters:
- comment: (required) The result of the task. Formulate this result in a way that is final and does not require further input from the user. Don't end your result with questions or offers for further assistance.
Usage:
<attempt_completion>
<comment>Your final result or comment here</comment>
</attempt_completion>
# Tool Use Examples
## Example 1: Requesting to read a part of the music from beat 0 to beat 8
<read_music>
<start_beat>0</start_beat>
<length>8</length>
</read_music>
## Example 2: Requesting to remove notes from the current region from beat 0 to beat 4
<remove_notes>
<start_beat>0</start_beat>
<end_beat>4</end_beat>
</remove_notes>
## Example 3: Requesting to add notes C4, D4, G4, E4 to the current region, starting at beat 0 and each one lasting 1 beat.
<add_notes>
<notes>
<note>
<pitch>C4</pitch>
<start_beat>0</start_beat>
<length>1</length>
</note>
<note>
<pitch>D4</pitch>
<start_beat>1</start_beat>
<length>1</length>
</note>
<note>
<pitch>G4</pitch>
<start_beat>2</start_beat>
<length>1</length>
</note>
<note>
<pitch>E4</pitch>
<start_beat>3</start_beat>
<length>1</length>
</note>
</notes>
</add_notes>
## Example 4: Requesting to add a chord containing C4, E4, G4 to the current region, starting at beat 0 and lasting 2 beats.
<add_notes>
<notes>
<note>
<pitch>C4</pitch>
<start_beat>0</start_beat>
<length>2</length>
</note>
<note>
<pitch>E4</pitch>
<start_beat>0</start_beat>
<length>2</length>
</note>
<note>
<pitch>G4</pitch>
<start_beat>0</start_beat>
<length>2</length>
</note>
</notes>
</add_notes>
## Example 5: Requesting to complete current task with a final comment
<attempt_completion>
<comment>Completed the I-V-vi-IV chord progression in C major, each chord lasting 2 beats</comment>
</attempt_completion>
# Tool Use Guidelines # Tool Use Guidelines
1. In <thinking> tags, assess what information you already have and what information you need to proceed with the task. 1. Assess what information you already have and what you need before choosing a tool.
2. Choose the most appropriate tool based on the task and the tool descriptions provided. Assess if you need additional information to proceed, and which of the available tools would be most effective for gathering this information. It's critical that you think about each available tool and use the one that best fits the current step in the task. 2. Choose the most appropriate tool for the current step. If you need to understand existing music, use `read_music` first.
3. If multiple actions are needed, use one tool at a time per message to accomplish the task iteratively, with each tool use being informed by the result of the previous tool use. Do not assume the outcome of any tool use. Each step must be informed by the previous step's result. 3. After each tool call, examine the result before deciding the next action. Do not assume success — verify from the returned result.
4. Formulate your tool use using the XML format specified for each tool. 4. If a required parameter cannot be determined from context, ask the user instead of guessing.
5. After each tool use, the user will respond with the result of that tool use. This result will provide you with the necessary information to continue your task or make further decisions. This response may include: 5. Proceed step-by-step. Each action should build on confirmed results from previous steps.
- Information about whether the tool succeeded or failed, along with any reasons for failure.
- Music pieces in ABC notation if you have used the read_music tool.
- Any other relevant feedback or information related to the tool use.
Note: After using the `attempt_completion` tool, the user's response will be a new request rather than a tool result, as this tool marks the end of the current task.
6. ALWAYS wait for user confirmation after each tool use before proceeding. Never assume the success of a tool use without explicit confirmation of the result from the user. Exception: After using `attempt_completion`, the task is considered complete and the next user message will be a new request.
It is crucial to proceed step-by-step, waiting for the user's message after each tool use before moving forward with the task. This approach allows you to:
1. Confirm the success of each step before proceeding.
2. Address any issues or errors that arise immediately.
3. Adapt your approach based on new information or unexpected results.
4. Ensure that each action builds correctly on the previous ones.
By waiting for and carefully considering the user's response after each tool use, you can react accordingly and make informed decisions about how to proceed with the task. This iterative process helps ensure the overall success and accuracy of your work.
==== ====
@@ -205,7 +56,7 @@ You have access to two tools for working with the current music region: **remove
## Important Considerations ## Important Considerations
- If you have used the add_notes tool previously to add notes to the current region, you should use this tool to remove the notes you added before using the add_notes tool again. - If you have previously added notes to the current region, you should use this tool to remove those notes before using `add_notes` again.
- Ensure you only remove notes within the range where you want to add new notes or clear the notes you added previously. - Ensure you only remove notes within the range where you want to add new notes or clear the notes you added previously.
# add_notes # add_notes
@@ -217,13 +68,12 @@ You have access to two tools for working with the current music region: **remove
## When to Use ## When to Use
- Add notes to the current region. - Add notes to the current region.
- You should add notes one by one. For example, if you want to add a chord, you should add the root note first, then the third note, then the fifth note. - For the `pitch` parameter, use scientific pitch notation in format `{note_name}{accidental}{octave_number}`. For example, `C4` is middle C, `F#3` is F-sharp in the 3rd octave, and `Bb2` is B-flat in the 2nd octave.
- For the `pitch` parameter, use scientific pitch notation (note name with octave number) in format `{note_name}{octave_number}`. For example, `C4` is the C note in the 4th octave.
## Important Considerations ## Important Considerations
- **Do not omit notes**: It is important that when adding notes, you must explicitly output every note that should be added — do not omit, summarize, or replace them with comments like .... Even if the pattern is repetitive, list all notes in full detail in the correct order. NEVER OMIT ANY NOTES IN THE XML BECAUSE OF REPETITION!! - **Do not omit notes**: When adding notes, you must explicitly include every note — do not omit, summarize, or replace them with comments like "...". Even if the pattern is repetitive, list all notes in full detail. NEVER OMIT ANY NOTES BECAUSE OF REPETITION.
- **Reading Music**: You should NEVER ask the user to manually provide you music pieces BEFORE invoking the read_music tool. Always use the read_music tool to get the music pieces first. - **Reading Music**: You should NEVER ask the user to manually provide you music pieces BEFORE invoking the `read_music` tool. Always use `read_music` to get the music pieces first.
- **Music Validation**: Always validate your musical choices: - **Music Validation**: Always validate your musical choices:
- Ensure pitches are within reasonable ranges for the current instrument - Ensure pitches are within reasonable ranges for the current instrument
- Verify that note timings align with the current time signature - Verify that note timings align with the current time signature
@@ -231,79 +81,11 @@ You have access to two tools for working with the current music region: **remove
- Confirm note lengths don't extend beyond reasonable musical phrases - Confirm note lengths don't extend beyond reasonable musical phrases
- **Pitch Notation**: Use scientific pitch notation (e.g., C4, A#3, Bb2) and ensure octave numbers are appropriate for the instrument - **Pitch Notation**: Use scientific pitch notation (e.g., C4, A#3, Bb2) and ensure octave numbers are appropriate for the instrument
- **Timing Constraints**: All start_beat and length values must align with the time signature grid - **Timing Constraints**: All start_beat and length values must align with the time signature grid
- When adding chord progressions, you should first break down the chord progression into individual notes based on the key signature, then add the notes one by one. - When adding chord progressions, first break down the progression into individual notes based on the key signature, then add all the notes.
- For example, if you want to add a chord progression "IVviIV" in C major: - For example, to create a I-V-vi-IV progression in C major with 4-beat chords:
- First, check the key signature of the current region. If it's C major, then the chord progression should be "CGAmF". 1. Check the key signature. In C major, the chords are C, G, Am, F.
- Then, convert each chord to its individual notes: 2. Convert each chord to individual notes: C = C4/E4/G4, G = G3/B3/D4, Am = A3/C4/E4, F = F3/A3/C4.
- We have 3 notes C4, E4, G4 for the C chord. 3. Call `add_notes` with all 12 notes: the C chord notes at `start_beat` 0, the G chord notes at `start_beat` 4, the Am chord notes at `start_beat` 8, and the F chord notes at `start_beat` 12 — each with `length` 4.
- We have 3 notes G3, B3, D4 for the G chord.
- We have 3 notes A3, C4, E4 for the Am chord.
- We have 3 notes F3, A3, C4 for the F chord.
- Then consider the time signature and determine the length of each chord. If you determine the length of each chord is 4 beats, then the XML you should use to create the chord progression using the **add_notes** tool is:
<add_notes>
<notes>
<note>
<pitch>C4</pitch>
<start_beat>0</start_beat>
<length>4</length>
</note>
<note>
<pitch>E4</pitch>
<start_beat>0</start_beat>
<length>4</length>
</note>
<note>
<pitch>G4</pitch>
<start_beat>0</start_beat>
<length>4</length>
</note>
<note>
<pitch>G3</pitch>
<start_beat>4</start_beat>
<length>4</length>
</note>
<note>
<pitch>B3</pitch>
<start_beat>4</start_beat>
<length>4</length>
</note>
<note>
<pitch>D4</pitch>
<start_beat>4</start_beat>
<length>4</length>
</note>
<note>
<pitch>A3</pitch>
<start_beat>8</start_beat>
<length>4</length>
</note>
<note>
<pitch>C4</pitch>
<start_beat>8</start_beat>
<length>4</length>
</note>
<note>
<pitch>E4</pitch>
<start_beat>8</start_beat>
<length>4</length>
</note>
<note>
<pitch>F3</pitch>
<start_beat>12</start_beat>
<length>4</length>
</note>
<note>
<pitch>A3</pitch>
<start_beat>12</start_beat>
<length>4</length>
</note>
<note>
<pitch>C4</pitch>
<start_beat>12</start_beat>
<length>4</length>
</note>
</notes>
</add_notes>
# Workflow Tips # Workflow Tips
@@ -326,11 +108,11 @@ OBJECTIVE
You accomplish a given task iteratively, breaking it down into clear steps and working through them methodically. You accomplish a given task iteratively, breaking it down into clear steps and working through them methodically.
1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order. 1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order.
2. Work through these goals sequentially, utilizing available tools one at a time as necessary. Each goal should correspond to a distinct step in your problem-solving process. 2. Work through these goals sequentially, utilizing available tools as necessary. Each goal should correspond to a distinct step in your problem-solving process.
3. Remember, you have extensive capabilities with access to a wide range of tools that can be used in powerful and clever ways as necessary to accomplish each goal. Before calling a tool, do some analysis within <thinking></thinking> tags. First, read the existing music to get the context. Then, think about which of the provided tools is the most relevant tool to accomplish the user's task. Next, go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, close the thinking tag and proceed with the tool use. BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params) and instead, ask the user to provide the missing parameters without any tool invoking (which will automatically pause the task execution). DO NOT ask for more information on optional parameters if it is not provided. 3. Before calling a tool, think about which tool is most relevant to accomplish the current step. Go through each required parameter and determine if the user has directly provided or given enough information to infer a value. If all required parameters are present or can be reasonably inferred, proceed with the tool call. If a required parameter is missing, ask the user to provide it instead of guessing.
4. Once you've completed the user's task, you must use the attempt_completion tool to present the result of the task to the user. 4. Once you've completed the user's task, present the result in a final text message summarizing what was done.
5. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance. 5. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.
6. It is important to think about the task step by step. DO NOT directly jump to tool invocation without thinking. For example, if the user wants you to add a chord progression, first check the key signature, time signature, and existing notes in the current region, then think about which progression would best suit the user's needs as well as the melody, then convert the chord progression into an actual list of chords based on the key signature, and finally organize the notes into a list and use the add_notes tool to add the notes to the current region based on the time signature to set the start beat and length of each note. 6. It is important to think about the task step by step. DO NOT directly jump to tool invocation without thinking. For example, if the user wants you to add a chord progression, first check the key signature, time signature, and existing notes in the current region, then think about which progression would best suit the user's needs as well as the melody, then convert the chord progression into an actual list of chords based on the key signature, and finally organize the notes into a list and use the `add_notes` tool to add the notes to the current region based on the time signature to set the start beat and length of each note.
==== ====
@@ -433,7 +215,7 @@ When working with drum tracks, use these pitch mappings for accurate drum notati
- **A5 (81)**: Open Triangle - Open triangle - **A5 (81)**: Open Triangle - Open triangle
**Usage Notes for Drums**: **Usage Notes for Drums**:
- When composing for drums, use the scientific pitch notation (e.g., C2, D2, F#2) in your add_notes commands - When composing for drums, use the scientific pitch notation (e.g., C2, D2, F#2) in your `add_notes` tool calls
- Focus on the core drum kit sounds (36, 38, 42, 46, 49, 51) for basic patterns - Focus on the core drum kit sounds (36, 38, 42, 46, 49, 51) for basic patterns
- Use extended percussion for more complex arrangements and world music styles - Use extended percussion for more complex arrangements and world music styles
- Consider the musical context when selecting appropriate drum sounds - Consider the musical context when selecting appropriate drum sounds
+109 -46
View File
@@ -1,10 +1,15 @@
import { LLMProvider } from '../llm/LLMProvider'; import { LLMProvider } from '../llm/LLMProvider';
import { AgentState } from './AgentState'; import { AgentState } from './AgentState';
import { SystemPrompts } from './SystemPrompts'; import { SystemPrompts } from './SystemPrompts';
import { AVAILABLE_TOOLS } from '../tools';
import { useProjectStore } from '../../stores/projectStore';
import type { StreamChunk } from '../llm/StreamingTypes'; import type { StreamChunk } from '../llm/StreamingTypes';
import type { ToolCall } from './AgentState';
import type { OpenAIToolDefinition } from '../tools/BaseTool';
/** /**
* Main orchestrator for the AI agent system * Main orchestrator for the AI agent system.
* Handles the full agentic loop: LLM streaming → tool execution → result feedback → repeat.
*/ */
export class AgentCore { export class AgentCore {
private static _instance: AgentCore | null = null; private static _instance: AgentCore | null = null;
@@ -18,9 +23,6 @@ export class AgentCore {
this.agentState = new AgentState(); this.agentState = new AgentState();
} }
/**
* Get the singleton instance
*/
static instance(): AgentCore { static instance(): AgentCore {
if (!AgentCore._instance) { if (!AgentCore._instance) {
AgentCore._instance = new AgentCore(); AgentCore._instance = new AgentCore();
@@ -28,72 +30,147 @@ export class AgentCore {
return AgentCore._instance; return AgentCore._instance;
} }
/**
* Set the LLM provider
*/
setLLMProvider(provider: LLMProvider): void { setLLMProvider(provider: LLMProvider): void {
this.llmProvider = provider; this.llmProvider = provider;
} }
/**
* Get the current LLM provider
*/
getLLMProvider(): LLMProvider | null { getLLMProvider(): LLMProvider | null {
return this.llmProvider; return this.llmProvider;
} }
/**
* Get the agent state
*/
getAgentState(): AgentState { getAgentState(): AgentState {
return this.agentState; return this.agentState;
} }
/** /**
* Process user input and generate streaming response * Get OpenAI tool definitions for all available tools
*/
private getToolDefinitions(): OpenAIToolDefinition[] {
return Object.values(AVAILABLE_TOOLS).map(ToolClass => {
const tool = new ToolClass();
return tool.getDefinition();
});
}
/**
* Execute a single tool call and return the result
*/
private async executeTool(toolCall: ToolCall): Promise<{ success: boolean; result: string }> {
const toolName = toolCall.function.name;
const ToolClass = AVAILABLE_TOOLS[toolName as keyof typeof AVAILABLE_TOOLS];
if (!ToolClass) {
return { success: false, result: `Unknown tool: ${toolName}` };
}
try {
const params = JSON.parse(toolCall.function.arguments);
const toolInstance = new ToolClass();
const result = await toolInstance.execute(params);
// Sync UI state after successful tool execution
if (result.success) {
useProjectStore.getState().refreshProjectState();
}
return result;
} catch (error) {
return { success: false, result: `Tool execution failed: ${error}` };
}
}
/**
* Process user input and generate streaming response.
* Handles the full agentic loop internally: if the LLM returns tool_calls,
* execute them and feed results back until the LLM produces a final text response.
*/ */
async *processUserInput(userInput: string): AsyncIterableIterator<StreamChunk> { async *processUserInput(userInput: string): AsyncIterableIterator<StreamChunk> {
if (!this.llmProvider) { if (!this.llmProvider) {
throw new Error('No LLM provider configured'); throw new Error('No LLM provider configured');
} }
// Add user message to state and track its ID // Add user message to state
this.currentUserMessageId = this.agentState.addMessage('user', userInput); this.currentUserMessageId = this.agentState.addMessage('user', userInput);
// Get system prompt with current context
const systemPrompt = await SystemPrompts.getSystemPromptWithContext(); const systemPrompt = await SystemPrompts.getSystemPromptWithContext();
const tools = this.getToolDefinitions();
// Get full conversation history with preserved roles try {
// Agentic loop: stream → check for tool calls → execute → repeat
let continueLoop = true;
while (continueLoop) {
const conversationHistory = this.agentState.getMessages(); const conversationHistory = this.agentState.getMessages();
// Generate streaming response with full conversation context
let assistantResponse = '';
// Pre-add an empty assistant message that we'll update as we stream // Pre-add an empty assistant message that we'll update as we stream
this.currentAssistantMessageId = this.agentState.addMessage('assistant', ''); this.currentAssistantMessageId = this.agentState.addMessage('assistant', '');
try { let assistantTextContent = '';
for await (const chunk of this.llmProvider.generateStream(conversationHistory, systemPrompt)) { const accumulatedToolCalls: ToolCall[] = [];
let finishReason = 'stop';
for await (const chunk of this.llmProvider.generateStream(conversationHistory, systemPrompt, tools)) {
if (chunk.type === 'text') { if (chunk.type === 'text') {
assistantResponse += chunk.content; assistantTextContent += chunk.content;
// Update the assistant message in real-time this.agentState.updateMessage(this.currentAssistantMessageId, assistantTextContent);
this.agentState.updateMessage(this.currentAssistantMessageId, assistantResponse);
}
yield chunk; yield chunk;
} else if (chunk.type === 'tool_call' && chunk.toolCall) {
accumulatedToolCalls.push(chunk.toolCall);
} else if (chunk.type === 'done') {
finishReason = chunk.finishReason ?? 'stop';
}
} }
// Final update to ensure the complete response is stored if (finishReason === 'tool_calls' && accumulatedToolCalls.length > 0) {
if (assistantResponse) { // Update assistant message with tool calls
this.agentState.updateMessage(this.currentAssistantMessageId, assistantResponse); this.agentState.updateMessage(
this.currentAssistantMessageId,
assistantTextContent || null,
{ tool_calls: accumulatedToolCalls }
);
// Execute each tool call and add results to conversation
for (const toolCall of accumulatedToolCalls) {
// Notify UI about the tool call
yield { type: 'tool_call', content: '', toolCall };
const result = await this.executeTool(toolCall);
// Add tool result message to conversation history
this.agentState.addMessage('tool', JSON.stringify(result), {
tool_call_id: toolCall.id,
});
// Notify UI about the tool result
yield {
type: 'tool_result',
content: '',
toolResult: {
name: toolCall.function.name,
success: result.success,
result: result.result,
},
};
} }
// Clear assistant message ID before next iteration creates a new one
this.currentAssistantMessageId = null;
// Continue loop — send tool results back to LLM
} else {
// LLM finished with text response (stop reason)
this.agentState.updateMessage(this.currentAssistantMessageId, assistantTextContent);
continueLoop = false;
}
}
yield { type: 'done', content: '', finishReason: 'stop' };
} finally { } finally {
// Clear the current message IDs when done (successfully or not)
this.currentUserMessageId = null; this.currentUserMessageId = null;
this.currentAssistantMessageId = null; this.currentAssistantMessageId = null;
} }
} }
/** /**
* Abort the current streaming request and clean up messages * Abort the current streaming request and clean up messages
* Returns the content of the user message that was aborted (for restoring to input) * Returns the content of the user message that was aborted (for restoring to input)
@@ -101,13 +178,11 @@ export class AgentCore {
abortCurrentRequest(): string | null { abortCurrentRequest(): string | null {
let userMessageContent = null; let userMessageContent = null;
// Remove the current assistant message (the "in progress" one)
if (this.currentAssistantMessageId) { if (this.currentAssistantMessageId) {
this.agentState.removeMessage(this.currentAssistantMessageId); this.agentState.removeMessage(this.currentAssistantMessageId);
this.currentAssistantMessageId = null; this.currentAssistantMessageId = null;
} }
// Remove the current user message and get its content for restoration
if (this.currentUserMessageId) { if (this.currentUserMessageId) {
const messages = this.agentState.getMessages(); const messages = this.agentState.getMessages();
const userMessage = messages.find(msg => msg.id === this.currentUserMessageId); const userMessage = messages.find(msg => msg.id === this.currentUserMessageId);
@@ -121,30 +196,18 @@ export class AgentCore {
return userMessageContent; return userMessageContent;
} }
/**
* Check if there's a current streaming request in progress
*/
isStreamingInProgress(): boolean { isStreamingInProgress(): boolean {
return this.currentUserMessageId !== null && this.currentAssistantMessageId !== null; return this.currentUserMessageId !== null;
} }
/**
* Clear the conversation history
*/
clearConversation(): void { clearConversation(): void {
this.agentState.clearMessages(); this.agentState.clearMessages();
} }
/**
* Get whether the agent is currently working on a task
*/
getIsWorkingOnTask(): boolean { getIsWorkingOnTask(): boolean {
return this.agentState.getIsWorkingOnTask(); return this.agentState.getIsWorkingOnTask();
} }
/**
* Set whether the agent is currently working on a task
*/
setIsWorkingOnTask(isWorking: boolean): void { setIsWorkingOnTask(isWorking: boolean): void {
this.agentState.setIsWorkingOnTask(isWorking); this.agentState.setIsWorkingOnTask(isWorking);
} }
+28 -5
View File
@@ -2,11 +2,25 @@
* Manages the state of an agent conversation * Manages the state of an agent conversation
*/ */
/**
* Tool call info attached to assistant messages (OpenAI function calling format)
*/
export interface ToolCall {
id: string;
type: 'function';
function: {
name: string;
arguments: string; // JSON string of parameters
};
}
export interface Message { export interface Message {
id: string; id: string;
role: 'user' | 'assistant'; role: 'user' | 'assistant' | 'tool';
content: string; content: string | null;
timestamp: number; timestamp: number;
tool_calls?: ToolCall[]; // present on assistant messages when LLM invokes tools
tool_call_id?: string; // present on tool-result messages, links back to ToolCall.id
} }
export class AgentState { export class AgentState {
@@ -22,12 +36,18 @@ export class AgentState {
/** /**
* Add a message to the conversation * Add a message to the conversation
*/ */
addMessage(role: 'user' | 'assistant', content: string): string { addMessage(
role: 'user' | 'assistant' | 'tool',
content: string | null,
options?: { tool_calls?: ToolCall[]; tool_call_id?: string }
): string {
const message: Message = { const message: Message = {
id: this.generateMessageId(), id: this.generateMessageId(),
role, role,
content, content,
timestamp: Date.now() timestamp: Date.now(),
...(options?.tool_calls ? { tool_calls: options.tool_calls } : {}),
...(options?.tool_call_id ? { tool_call_id: options.tool_call_id } : {}),
}; };
this.messages.push(message); this.messages.push(message);
@@ -37,10 +57,13 @@ export class AgentState {
/** /**
* Update the content of a message by ID * Update the content of a message by ID
*/ */
updateMessage(messageId: string, content: string): boolean { updateMessage(messageId: string, content: string | null, options?: { tool_calls?: ToolCall[] }): boolean {
const messageIndex = this.messages.findIndex(msg => msg.id === messageId); const messageIndex = this.messages.findIndex(msg => msg.id === messageId);
if (messageIndex !== -1) { if (messageIndex !== -1) {
this.messages[messageIndex].content = content; this.messages[messageIndex].content = content;
if (options?.tool_calls) {
this.messages[messageIndex].tool_calls = options.tool_calls;
}
return true; return true;
} }
return false; return false;
-389
View File
@@ -1,389 +0,0 @@
/**
* XMLToolExecutor - Bridge between XML tool invocations and the existing tool system
* Parses XML blocks from LLM responses and executes corresponding tools
*/
import { extractXMLFromString } from '../../util/xmlUtil';
import { AVAILABLE_TOOLS, type ToolName } from '../tools';
import type { BaseTool, ToolResult } from '../tools/BaseTool';
import { useProjectStore } from '../../stores/projectStore';
/**
* Main executor class for XML-based tool invocations
* Integrates with existing tool architecture and streaming types
*/
export class XMLToolExecutor {
// Private static instance for singleton pattern
private static _instance: XMLToolExecutor | null = null;
// Private constructor to prevent direct instantiation
private constructor() {}
/**
* Get the singleton instance of XMLToolExecutor
*/
public static instance(): XMLToolExecutor {
if (!XMLToolExecutor._instance) {
XMLToolExecutor._instance = new XMLToolExecutor();
}
return XMLToolExecutor._instance;
}
/**
* Execute all XML tool invocations found in the given input string
* @param input - String containing XML tool invocations (typically LLM response)
* @returns Promise resolving to array of tool results in order of appearance
*/
public async executeXMLTools(input: string): Promise<ToolResult[]> {
try {
// Extract all XML blocks from the input
const xmlBlocks = extractXMLFromString(input);
if (xmlBlocks.length === 0) {
return [];
}
// Process each XML block and collect results
const results: ToolResult[] = [];
for (const xmlBlock of xmlBlocks) {
try {
const result = await this.executeXMLBlock(xmlBlock);
results.push(result);
} catch (error) {
// Create failed result
results.push({
success: false,
result: `Failed to process XML block: ${error}`
});
}
}
return results;
} catch (error) {
return [{
success: false,
result: `Failed to execute XML tools: ${error}`
}];
}
}
/**
* Execute a single XML block as a tool invocation
* @param xmlBlock - XML string representing a tool invocation
* @returns Promise resolving to tool execution result
*/
private async executeXMLBlock(xmlBlock: string): Promise<ToolResult> {
// Parse XML to extract tool information
const parseResult = this.parseXMLBlock(xmlBlock);
if (!parseResult.success) {
return {
success: false,
result: parseResult.error || 'Failed to parse XML block'
};
}
// Check if tool exists in registry
if (!(parseResult.toolName in AVAILABLE_TOOLS)) {
return {
success: false,
result: `Unknown tool: ${parseResult.toolName}`
};
}
try {
// Create tool instance
const ToolClass = AVAILABLE_TOOLS[parseResult.toolName as ToolName];
const toolInstance: BaseTool = new ToolClass();
// Execute the tool
const toolResult = await toolInstance.execute(parseResult.parameters);
// Sync UI state if the tool execution was successful
if (toolResult.success) {
this.syncUIState();
}
return toolResult;
} catch (error) {
return {
success: false,
result: `Tool execution failed: ${error}`
};
}
}
/**
* Parse XML block to extract tool name and parameters
* @param xmlBlock - XML string to parse
* @returns Parse result with tool information or error
*/
private parseXMLBlock(xmlBlock: string): { success: boolean; toolName: string; parameters: Record<string, unknown>; error?: string } {
try {
// Special pre-processing for attempt_completion: ensure <comment> is wrapped in CDATA
const preparedXml = this.preprocessAttemptCompletionXML(xmlBlock);
// Parse XML using native DOMParser
const parser = new DOMParser();
const doc = parser.parseFromString(preparedXml, 'text/xml');
// Check for parsing errors
const parserError = doc.querySelector('parsererror');
if (parserError) {
return {
success: false,
toolName: '',
parameters: {},
error: `XML parsing error: ${parserError.textContent}`
};
}
// Get the root element (tool name)
const rootElement = doc.documentElement;
const toolName = rootElement.tagName;
// Parse XML parameters
const parameters = this.parseXMLParameters(rootElement);
return {
success: true,
toolName,
parameters
};
} catch (error) {
return {
success: false,
toolName: '',
parameters: {},
error: `Failed to parse XML: ${error}`
};
}
}
/**
* Ensure special tools have CDATA-wrapped content where appropriate.
* - attempt_completion: wrap <comment> inner text with CDATA (if not already)
* - think / thinking: wrap root inner text with CDATA (if not already)
* Decode basic XML entities before wrapping so CDATA contains human-readable text.
*/
private preprocessAttemptCompletionXML(xml: string): string {
try {
const leadingWhitespaceMatch = xml.match(/^\s*/);
const prefix = leadingWhitespaceMatch ? leadingWhitespaceMatch[0] : '';
const withoutLeading = xml.slice(prefix.length);
const rootMatch = withoutLeading.match(/^<([A-Za-z_][\w-]*)\b/);
const root = rootMatch?.[1] || '';
if (root !== 'attempt_completion' && root !== 'think' && root !== 'thinking') return xml;
// Helper to decode entities
const decodeEntities = (text: string): string =>
text
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'");
if (root === 'attempt_completion') {
// Find first <comment>...</comment>
const commentRegex = /<comment>([\s\S]*?)<\/comment>/i;
const match = xml.match(commentRegex);
if (!match) return xml;
const inner = match[1];
if (/<!\[CDATA\[/.test(inner)) {
// Already wrapped
return xml;
}
const decoded = decodeEntities(inner);
const replacement = `<comment><![CDATA[${decoded}]]></comment>`;
return xml.replace(commentRegex, replacement);
}
// Handle <think>...</think> or <thinking>...</thinking>
const rootRegex = new RegExp(`<${root}>([\\s\\S]*?)</${root}>`, 'i');
const rootMatchContent = xml.match(rootRegex);
if (!rootMatchContent) return xml;
const innerRoot = rootMatchContent[1];
if (/<!\[CDATA\[/.test(innerRoot)) {
return xml; // Already wrapped
}
const decodedRoot = decodeEntities(innerRoot);
const replacementRoot = `<${root}><![CDATA[${decodedRoot}]]></${root}>`;
return xml.replace(rootRegex, replacementRoot);
} catch {
// On any error, return original XML to avoid breaking flow
return xml;
}
}
/**
* Parse XML element into tool parameters object
* Converts XML structure to JavaScript object that matches tool parameter schema
* @param element - Root XML element containing tool parameters
* @returns Parameters object for tool execution
*/
private parseXMLParameters(element: Element): Record<string, unknown> {
const parameters: Record<string, unknown> = {};
// Special handling for thinking tool: if no child elements, use text content directly
if (element.tagName === 'thinking' && element.children.length === 0) {
const textContent = element.textContent?.trim() || '';
parameters.content = textContent;
return parameters;
}
// Process all child elements
for (const child of element.children) {
const paramName = child.tagName;
const paramValue = this.parseXMLValue(child);
// Handle arrays (multiple elements with same tag name)
if (parameters[paramName] !== undefined) {
// Convert to array if not already
if (!Array.isArray(parameters[paramName])) {
parameters[paramName] = [parameters[paramName]];
}
(parameters[paramName] as unknown[]).push(paramValue);
} else {
parameters[paramName] = paramValue;
}
}
// Apply array wrapper flattening
return this.flattenArrayWrappers(parameters);
}
/**
* Flatten array wrapper patterns in parsed parameters
* Converts structures like {notes: {note: [...]}} to {notes: [...]}
* @param parameters - Parsed parameters object
* @returns Parameters with flattened array wrappers
*/
private flattenArrayWrappers(parameters: Record<string, unknown>): Record<string, unknown> {
const flattened: Record<string, unknown> = {};
for (const [key, value] of Object.entries(parameters)) {
if (this.isArrayWrapperCandidate(key, value)) {
// This is an array wrapper - flatten it
const wrapperObj = value as Record<string, unknown>;
const innerKeys = Object.keys(wrapperObj);
if (innerKeys.length === 1) {
const innerKey = innerKeys[0];
const innerValue = wrapperObj[innerKey];
// Check if inner key is singular form of outer key
if (this.isSingularOf(innerKey, key)) {
// Flatten: {notes: {note: [...]}} → {notes: [...]}
flattened[key] = innerValue;
continue;
}
}
}
// No flattening needed, keep as is
flattened[key] = value;
}
return flattened;
}
/**
* Check if a value is a candidate for array wrapper flattening
* @param key - Parameter key (e.g., "notes")
* @param value - Parameter value to check
* @returns True if this looks like an array wrapper pattern
*/
private isArrayWrapperCandidate(key: string, value: unknown): boolean {
// Must be an object (not array, not primitive)
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return false;
}
const obj = value as Record<string, unknown>;
const innerKeys = Object.keys(obj);
// Must have exactly one property
if (innerKeys.length !== 1) {
return false;
}
const innerKey = innerKeys[0];
const innerValue = obj[innerKey];
// Inner value should be an array or could become an array
// (single items are often converted to arrays by the parser)
return this.isSingularOf(innerKey, key) &&
(Array.isArray(innerValue) || typeof innerValue === 'object');
}
/**
* Check if one word is the singular form of another (simple heuristic)
* @param singular - Potential singular form (e.g., "note")
* @param plural - Potential plural form (e.g., "notes")
* @returns True if singular appears to be singular form of plural
*/
private isSingularOf(singular: string, plural: string): boolean {
// Simple heuristics for common English pluralization
if (plural === singular + 's') return true; // note → notes
if (plural === singular + 'es') return true; // box → boxes
if (plural.endsWith('ies') && singular.endsWith('y')) { // entry → entries
return plural === singular.slice(0, -1) + 'ies';
}
// Add more rules as needed for your specific use cases
return false;
}
/**
* Parse a single XML element value, handling different data types and structures
* @param element - XML element to parse
* @returns Parsed value (string, number, boolean, object, or array)
*/
private parseXMLValue(element: Element): unknown {
// If element has children, parse as object
if (element.children.length > 0) {
return this.parseXMLParameters(element);
}
// Get text content
const textContent = element.textContent?.trim() || '';
// Try to parse as number
if (/^-?\d+(\.\d+)?$/.test(textContent)) {
return parseFloat(textContent);
}
// Try to parse as boolean
if (textContent === 'true') return true;
if (textContent === 'false') return false;
// Return as string
return textContent;
}
/**
* Synchronize UI state after successful tool execution
* Uses the centralized refresh method from the project store
*/
private syncUIState(): void {
try {
// Use the centralized refresh method from the store
const storeActions = useProjectStore.getState();
if (storeActions.refreshProjectState) {
storeActions.refreshProjectState();
}
} catch (error) {
console.warn('Failed to sync UI state after XML tool execution:', error);
// Don't throw - UI sync failure shouldn't break tool execution
}
}
}
-239
View File
@@ -1,239 +0,0 @@
import { LLMProvider } from './LLMProvider';
import type { StreamChunk } from './StreamingTypes';
import type { Message } from '../core/AgentState';
import { ConfigManager } from '../../core/config/ConfigManager';
import { LLM_PROTOCOL } from '../../constants/llmConstants';
/**
* Claude (via OpenRouter) provider using the OpenAI-compatible Chat Completions API.
* Difference from the generic OpenAI provider: message content is an array of parts
* with a single text item per message (future-ready for images, tools, etc.).
*/
export class ClaudeOpenRouterProvider extends LLMProvider {
readonly name = 'Claude (OpenRouter)';
private isOllamaFormat: boolean | null = null; // Detected at runtime
constructor() {
super();
}
/**
* Build OpenAI-compatible messages array where each message content is an array of parts.
*/
private buildRequestMessages(messages: Message[], systemPrompt?: string): Array<{ role: string; content: Array<{ type: 'text'; text: string; cache_control?: { type: 'ephemeral' } }> }> {
type ORPart = { type: 'text'; text: string; cache_control?: { type: 'ephemeral' } };
const openAIMessages: Array<{ role: string; content: Array<ORPart> }> = [];
// Add system prompt if provided
if (systemPrompt) {
openAIMessages.push({ role: 'system', content: [{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } }] });
}
// Add conversation history with preserved roles
let lastUserIndex = -1;
for (let i = 0; i < messages.length; i++) {
if (messages[i].role === 'user') lastUserIndex = i;
}
openAIMessages.push(
...messages.map((msg, i) => {
const part: ORPart = { type: 'text', text: msg.content };
if (msg.role === 'user' && i === lastUserIndex) {
part.cache_control = { type: 'ephemeral' };
}
return {
role: msg.role,
content: [part]
};
})
);
return openAIMessages;
}
/**
* Create API request with proper headers and body
*/
private async createApiRequest(messages: Array<{ role: string; content: Array<{ type: 'text'; text: string; cache_control?: { type: 'ephemeral' } }> }>, config: ReturnType<typeof this.getCurrentConfig>, streaming: boolean): Promise<Response> {
const headers: Record<string, string> = {
'Authorization': `Bearer ${config.apiKey}`,
'Content-Type': 'application/json',
};
// Optional, but recommended by OpenRouter docs to set referer/title for attribution
// if (typeof window !== 'undefined') {
// headers['HTTP-Referer'] = window.location.origin;
// headers['X-Title'] = 'K.G.Studio';
// }
const response = await fetch(config.apiEndpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model: config.model,
messages,
stream: streaming,
}),
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`${this.name} API request failed (${response.status}): ${response.statusText}. ${errorText}`);
}
return response;
}
/**
* Get current configuration values from ConfigManager
*/
private getCurrentConfig() {
const configManager = ConfigManager.instance();
const apiKey = configManager.get('general.claude_openrouter.api_key') as string;
const model = configManager.get('general.claude_openrouter.model') as string;
const baseURL = configManager.get('general.claude_openrouter.base_url') as string;
// baseURL is the full API endpoint for OpenRouter (e.g., https://openrouter.ai/api/v1/chat/completions)
const apiEndpoint = baseURL;
return { apiKey, model, baseURL, apiEndpoint };
}
/**
* Detect if the response uses Ollama's raw JSON format or OpenAI's SSE format
*/
private detectStreamFormat(firstChunk: string): boolean {
// If it starts with "data: ", it's OpenAI SSE format
if (firstChunk.trim().startsWith(LLM_PROTOCOL.SSE_DATA_PREFIX)) {
return false; // Not Ollama format
}
// Try to parse as JSON - if successful and has 'done' field, it's Ollama format
try {
const json = JSON.parse(firstChunk.trim());
return typeof json.done === 'boolean';
} catch {
return false; // Not valid JSON, assume OpenAI format
}
}
/**
* Parse Ollama's raw JSON chunk format
*/
private parseOllamaChunk(chunk: string): { thinking?: string; content?: string; isDone?: boolean } {
try {
const json = JSON.parse(chunk.trim());
const thinking: string | undefined = json.message?.thinking;
const content: string | undefined = json.message?.content || json.response; // Handle both chat and completion formats
return {
thinking,
content,
isDone: json.done === true
};
} catch {
return {}; // Invalid JSON, return empty object
}
}
/**
* Parse OpenAI's SSE format chunk
*/
private parseOpenAIChunk(line: string): { thinking?: string; content?: string; isDone?: boolean } {
if (!line.startsWith(LLM_PROTOCOL.SSE_DATA_PREFIX)) {
return {};
}
const data = line.slice(LLM_PROTOCOL.SSE_DATA_PREFIX.length);
if (data === LLM_PROTOCOL.SSE_DONE_MARKER) {
return { isDone: true };
}
try {
const json = JSON.parse(data);
const delta = json.choices?.[0]?.delta;
const thinking: string | undefined = delta?.thinking; // Some providers may stream "thinking"
const content: string | undefined = delta?.content;
return { thinking, content, isDone: false };
} catch {
return {}; // Skip invalid JSON lines
}
}
private async *processContentChunk(
thinking: string | undefined,
content: string | undefined,
lastSegmentType: { current: 'thinking' | 'content' | null }
): AsyncIterableIterator<StreamChunk> {
if (typeof thinking === 'string' && thinking.length > 0) {
if (lastSegmentType.current && lastSegmentType.current !== 'thinking') {
yield { type: 'text', content: LLM_PROTOCOL.SEGMENT_SEPARATOR };
}
yield { type: 'text', content: thinking };
lastSegmentType.current = 'thinking';
}
if (typeof content === 'string' && content.length > 0) {
if (lastSegmentType.current && lastSegmentType.current !== 'content') {
yield { type: 'text', content: LLM_PROTOCOL.SEGMENT_SEPARATOR };
}
yield { type: 'text', content: content };
lastSegmentType.current = 'content';
}
}
async *generateStream(
messages: Message[],
systemPrompt?: string
): AsyncIterableIterator<StreamChunk> {
const config = this.getCurrentConfig();
const requestMessages = this.buildRequestMessages(messages, systemPrompt);
const response = await this.createApiRequest(requestMessages, config, true);
const reader = response.body?.getReader();
if (!reader) {
throw new Error(`${this.name} streaming: Failed to get response reader from API response`);
}
const decoder = new TextDecoder();
let buffer = '';
let firstChunkProcessed = false;
const lastSegmentType = { current: null as 'thinking' | 'content' | null };
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Split by newlines for SSE (and also works for line-delimited JSON)
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine) continue;
// Detect format on first non-empty chunk
if (!firstChunkProcessed) {
this.isOllamaFormat = this.detectStreamFormat(trimmedLine);
firstChunkProcessed = true;
}
const { thinking, content, isDone } = this.isOllamaFormat
? this.parseOllamaChunk(trimmedLine)
: this.parseOpenAIChunk(trimmedLine);
if (isDone) {
yield { type: 'done', content: '' };
return;
}
yield* this.processContentChunk(thinking, content, lastSegmentType);
}
}
} finally {
reader.releaseLock();
}
}
}
-162
View File
@@ -1,162 +0,0 @@
import { LLMProvider } from './LLMProvider';
import type { StreamChunk } from './StreamingTypes';
import type { Message } from '../core/AgentState';
import { ConfigManager } from '../../core/config/ConfigManager';
/**
* Anthropic Claude API provider implementation
*/
export class ClaudeProvider extends LLMProvider {
readonly name = 'Claude';
private apiKey: string;
private model: string;
private baseURL: string = 'https://api.anthropic.com';
private apiEndpoint: string;
constructor() {
super();
const configManager = ConfigManager.instance();
this.apiKey = configManager.get('general.claude.api_key') as string;
this.model = configManager.get('general.claude.model') as string;
this.apiEndpoint = `${this.baseURL}/v1/messages`;
}
/**
* Convert internal messages to Claude's format
*/
private convertMessages(messages: Message[], systemPrompt?: string): {
system?: string;
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
} {
const claudeMessages: Array<{ role: 'user' | 'assistant'; content: string }> = [];
claudeMessages.push(...messages.map(msg => ({ role: msg.role, content: msg.content })));
return {
system: systemPrompt,
messages: claudeMessages
};
}
/**
* Parse Claude's streaming response chunks
*/
private parseClaudeStreamChunk(line: string): { content?: string; isDone?: boolean } {
if (!line.startsWith('data: ')) {
return {};
}
const data = line.slice(6);
if (data === '[DONE]') {
return { isDone: true };
}
try {
const json = JSON.parse(data);
// Handle different Claude streaming event types
switch (json.type) {
case 'content_block_delta':
return {
content: json.delta?.text,
isDone: false
};
case 'message_stop':
return { isDone: true };
default:
return {};
}
} catch {
return {}; // Skip invalid JSON lines
}
}
async *generateStream(
messages: Message[],
systemPrompt?: string,
tools?: Record<string, unknown>[]
): AsyncIterableIterator<StreamChunk> {
const { system, messages: claudeMessages } = this.convertMessages(messages, systemPrompt);
const requestBody: {
model: string;
max_tokens: number;
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
stream: boolean;
system?: string;
tools?: Record<string, unknown>[];
} = {
model: this.model,
max_tokens: 8192,
messages: claudeMessages,
stream: true
};
if (system) {
requestBody.system = system;
}
if (tools && tools.length > 0) {
requestBody.tools = tools;
}
const response = await fetch(this.apiEndpoint, {
method: 'POST',
headers: {
'x-api-key': this.apiKey,
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01'
},
body: JSON.stringify(requestBody),
mode: 'cors'
});
if (!response.ok) {
throw new Error(`Claude API error: ${response.status} ${response.statusText}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('Failed to get response reader');
}
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine) continue;
const parseResult = this.parseClaudeStreamChunk(trimmedLine);
if (parseResult.isDone) {
yield { type: 'done', content: '' };
return;
}
if (parseResult.content) {
yield {
type: 'text',
content: parseResult.content
};
}
}
}
} finally {
reader.releaseLock();
}
}
}
-183
View File
@@ -1,183 +0,0 @@
import { LLMProvider } from './LLMProvider';
import type { StreamChunk } from './StreamingTypes';
import type { Message } from '../core/AgentState';
import { ConfigManager } from '../../core/config/ConfigManager';
/**
* Google Gemini API provider implementation
*/
export class GeminiProvider extends LLMProvider {
readonly name = 'Gemini';
private apiKey: string;
private model: string;
private baseURL: string = 'https://generativelanguage.googleapis.com';
private apiEndpoint: string;
constructor() {
super();
const configManager = ConfigManager.instance();
this.apiKey = configManager.get('general.gemini.api_key') as string;
this.model = configManager.get('general.gemini.model') as string;
this.apiEndpoint = `${this.baseURL}/v1beta/models/${this.model}`;
}
/**
* Convert internal messages to Gemini's format
*/
private convertMessages(messages: Message[], systemPrompt?: string): {
systemInstruction?: { parts: Array<{ text: string }> };
contents: Array<{ role: 'user' | 'model'; parts: Array<{ text: string }> }>;
} {
const geminiMessages: Array<{ role: 'user' | 'model'; parts: Array<{ text: string }> }> = [];
for (const msg of messages) {
let role: 'user' | 'model';
if (msg.role === 'assistant') {
role = 'model';
} else {
// Treat system and user messages as 'user' role
role = 'user';
}
geminiMessages.push({
role,
parts: [{ text: msg.content }]
});
}
const result: {
systemInstruction?: { parts: Array<{ text: string }> };
contents: Array<{ role: 'user' | 'model'; parts: Array<{ text: string }> }>;
} = {
contents: geminiMessages
};
if (systemPrompt) {
result.systemInstruction = {
parts: [{ text: systemPrompt }]
};
}
return result;
}
/**
* Parse Gemini's streaming response chunks
*/
private parseGeminiStreamChunk(chunk: string): { content?: string; isDone?: boolean } {
try {
const json = JSON.parse(chunk.trim());
// Gemini streaming format
if (json.candidates && json.candidates.length > 0) {
const candidate = json.candidates[0];
// Check if generation is finished
if (candidate.finishReason && candidate.finishReason !== 'STOP') {
return { isDone: true };
}
// Extract text content
const content = candidate.content?.parts?.[0]?.text;
if (content) {
return { content, isDone: false };
}
}
// Check for explicit done signal
if (json.done === true) {
return { isDone: true };
}
return {};
} catch {
return {}; // Skip invalid JSON
}
}
async *generateStream(
messages: Message[],
systemPrompt?: string,
tools?: Record<string, unknown>[]
): AsyncIterableIterator<StreamChunk> {
const { systemInstruction, contents } = this.convertMessages(messages, systemPrompt);
const requestBody: {
contents: Array<{ role: 'user' | 'model'; parts: Array<{ text: string }> }>;
generationConfig: { temperature: number; maxOutputTokens: number };
systemInstruction?: { parts: Array<{ text: string }> };
tools?: Record<string, unknown>[];
} = {
contents,
generationConfig: {
temperature: 0.7,
maxOutputTokens: 8192,
}
};
if (systemInstruction) {
requestBody.systemInstruction = systemInstruction;
}
if (tools && tools.length > 0) {
requestBody.tools = tools;
}
const response = await fetch(`${this.apiEndpoint}:streamGenerateContent?key=${this.apiKey}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
throw new Error(`Gemini API error: ${response.status} ${response.statusText}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('Failed to get response reader');
}
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Gemini sends JSON objects separated by newlines
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine) continue;
const parseResult = this.parseGeminiStreamChunk(trimmedLine);
if (parseResult.isDone) {
yield { type: 'done', content: '' };
return;
}
if (parseResult.content) {
yield {
type: 'text',
content: parseResult.content
};
}
}
}
} finally {
reader.releaseLock();
}
}
}
+136 -11
View File
@@ -1,21 +1,146 @@
import OpenAI from 'openai';
import type { StreamChunk } from './StreamingTypes'; import type { StreamChunk } from './StreamingTypes';
import type { Message } from '../core/AgentState'; import type { Message, ToolCall } from '../core/AgentState';
import type { OpenAIToolDefinition } from '../tools/BaseTool';
/** /**
* Abstract interface for LLM providers * LLM provider using the OpenAI SDK.
* Works with any OpenAI-compatible API (OpenAI, OpenRouter, Ollama, vLLM, etc.)
*/ */
export abstract class LLMProvider { export class LLMProvider {
abstract name: string; private client: OpenAI;
private model: string;
constructor(apiKey: string, model: string, baseURL?: string) {
// The OpenAI SDK appends /chat/completions itself, so strip it if the user included it
const normalizedBaseURL = baseURL?.replace(/\/chat\/completions\/?$/, '') || undefined;
this.client = new OpenAI({
apiKey,
...(normalizedBaseURL ? { baseURL: normalizedBaseURL } : {}),
dangerouslyAllowBrowser: true,
});
this.model = model;
}
/** /**
* Generate a streaming response from the LLM * Convert internal Message[] to OpenAI ChatCompletionMessageParam[]
* @param messages The full conversation history with preserved roles
* @param systemPrompt The system prompt (optional, can be included in messages)
* @param tools Available tools (optional for now)
*/ */
abstract generateStream( private convertMessages(
messages: Message[],
systemPrompt?: string
): OpenAI.ChatCompletionMessageParam[] {
const result: OpenAI.ChatCompletionMessageParam[] = [];
if (systemPrompt) {
result.push({ role: 'system', content: systemPrompt });
}
for (const msg of messages) {
if (msg.role === 'user') {
result.push({ role: 'user', content: msg.content ?? '' });
} else if (msg.role === 'assistant') {
const assistantMsg: OpenAI.ChatCompletionAssistantMessageParam = {
role: 'assistant',
content: msg.content ?? null,
};
if (msg.tool_calls && msg.tool_calls.length > 0) {
assistantMsg.tool_calls = msg.tool_calls.map(tc => ({
id: tc.id,
type: 'function' as const,
function: { name: tc.function.name, arguments: tc.function.arguments },
}));
}
result.push(assistantMsg);
} else if (msg.role === 'tool') {
result.push({
role: 'tool',
tool_call_id: msg.tool_call_id!,
content: msg.content ?? '',
});
}
}
return result;
}
/**
* Generate a streaming response from the LLM.
* Yields StreamChunks for text content and tool calls.
*/
async *generateStream(
messages: Message[], messages: Message[],
systemPrompt?: string, systemPrompt?: string,
tools?: Record<string, unknown>[] tools?: OpenAIToolDefinition[],
): AsyncIterableIterator<StreamChunk>; ): AsyncIterableIterator<StreamChunk> {
const openaiMessages = this.convertMessages(messages, systemPrompt);
const requestParams: OpenAI.ChatCompletionCreateParamsStreaming = {
model: this.model,
messages: openaiMessages,
stream: true,
};
if (tools && tools.length > 0) {
requestParams.tools = tools as unknown as OpenAI.ChatCompletionTool[];
requestParams.tool_choice = 'auto';
}
const stream = this.client.chat.completions.stream(requestParams);
// Accumulate tool calls across chunks (they arrive incrementally)
const toolCallAccumulator = new Map<number, { id: string; name: string; arguments: string }>();
for await (const chunk of stream) {
console.log('LLMProvider: chunk', JSON.stringify(chunk));
const choice = chunk.choices[0];
if (!choice) continue;
const delta = choice.delta;
// Yield text content
if (delta.content) {
yield { type: 'text', content: delta.content };
}
// Accumulate tool calls from deltas
if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
const existing = toolCallAccumulator.get(tc.index);
if (existing) {
// Append to existing tool call
if (tc.function?.arguments) {
existing.arguments += tc.function.arguments;
}
} else {
// New tool call
toolCallAccumulator.set(tc.index, {
id: tc.id ?? '',
name: tc.function?.name ?? '',
arguments: tc.function?.arguments ?? '',
});
}
}
}
}
// After stream ends, get the final completion for finish_reason
const finalCompletion = await stream.finalChatCompletion();
const finishReason = finalCompletion.choices[0]?.finish_reason ?? 'stop';
// Emit accumulated tool calls
if (toolCallAccumulator.size > 0) {
for (const [, tc] of toolCallAccumulator) {
const toolCall: ToolCall = {
id: tc.id,
type: 'function',
function: { name: tc.name, arguments: tc.arguments },
};
yield { type: 'tool_call', content: '', toolCall };
}
}
// Signal completion
yield { type: 'done', content: '', finishReason };
}
} }
-237
View File
@@ -1,237 +0,0 @@
import { LLMProvider } from './LLMProvider';
import type { StreamChunk } from './StreamingTypes';
import type { Message } from '../core/AgentState';
import { ConfigManager } from '../../core/config/ConfigManager';
import { URL_CONSTANTS } from '../../constants/coreConstants';
import { LLM_PROTOCOL } from '../../constants/llmConstants';
/**
* OpenAI API provider implementation
*/
export class OpenAIProvider extends LLMProvider {
readonly name = 'OpenAI';
private isOllamaFormat: boolean | null = null; // Detected at runtime
constructor() {
super();
}
/**
* Build OpenAI-compatible messages array from input messages and system prompt
*/
private buildRequestMessages(messages: Message[], systemPrompt?: string): Array<{ role: string; content: string }> {
const openAIMessages: Array<{ role: string; content: string }> = [];
// Add system prompt if provided
if (systemPrompt) {
openAIMessages.push({ role: 'system', content: systemPrompt });
}
// Add conversation history with preserved roles
openAIMessages.push(...messages.map(msg => ({
role: msg.role,
content: msg.content
})));
return openAIMessages;
}
/**
* Create API request with proper headers and body
*/
private async createApiRequest(messages: Array<{ role: string; content: string }>, config: ReturnType<typeof this.getCurrentConfig>, streaming: boolean): Promise<Response> {
const response = await fetch(config.apiEndpoint, {
method: 'POST',
headers: {
'Authorization': `Bearer ${config.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: config.model,
...(config.flexMode && !config.isCompatibleProvider ? { service_tier: 'flex' } : {}),
messages,
stream: streaming,
}),
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`${this.name} API request failed (${response.status}): ${response.statusText}. ${errorText}`);
}
return response;
}
/**
* Process thinking and content chunks, yielding appropriate StreamChunks
*/
private async *processContentChunk(
thinking: string | undefined,
content: string | undefined,
lastSegmentType: { current: 'thinking' | 'content' | null }
): AsyncIterableIterator<StreamChunk> {
if (typeof thinking === 'string' && thinking.length > 0) {
if (lastSegmentType.current && lastSegmentType.current !== 'thinking') {
yield { type: 'text', content: LLM_PROTOCOL.SEGMENT_SEPARATOR };
}
yield { type: 'text', content: thinking };
lastSegmentType.current = 'thinking';
}
if (typeof content === 'string' && content.length > 0) {
if (lastSegmentType.current && lastSegmentType.current !== 'content') {
yield { type: 'text', content: LLM_PROTOCOL.SEGMENT_SEPARATOR };
}
yield { type: 'text', content: content };
lastSegmentType.current = 'content';
}
}
/**
* Get current configuration values from ConfigManager
*/
private getCurrentConfig() {
const configManager = ConfigManager.instance();
const llmProvider = configManager.get('general.llm_provider') as string;
const isCompatibleProvider = llmProvider === 'openai_compatible';
if (isCompatibleProvider) {
const apiKey = configManager.get('general.openai_compatible.api_key') as string;
const model = configManager.get('general.openai_compatible.model') as string;
const baseURL = configManager.get('general.openai_compatible.base_url') as string;
// For compatible providers, use the full URL as provided (assume it includes the endpoint)
// Common patterns: http://localhost:11434/api/chat (Ollama), https://api.openrouter.ai/v1 (OpenRouter)
const apiEndpoint = baseURL;
const flexMode = false; // Not applicable to compatible providers
return { apiKey, model, baseURL, apiEndpoint, flexMode, isCompatibleProvider };
} else {
const apiKey = configManager.get('general.openai.api_key') as string;
const model = configManager.get('general.openai.model') as string;
const flexMode = (configManager.get('general.openai.flex') as boolean) === true;
const baseURL = URL_CONSTANTS.DEFAULT_OPENAI_BASE_URL;
const apiEndpoint = `${baseURL}/chat/completions`;
return { apiKey, model, baseURL, apiEndpoint, flexMode, isCompatibleProvider };
}
}
/**
* Detect if the response uses Ollama's raw JSON format or OpenAI's SSE format
*/
private detectStreamFormat(firstChunk: string): boolean {
// If it starts with "data: ", it's OpenAI SSE format
if (firstChunk.trim().startsWith(LLM_PROTOCOL.SSE_DATA_PREFIX)) {
return false; // Not Ollama format
}
// Try to parse as JSON - if successful and has 'done' field, it's Ollama format
try {
const json = JSON.parse(firstChunk.trim());
return typeof json.done === 'boolean';
} catch {
return false; // Not valid JSON, assume OpenAI format
}
}
/**
* Parse Ollama's raw JSON chunk format
*/
private parseOllamaChunk(chunk: string): { thinking?: string; content?: string; isDone?: boolean } {
try {
const json = JSON.parse(chunk.trim());
const thinking: string | undefined = json.message?.thinking;
const content: string | undefined = json.message?.content || json.response; // Handle both chat and completion formats
return {
thinking,
content,
isDone: json.done === true
};
} catch {
return {}; // Invalid JSON, return empty object
}
}
/**
* Parse OpenAI's SSE format chunk
*/
private parseOpenAIChunk(line: string): { thinking?: string; content?: string; isDone?: boolean } {
if (!line.startsWith(LLM_PROTOCOL.SSE_DATA_PREFIX)) {
return {};
}
const data = line.slice(LLM_PROTOCOL.SSE_DATA_PREFIX.length);
if (data === LLM_PROTOCOL.SSE_DONE_MARKER) {
return { isDone: true };
}
try {
const json = JSON.parse(data);
const delta = json.choices?.[0]?.delta;
const thinking: string | undefined = delta?.thinking; // Some providers may stream "thinking"
const content: string | undefined = delta?.content;
return { thinking, content, isDone: false };
} catch {
return {}; // Skip invalid JSON lines
}
}
async *generateStream(
messages: Message[],
systemPrompt?: string
): AsyncIterableIterator<StreamChunk> {
const config = this.getCurrentConfig();
const requestMessages = this.buildRequestMessages(messages, systemPrompt);
const response = await this.createApiRequest(requestMessages, config, true);
const reader = response.body?.getReader();
if (!reader) {
throw new Error(`${this.name} streaming: Failed to get response reader from API response`);
}
const decoder = new TextDecoder();
let buffer = '';
let firstChunkProcessed = false;
const lastSegmentType = { current: null as 'thinking' | 'content' | null };
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// For Ollama format, we need to split by newlines for JSON objects
// For OpenAI format, we also split by newlines for SSE
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine) continue;
// Detect format on first non-empty chunk
if (!firstChunkProcessed) {
this.isOllamaFormat = this.detectStreamFormat(trimmedLine);
firstChunkProcessed = true;
}
const { thinking, content, isDone } = this.isOllamaFormat
? this.parseOllamaChunk(trimmedLine)
: this.parseOpenAIChunk(trimmedLine);
if (isDone) {
yield { type: 'done', content: '' };
return;
}
yield* this.processContentChunk(thinking, content, lastSegmentType);
}
}
} finally {
reader.releaseLock();
}
}
}
+5 -19
View File
@@ -1,27 +1,13 @@
/** /**
* Types for streaming LLM responses and tool execution * Types for streaming LLM responses
*/ */
import type { ToolResult } from '../tools/BaseTool'; import type { ToolCall } from '../core/AgentState';
// Re-export for convenience
export type { ToolResult };
export interface ToolInvocation {
id: string;
name: string;
parameters: Record<string, unknown>;
}
export interface StreamChunk { export interface StreamChunk {
type: 'text' | 'tool_call' | 'tool_result' | 'done'; type: 'text' | 'tool_call' | 'tool_result' | 'done';
content: string; content: string;
toolCall?: ToolInvocation; toolCall?: ToolCall;
toolResult?: ToolResult; toolResult?: { name: string; success: boolean; result: string };
} finishReason?: string; // 'stop' | 'tool_calls' — present on 'done' chunks
export interface LLMResponse {
content: string;
toolCalls?: ToolInvocation[];
finished: boolean;
} }
+8 -8
View File
@@ -12,35 +12,35 @@ import { KGCore } from '../../core/KGCore';
*/ */
export class AddNotesTool extends BaseTool { export class AddNotesTool extends BaseTool {
readonly name = 'add_notes'; readonly name = 'add_notes';
readonly description = 'Create one or more MIDI notes in the current region. Each note requires pitch (e.g., "C4", "F#3"), start_beat (beat position), and length (duration in beats).'; readonly description = 'Add one or more MIDI notes to the current region. Use this to create melodies, chords, or any musical content. Notes use absolute beat positions on the project timeline — not relative to the region start.';
readonly parameters: Record<string, ToolParameter> = { readonly parameters: Record<string, ToolParameter> = {
notes: { notes: {
type: 'array', type: 'array',
description: 'Array of notes to create', description: 'List of notes to add. To create a chord, give multiple notes the same start_beat. To create a melody, use sequential start_beat values.',
required: true, required: true,
items: { items: {
type: 'object', type: 'object',
description: 'A MIDI note definition', description: 'A single note',
properties: { properties: {
pitch: { pitch: {
type: 'string', type: 'string',
description: 'Note pitch in scientific notation (e.g., "C4", "F#3", "Bb2")', description: 'Pitch in scientific notation: note name, optional accidental (# or b), and octave number. Examples: "C4" (middle C), "F#3" (F-sharp 3rd octave), "Bb2" (B-flat 2nd octave).',
required: true required: true
}, },
start_beat: { start_beat: {
type: 'number', type: 'number',
description: 'Start position in beats (e.g., 0, 1.5, 2)', description: 'Absolute beat position on the project timeline where the note starts. This is NOT relative to the region — beat 6 means beat 6 in the project regardless of where the region begins. Fractional values are supported (e.g., 0.5 = half a beat after beat 0).',
required: true required: true
}, },
length: { length: {
type: 'number', type: 'number',
description: 'Note duration in beats (e.g., 1, 0.5, 4)', description: 'Duration of the note in beats. In 4/4 time: 4 = whole note, 2 = half note, 1 = quarter note, 0.5 = eighth note, 0.25 = sixteenth note.',
required: true required: true
}, },
velocity: { velocity: {
type: 'number', type: 'number',
description: 'Note velocity (1-127, default: 127)', description: 'Note velocity / loudness from 1 (softest) to 127 (loudest). Defaults to 127 if omitted.',
required: false required: false
} }
} }
@@ -48,7 +48,7 @@ export class AddNotesTool extends BaseTool {
}, },
region_id: { region_id: {
type: 'string', type: 'string',
description: 'ID of the region to add notes to. If not provided, uses the currently selected region.', description: 'Target region ID. If omitted, uses the currently active piano roll region or selected region.',
required: false required: false
} }
}; };
-49
View File
@@ -1,49 +0,0 @@
import { BaseTool } from './BaseTool';
import type { ToolResult, ToolParameter } from './BaseTool';
import { AgentCore } from '../core/AgentCore';
/**
* Tool for signaling task completion
* This is a pure agent state tool that doesn't modify the DAW but signals
* to the agent system that the user's requested task has been completed
*/
export class AttemptCompletionTool extends BaseTool {
readonly name = 'attempt_completion';
readonly description = 'Signal that the current user task is fully complete. Only use this when you have successfully fulfilled all aspects of the user\'s request.';
readonly parameters: Record<string, ToolParameter> = {
comment: {
type: 'string',
description: 'A brief comment describing what was completed and any relevant details about the task fulfillment.',
required: true
}
};
async execute(params: Record<string, unknown>): Promise<ToolResult> {
try {
// Validate parameters
this.validateParameters(params);
const comment = params.comment as string;
// Validate comment is not empty
if (!comment.trim()) {
return this.createErrorResult('Comment cannot be empty. Please provide a meaningful completion summary.');
}
// Get current agent state and update task completion status
const agentCore = AgentCore.instance();
const agentState = agentCore.getAgentState();
// Mark that we're no longer working on a task
agentState.setIsWorkingOnTask(false);
return this.createSuccessResult(
`Task completed: ${comment}. `
);
} catch (error) {
return this.createErrorResult(`Failed to mark task as complete: ${error}`);
}
}
}
+81 -3
View File
@@ -21,7 +21,28 @@ export interface ToolParameter {
} }
/** /**
* Tool definition schema * OpenAI-compatible JSON Schema for function parameters
*/
export interface OpenAIFunctionParameters {
type: 'object';
properties: Record<string, unknown>;
required?: string[];
}
/**
* OpenAI-compatible tool definition
*/
export interface OpenAIToolDefinition {
type: 'function';
function: {
name: string;
description: string;
parameters: OpenAIFunctionParameters;
};
}
/**
* Tool definition schema (internal format)
*/ */
export interface ToolDefinition { export interface ToolDefinition {
name: string; name: string;
@@ -48,14 +69,71 @@ export abstract class BaseTool {
/** /**
* Get the tool definition in OpenAI function calling format * Get the tool definition in OpenAI function calling format
*/ */
getDefinition(): ToolDefinition { getDefinition(): OpenAIToolDefinition {
return { return {
type: 'function',
function: {
name: this.name, name: this.name,
description: this.description, description: this.description,
parameters: this.parameters parameters: this.convertToJsonSchema(this.parameters)
}
}; };
} }
/**
* Convert internal ToolParameter map to OpenAI-compatible JSON Schema
*/
private convertToJsonSchema(params: Record<string, ToolParameter>): OpenAIFunctionParameters {
const properties: Record<string, unknown> = {};
const required: string[] = [];
for (const [name, param] of Object.entries(params)) {
properties[name] = this.convertParamToJsonSchema(param);
if (param.required) {
required.push(name);
}
}
return {
type: 'object',
properties,
...(required.length > 0 ? { required } : {})
};
}
/**
* Convert a single ToolParameter to JSON Schema format
*/
private convertParamToJsonSchema(param: ToolParameter): Record<string, unknown> {
const schema: Record<string, unknown> = {
type: param.type,
description: param.description
};
if (param.type === 'array' && param.items) {
schema.items = this.convertParamToJsonSchema(param.items);
}
if (param.type === 'object' && param.properties) {
const properties: Record<string, unknown> = {};
const required: string[] = [];
for (const [name, prop] of Object.entries(param.properties)) {
properties[name] = this.convertParamToJsonSchema(prop);
if (prop.required) {
required.push(name);
}
}
schema.properties = properties;
if (required.length > 0) {
schema.required = required;
}
}
return schema;
}
/** /**
* Validate parameters against the tool's parameter schema * Validate parameters against the tool's parameter schema
* @param params Parameters to validate * @param params Parameters to validate
+4 -4
View File
@@ -12,22 +12,22 @@ import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
*/ */
export class ReadMusicTool extends BaseTool { export class ReadMusicTool extends BaseTool {
readonly name = 'read_music'; readonly name = 'read_music';
readonly description = 'Read the music content from a specific track or all tracks, returning the content in ABC notation format.'; readonly description = 'Read existing musical content from one or more tracks, returned as ABC notation. Use this to understand what notes already exist before making edits. Always call this before asking the user about their music. The output is bar-aligned and includes key/time signature headers.';
readonly parameters: Record<string, ToolParameter> = { readonly parameters: Record<string, ToolParameter> = {
track_id: { track_id: {
type: 'string', type: 'string',
description: 'The track ID to read, or "all" to read all tracks. If not provided, reads the first available track.', description: 'Which track to read. Pass a specific track ID, or "all" to read every track. If omitted, reads the first available track.',
required: false required: false
}, },
start_beat: { start_beat: {
type: 'number', type: 'number',
description: 'Start beat position to read from (default: 0)', description: 'Absolute beat position to start reading from. The actual output will be rounded down to the nearest bar boundary. Defaults to 0.',
required: false required: false
}, },
length: { length: {
type: 'number', type: 'number',
description: 'Length in beats to read (default: entire track/project)', description: 'Number of beats to read. The actual output will be rounded up to the nearest bar boundary. If omitted, reads to the end of the track.',
required: false required: false
} }
}; };
+4 -4
View File
@@ -11,22 +11,22 @@ import { KGCore } from '../../core/KGCore';
*/ */
export class RemoveNotesTool extends BaseTool { export class RemoveNotesTool extends BaseTool {
readonly name = 'remove_notes'; readonly name = 'remove_notes';
readonly description = 'Remove MIDI notes from the current region within a specified beat range. All notes that start within the range will be deleted.'; readonly description = 'Remove all MIDI notes whose start position falls within the specified beat range. Use this to clear a section before rewriting it, or to delete unwanted notes. Beat positions are absolute on the project timeline.';
readonly parameters: Record<string, ToolParameter> = { readonly parameters: Record<string, ToolParameter> = {
start_beat: { start_beat: {
type: 'number', type: 'number',
description: 'Start of the beat range to remove notes from (inclusive)', description: 'Absolute beat position where the removal range begins (inclusive). A note starting at exactly this beat will be removed.',
required: true required: true
}, },
end_beat: { end_beat: {
type: 'number', type: 'number',
description: 'End of the beat range to remove notes from (exclusive)', description: 'Absolute beat position where the removal range ends (exclusive). A note starting at exactly this beat will NOT be removed. Must be greater than start_beat.',
required: true required: true
}, },
region_id: { region_id: {
type: 'string', type: 'string',
description: 'ID of the region to remove notes from. If not provided, uses the currently selected region.', description: 'Target region ID. If omitted, uses the currently active piano roll region or selected region.',
required: false required: false
} }
}; };
-35
View File
@@ -1,35 +0,0 @@
import { BaseTool } from './BaseTool';
import type { ToolResult, ToolParameter } from './BaseTool';
/**
* Pseudo tool for handling <think> tags in LLM responses
* This tool displays the thinking content in the UI but doesn't send results back to the LLM
* Handles XML format: <think>any content here</think>
* This is functionally identical to ThinkingTool but handles the shorter tag name
*/
export class ThinkTool extends BaseTool {
readonly name = 'think';
readonly description = 'Pseudo tool for handling LLM thinking content from <think> tags. Shows content in UI but does not send results back to LLM.';
readonly parameters: Record<string, ToolParameter> = {
content: {
type: 'string',
description: 'The thinking content from the XML tag',
required: false
}
};
async execute(params: Record<string, unknown>): Promise<ToolResult> {
try {
// Extract the thinking content from the parameters
// const content = params.content as string || '';
// Return the thinking content as a successful result
// This will be displayed in the UI but not sent back to the LLM
return this.createSuccessResult("Thinking completed.");
} catch (error) {
return this.createErrorResult(`Failed to process thinking content: ${error}`);
}
}
}
-34
View File
@@ -1,34 +0,0 @@
import { BaseTool } from './BaseTool';
import type { ToolResult, ToolParameter } from './BaseTool';
/**
* Pseudo tool for handling <thinking> tags in LLM responses
* This tool displays the thinking content in the UI but doesn't send results back to the LLM
* Handles XML format: <thinking>any content here</thinking>
*/
export class ThinkingTool extends BaseTool {
readonly name = 'thinking';
readonly description = 'Pseudo tool for handling LLM thinking content. Shows content in UI but does not send results back to LLM.';
readonly parameters: Record<string, ToolParameter> = {
content: {
type: 'string',
description: 'The thinking content from the XML tag',
required: false
}
};
async execute(params: Record<string, unknown>): Promise<ToolResult> {
try {
// Extract the thinking content from the parameters
// const content = params.content as string || '';
// Return the thinking content as a successful result
// This will be displayed in the UI but not sent back to the LLM
return this.createSuccessResult("Thinking completed.");
} catch (error) {
return this.createErrorResult(`Failed to process thinking content: ${error}`);
}
}
}
+2 -8
View File
@@ -1,25 +1,19 @@
// Base tool system // Base tool system
export { BaseTool } from './BaseTool'; export { BaseTool } from './BaseTool';
export type { ToolResult, ToolParameter, ToolDefinition } from './BaseTool'; export type { ToolResult, ToolParameter, ToolDefinition, OpenAIToolDefinition, OpenAIFunctionParameters } from './BaseTool';
// Specific tools // Specific tools
import { AddNotesTool } from './AddNotesTool'; import { AddNotesTool } from './AddNotesTool';
import { RemoveNotesTool } from './RemoveNotesTool'; import { RemoveNotesTool } from './RemoveNotesTool';
import { ReadMusicTool } from './ReadMusicTool'; import { ReadMusicTool } from './ReadMusicTool';
import { AttemptCompletionTool } from './AttemptCompletionTool';
import { ThinkingTool } from './ThinkingTool';
import { ThinkTool } from './ThinkTool';
export { AddNotesTool, RemoveNotesTool, ReadMusicTool, AttemptCompletionTool, ThinkingTool, ThinkTool }; export { AddNotesTool, RemoveNotesTool, ReadMusicTool };
// Tool registry for easy access // Tool registry for easy access
export const AVAILABLE_TOOLS = { export const AVAILABLE_TOOLS = {
add_notes: AddNotesTool, add_notes: AddNotesTool,
remove_notes: RemoveNotesTool, remove_notes: RemoveNotesTool,
read_music: ReadMusicTool, read_music: ReadMusicTool,
attempt_completion: AttemptCompletionTool,
thinking: ThinkingTool,
think: ThinkTool
} as const; } as const;
export type ToolName = keyof typeof AVAILABLE_TOOLS; export type ToolName = keyof typeof AVAILABLE_TOOLS;
+41 -153
View File
@@ -2,10 +2,6 @@ import React, { useState, useRef, useEffect, memo, useCallback } from 'react';
import { FaPlus, FaBan, FaDownload } from 'react-icons/fa'; import { FaPlus, FaBan, FaDownload } from 'react-icons/fa';
import { UserMessage, AssistantMessage } from './chat'; import { UserMessage, AssistantMessage } from './chat';
import { AgentCore } from '../agent/core/AgentCore'; import { AgentCore } from '../agent/core/AgentCore';
import { OpenAIProvider } from '../agent/llm/OpenAIProvider';
import { ClaudeProvider } from '../agent/llm/ClaudeProvider';
import { ClaudeOpenRouterProvider } from '../agent/llm/ClaudeOpenRouterProvider';
import { GeminiProvider } from '../agent/llm/GeminiProvider';
import { LLMProvider } from '../agent/llm/LLMProvider'; import { LLMProvider } from '../agent/llm/LLMProvider';
import { ConfigManager } from '../core/config/ConfigManager'; import { ConfigManager } from '../core/config/ConfigManager';
import { useProjectStore } from '../stores/projectStore'; import { useProjectStore } from '../stores/projectStore';
@@ -14,10 +10,8 @@ import { clearChatHistoryAndUI, registerClearChatUICallback } from '../util/chat
import { processUserMessage } from '../util/messageFilter/UserMessageFilter'; import { processUserMessage } from '../util/messageFilter/UserMessageFilter';
import { useStreamProcessor } from '../hooks/useStreamProcessor'; import { useStreamProcessor } from '../hooks/useStreamProcessor';
import { createMessage, addWelcomeMessage } from '../utils/chatMessageUtils'; import { createMessage, addWelcomeMessage } from '../utils/chatMessageUtils';
import { extractActionableTools, executeAllTools } from '../utils/toolExecutionUtils';
import { formatLocalDateTime } from '../util/timeUtil'; import { formatLocalDateTime } from '../util/timeUtil';
import { downloadBlob, buildTimestampSuffix } from '../util/miscUtil'; import { downloadBlob, buildTimestampSuffix } from '../util/miscUtil';
import { wrapXmlBlocksInContent } from '../util/xmlUtil';
import KGDropdown from './common/KGDropdown'; import KGDropdown from './common/KGDropdown';
import type { ChatMessage } from '../types/projectTypes'; import type { ChatMessage } from '../types/projectTypes';
@@ -26,24 +20,31 @@ import type { ChatMessage } from '../types/projectTypes';
let hasShownWelcomeOnceInRuntime = false; let hasShownWelcomeOnceInRuntime = false;
/** /**
* Create the appropriate LLM provider based on configuration * Create the LLM provider from current configuration
*/ */
const createLLMProvider = (): LLMProvider => { const createLLMProviderFromConfig = (): LLMProvider => {
const configManager = ConfigManager.instance(); const configManager = ConfigManager.instance();
const providerType = configManager.get('general.llm_provider') as string; const providerType = configManager.get('general.llm_provider') as string;
let apiKey: string;
let model: string;
let baseURL: string | undefined;
switch (providerType) { switch (providerType) {
case 'claude':
return new ClaudeProvider();
case 'gemini':
return new GeminiProvider();
case 'claude_openrouter':
return new ClaudeOpenRouterProvider();
case 'openai_compatible':
case 'openai': case 'openai':
apiKey = configManager.get('general.openai.api_key') as string;
model = configManager.get('general.openai.model') as string;
baseURL = undefined; // Uses OpenAI default
break;
case 'openai_compatible':
default: default:
return new OpenAIProvider(); apiKey = configManager.get('general.openai_compatible.api_key') as string;
model = configManager.get('general.openai_compatible.model') as string;
baseURL = configManager.get('general.openai_compatible.base_url') as string || undefined;
break;
} }
return new LLMProvider(apiKey, model, baseURL);
}; };
interface ChatBoxProps { interface ChatBoxProps {
@@ -54,16 +55,10 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
// Initialize with empty messages
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [lastUserMessage, setLastUserMessage] = useState<string>(''); const [lastUserMessage, setLastUserMessage] = useState<string>('');
// Tool execution state
const [isExecutingTools, setIsExecutingTools] = useState(false);
const [, setToolResults] = useState<string>(''); // placeholder for future display/use
const [, setCurrentToolIndex] = useState<number>(0); // placeholder for future display/use
// Track if this is the first message (for system prompt logging) // Track if this is the first message (for system prompt logging)
const [isFirstMessage, setIsFirstMessage] = useState(true); const [isFirstMessage, setIsFirstMessage] = useState(true);
@@ -77,8 +72,8 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
const handleExportOptionSelect = (option: string) => { const handleExportOptionSelect = (option: string) => {
if (option === 'Export conversation as JSON') { if (option === 'Export conversation as JSON') {
try { try {
const messages = AgentCore.instance().getAgentState().getMessages(); const agentMessages = AgentCore.instance().getAgentState().getMessages();
const exportMessages = messages.map((m) => ({ const exportMessages = agentMessages.map((m) => ({
id: m.id, id: m.id,
role: m.role, role: m.role,
content: m.content, content: m.content,
@@ -93,24 +88,20 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
} else if (option === 'Export conversation as Markdown') { } else if (option === 'Export conversation as Markdown') {
(async () => { (async () => {
try { try {
const messages = AgentCore.instance().getAgentState().getMessages(); const agentMessages = AgentCore.instance().getAgentState().getMessages();
const templateUrl = `${import.meta.env.BASE_URL}chat/export_conversation_template.md`; const templateUrl = `${import.meta.env.BASE_URL}chat/export_conversation_template.md`;
const res = await fetch(templateUrl); const res = await fetch(templateUrl);
const template = await res.text(); const template = await res.text();
const isAutomatedUserMessage = (content: string): boolean => { const sections = agentMessages
return /^tool:\s.*\nsuccess:\s*(true|false)/i.test(content); .filter(m => m.role === 'user' || m.role === 'assistant')
}; .map((m) => {
const roleLabel = m.role === 'assistant' ? 'Assistant' : 'User';
const sections = messages.map((m) => {
const isAutomaticUserMessage = isAutomatedUserMessage(m.content);
const roleLabel = m.role === 'assistant' ? 'Assistant' : (isAutomaticUserMessage ? 'User (Automatic)' : 'User');
const ts = formatLocalDateTime(new Date(m.timestamp)); const ts = formatLocalDateTime(new Date(m.timestamp));
const contentWithXml = isAutomaticUserMessage ? "```\n" + m.content + "\n```" : wrapXmlBlocksInContent(m.content);
return template return template
.replace('{role}', roleLabel) .replace('{role}', roleLabel)
.replace('{timestamp}', ts) .replace('{timestamp}', ts)
.replace('{content}', contentWithXml); .replace('{content}', m.content ?? '');
}); });
const markdown = sections.join('\n'); const markdown = sections.join('\n');
@@ -120,8 +111,6 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
console.error('Failed to export conversation as Markdown:', err); console.error('Failed to export conversation as Markdown:', err);
} }
})(); })();
} else {
console.log('Chat export selected:', option);
} }
setShowExportDropdown(false); setShowExportDropdown(false);
}; };
@@ -135,6 +124,10 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
setMessages(prev => [...prev, message]); setMessages(prev => [...prev, message]);
}, []); }, []);
const handleMessageRemove = useCallback((messageId: string) => {
setMessages(prev => prev.filter(msg => msg.id !== messageId));
}, []);
const handleProcessingChange = useCallback((processing: boolean) => { const handleProcessingChange = useCallback((processing: boolean) => {
setIsProcessing(processing); setIsProcessing(processing);
}, []); }, []);
@@ -143,17 +136,14 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
const streamProcessor = useStreamProcessor({ const streamProcessor = useStreamProcessor({
onMessageUpdate: handleMessageUpdate, onMessageUpdate: handleMessageUpdate,
onMessageAdd: handleMessageAdd, onMessageAdd: handleMessageAdd,
onMessageRemove: handleMessageRemove,
onProcessingChange: handleProcessingChange onProcessingChange: handleProcessingChange
}); });
const clearChatUI = useCallback(async () => { const clearChatUI = useCallback(async () => {
// Clear UI state
setMessages([]); setMessages([]);
// Reset first message flag so system prompt will be logged again
setIsFirstMessage(true); setIsFirstMessage(true);
// Auto-show welcome message after clearing (like on app startup)
const welcomeMessage = await addWelcomeMessage(); const welcomeMessage = await addWelcomeMessage();
if (welcomeMessage) { if (welcomeMessage) {
setMessages([welcomeMessage]); setMessages([welcomeMessage]);
@@ -165,46 +155,36 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
const initializeProvider = async () => { const initializeProvider = async () => {
const configManager = ConfigManager.instance(); const configManager = ConfigManager.instance();
// Ensure ConfigManager is initialized
if (!configManager.getIsInitialized()) { if (!configManager.getIsInitialized()) {
await configManager.initialize(); await configManager.initialize();
} }
const applyProviderFromConfig = () => { const applyProviderFromConfig = () => {
const provider = createLLMProvider(); const provider = createLLMProviderFromConfig();
const agentCore = AgentCore.instance(); const agentCore = AgentCore.instance();
agentCore.setLLMProvider(provider); agentCore.setLLMProvider(provider);
console.log(`Switched to ${provider.name} provider`); console.log('LLM provider configured');
}; };
// Initial apply
applyProviderFromConfig(); applyProviderFromConfig();
// Subscribe to config changes to hot-swap providers
const unsubscribe = configManager.addChangeListener((changedKeys) => { const unsubscribe = configManager.addChangeListener((changedKeys) => {
// Hot-swap on provider change or when relevant provider config changes
if ( if (
changedKeys.includes('general.llm_provider') || changedKeys.includes('general.llm_provider') ||
changedKeys.some(k => k.startsWith('general.openai.')) || changedKeys.some(k => k.startsWith('general.openai.')) ||
changedKeys.some(k => k.startsWith('general.openai_compatible.')) || changedKeys.some(k => k.startsWith('general.openai_compatible.'))
changedKeys.some(k => k.startsWith('general.claude_openrouter.')) ||
changedKeys.some(k => k.startsWith('general.gemini.')) ||
changedKeys.some(k => k.startsWith('general.claude.'))
) { ) {
applyProviderFromConfig(); applyProviderFromConfig();
} }
}); });
// Cleanup subscription on unmount
return unsubscribe; return unsubscribe;
}; };
// Register the UI clear callback for external components to use
registerClearChatUICallback(clearChatUI); registerClearChatUICallback(clearChatUI);
const maybeUnsubscribePromise = initializeProvider(); const maybeUnsubscribePromise = initializeProvider();
// Auto-trigger welcome on first launch (guard against React StrictMode double-invoke only)
(async () => { (async () => {
if (hasShownWelcomeOnceInRuntime) return; if (hasShownWelcomeOnceInRuntime) return;
hasShownWelcomeOnceInRuntime = true; hasShownWelcomeOnceInRuntime = true;
@@ -214,7 +194,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
setMessages([welcomeMessage]); setMessages([welcomeMessage]);
} }
})(); })();
// In case initializeProvider returned a cleanup, ensure we call it
return () => { return () => {
Promise.resolve(maybeUnsubscribePromise).then((cleanup) => { Promise.resolve(maybeUnsubscribePromise).then((cleanup) => {
if (typeof cleanup === 'function') cleanup(); if (typeof cleanup === 'function') cleanup();
@@ -227,16 +207,11 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
if (controller) { if (controller) {
controller.abort(); controller.abort();
// Use AgentCore to clean up the data model and get the user message content
const agentCore = AgentCore.instance(); const agentCore = AgentCore.instance();
const userMessageContent = agentCore.abortCurrentRequest(); const userMessageContent = agentCore.abortCurrentRequest();
// Remove the last user message and assistant message from UI
setMessages(prev => prev.slice(0, -2)); setMessages(prev => prev.slice(0, -2));
// Restore the user's input (use the content from AgentCore if available)
setInputValue(userMessageContent || lastUserMessage); setInputValue(userMessageContent || lastUserMessage);
setIsProcessing(false); setIsProcessing(false);
} }
}; };
@@ -246,101 +221,29 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
clearChatHistoryAndUI(setStatus); clearChatHistoryAndUI(setStatus);
}; };
const executeToolsFromResponse = async (response: string): Promise<boolean> => {
try {
const actionableBlocks = extractActionableTools(response);
if (actionableBlocks.length === 0) {
// No actionable tools to execute, stop the loop
return false;
}
// Start tool execution phase
setIsExecutingTools(true);
setToolResults('');
setCurrentToolIndex(0);
const { setStatus } = useProjectStore.getState();
// Execute all tools and get accumulated results
const accumulatedResults = await executeAllTools(actionableBlocks, {
onMessageAdd: handleMessageAdd,
onStatusUpdate: setStatus
});
// Store accumulated results
setToolResults(accumulatedResults);
setIsExecutingTools(false);
// Check if agent is still working on task before sending results to LLM
const agentCore = AgentCore.instance();
const isStillWorkingOnTask = agentCore.getAgentState().getIsWorkingOnTask();
if (isStillWorkingOnTask) {
// Send tool results back to LLM
setStatus('Processing tool results...');
await sendToolResultsToLLM(accumulatedResults);
} else {
// Agent is no longer working on task, ignore results and return control to user
setStatus('Tool execution completed');
}
return true; // Tools were found and executed
} catch (error) {
console.error('Error executing tools:', error);
setIsExecutingTools(false);
const { setStatus } = useProjectStore.getState();
setStatus(`Tool execution failed: ${error}`);
return false; // Tool execution failed
}
};
const sendToolResultsToLLM = async (toolResultsString: string): Promise<void> => {
// Process tool results through the stream processor
const assistantResponse = await streamProcessor.processStream(toolResultsString, 'TOOL_RESULTS');
// Check if the new response contains more tools
const hasMoreTools = await executeToolsFromResponse(assistantResponse);
// If no more tools were found, set working flag to false
if (!hasMoreTools) {
const agentCore = AgentCore.instance();
agentCore.getAgentState().setIsWorkingOnTask(false);
}
};
const handleSend = async () => { const handleSend = async () => {
if (inputValue.trim() && !isProcessing) { if (inputValue.trim() && !isProcessing) {
const userMessage = inputValue.trim(); const userMessage = inputValue.trim();
setLastUserMessage(userMessage); setLastUserMessage(userMessage);
setInputValue(''); setInputValue('');
// Run message through the filter system
const filterResult = await processUserMessage(userMessage); const filterResult = await processUserMessage(userMessage);
// Conditionally show the user message bubble
if (filterResult.displayUserMessage) { if (filterResult.displayUserMessage) {
const userMsgObject = createMessage('user', userMessage); const userMsgObject = createMessage('user', userMessage);
handleMessageAdd(userMsgObject); handleMessageAdd(userMsgObject);
} }
// If we have a pseudo assistant response, show it immediately
if (filterResult.pseudoAssistantResponse) { if (filterResult.pseudoAssistantResponse) {
const pseudoMessage = createMessage('assistant', filterResult.pseudoAssistantResponse); const pseudoMessage = createMessage('assistant', filterResult.pseudoAssistantResponse);
handleMessageAdd(pseudoMessage); handleMessageAdd(pseudoMessage);
} }
// If we shouldn't send anything to LLM, stop here
if (!filterResult.sendToLLM || !filterResult.finalMessageForLLM) { if (!filterResult.sendToLLM || !filterResult.finalMessageForLLM) {
return; return;
} }
// Set working on task flag when user sends a message // Log system prompt only for first message
const agentCore = AgentCore.instance();
agentCore.getAgentState().setIsWorkingOnTask(true);
// Log system prompt only for first message or first message after clear
if (isFirstMessage) { if (isFirstMessage) {
try { try {
const systemPrompt = await SystemPrompts.getSystemPromptWithContext(); const systemPrompt = await SystemPrompts.getSystemPromptWithContext();
@@ -350,20 +253,11 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
} catch (error) { } catch (error) {
console.error('Failed to log system prompt:', error); console.error('Failed to log system prompt:', error);
} }
// Mark that we've logged the system prompt for this conversation
setIsFirstMessage(false); setIsFirstMessage(false);
} }
// Process user input through the stream processor // Process through stream processor — AgentCore handles the full agentic loop internally
const assistantResponse = await streamProcessor.processStream(filterResult.finalMessageForLLM, 'USER'); await streamProcessor.processStream(filterResult.finalMessageForLLM, 'USER');
// Check if response contains tools to execute
const hasTools = await executeToolsFromResponse(assistantResponse);
// If no tools were found, set working flag to false and return control to user
if (!hasTools) {
agentCore.getAgentState().setIsWorkingOnTask(false);
}
} }
}; };
@@ -372,18 +266,15 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
e.preventDefault(); e.preventDefault();
handleSend(); handleSend();
} }
// Allow Shift+Enter for new lines (default textarea behavior)
}; };
const handleInputFocus = () => { const handleInputFocus = () => {
// Set a data attribute on the textarea to help global keyboard handler identify it
if (textareaRef.current) { if (textareaRef.current) {
textareaRef.current.setAttribute('data-chatbox-input', 'true'); textareaRef.current.setAttribute('data-chatbox-input', 'true');
} }
}; };
const handleInputBlur = () => { const handleInputBlur = () => {
// Remove the data attribute when losing focus
if (textareaRef.current) { if (textareaRef.current) {
textareaRef.current.removeAttribute('data-chatbox-input'); textareaRef.current.removeAttribute('data-chatbox-input');
} }
@@ -403,18 +294,15 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]); }, [messages]);
// Auto-focus input when it becomes visible (when processing and tool execution complete) // Auto-focus input when processing completes
useEffect(() => { useEffect(() => {
if (!isProcessing && !isExecutingTools && textareaRef.current) { if (!isProcessing && textareaRef.current) {
// Use a small delay to ensure the DOM has updated
setTimeout(() => { setTimeout(() => {
textareaRef.current?.focus(); textareaRef.current?.focus();
// Also scroll to bottom when input becomes visible after tool execution
// This ensures proper scroll position after layout changes from showing input box
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, 0); }, 0);
} }
}, [isProcessing, isExecutingTools]); }, [isProcessing]);
return ( return (
<div className={`chatbox ${isVisible ? '' : 'is-hidden'}`}> <div className={`chatbox ${isVisible ? '' : 'is-hidden'}`}>
@@ -480,7 +368,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
{!isProcessing && !isExecutingTools && ( {!isProcessing && (
<div className="chatbox-input-area"> <div className="chatbox-input-area">
<textarea <textarea
ref={textareaRef} ref={textareaRef}
+8 -128
View File
@@ -1,39 +1,8 @@
import React, { memo, useState } from 'react'; import React, { memo } from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { extractXMLFromString } from '../../util/xmlUtil';
interface ToolXMLExpanderProps {
toolName: string;
xmlContent: string;
}
const ToolXMLExpander: React.FC<ToolXMLExpanderProps> = ({ toolName, xmlContent }) => {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className="tool-xml-expander">
<div
className="tool-xml-expander-header"
onClick={() => setIsExpanded(!isExpanded)}
>
<span className="tool-xml-expander-arrow">
{isExpanded ? '▼' : '▶'}
</span>
<span className="tool-xml-expander-title">
🔧 Tool: {toolName}
</span>
</div>
{isExpanded && (
<div className="tool-xml-expander-content">
{xmlContent}
</div>
)}
</div>
);
};
interface AssistantMessageProps { interface AssistantMessageProps {
content: string; content: string;
@@ -62,68 +31,14 @@ const CodeComponent = memo(({ inline, className, children, ...props }: any) => {
}); });
const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreaming, onAbort }) => { const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreaming, onAbort }) => {
// Function to process content and replace XML blocks with expanders
const processContentWithXMLExpanders = (text: string) => {
const xmlBlocks = extractXMLFromString(text);
if (xmlBlocks.length === 0) {
// No XML blocks found, return content as-is
return text;
}
let processedContent = text;
const expanders: React.ReactElement[] = [];
let expanderIndex = 0;
// Replace each XML block with a placeholder
xmlBlocks.forEach((xmlBlock) => {
const toolNameMatch = xmlBlock.match(/<([a-zA-Z_][a-zA-Z0-9_-]*)/);
const toolName = toolNameMatch ? toolNameMatch[1] : 'unknown_tool';
const placeholder = `__XML_EXPANDER_${expanderIndex}__`;
processedContent = processedContent.replace(xmlBlock, placeholder);
expanders[expanderIndex] = (
<ToolXMLExpander
key={`xml-expander-${expanderIndex}`}
toolName={toolName}
xmlContent={xmlBlock}
/>
);
expanderIndex++;
});
// Split content by placeholders and interleave with expanders
const parts = processedContent.split(/__XML_EXPANDER_\d+__/);
const result: (string | React.ReactElement)[] = [];
for (let i = 0; i < parts.length; i++) {
if (parts[i]) {
result.push(parts[i]);
}
if (i < expanders.length) {
result.push(expanders[i]);
}
}
return result;
};
// Handle special abort link for streaming messages
const renderContent = () => { const renderContent = () => {
// Handle special abort link for streaming messages
if (isStreaming && onAbort && content.includes('click here to abort')) { if (isStreaming && onAbort && content.includes('click here to abort')) {
// Check if content has the processing wave HTML
const hasProcessingWave = content.includes('<span class="processing-wave">Processing...</span>'); const hasProcessingWave = content.includes('<span class="processing-wave">Processing...</span>');
if (hasProcessingWave) { if (hasProcessingWave) {
// Parse the content to handle both the wave animation and abort link
const parts = content.split('click here to abort'); const parts = content.split('click here to abort');
const beforeAbort = parts[0]; const beforeAbort = parts[0].replace(
const afterAbort = parts[1];
// Replace the HTML span with JSX
const processedBefore = beforeAbort.replace(
'<span class="processing-wave">Processing...</span>', '<span class="processing-wave">Processing...</span>',
'' ''
); );
@@ -131,26 +46,19 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreamin
return ( return (
<span> <span>
<span className="processing-wave">Processing...</span> <span className="processing-wave">Processing...</span>
{processedBefore} {beforeAbort}
<button <button onClick={onAbort} className="abort-link">
onClick={onAbort}
className="abort-link"
>
click here to abort click here to abort
</button> </button>
{afterAbort} {parts[1]}
</span> </span>
); );
} else { } else {
// Original logic for non-wave processing messages
const parts = content.split('click here to abort'); const parts = content.split('click here to abort');
return ( return (
<span> <span>
{parts[0]} {parts[0]}
<button <button onClick={onAbort} className="abort-link">
onClick={onAbort}
className="abort-link"
>
click here to abort click here to abort
</button> </button>
{parts[1]} {parts[1]}
@@ -159,34 +67,6 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreamin
} }
} }
const processedContent = processContentWithXMLExpanders(content);
// If we have mixed content (text + React elements), render them separately
if (Array.isArray(processedContent)) {
return (
<div>
{processedContent.map((item, index) => {
if (typeof item === 'string') {
return (
<ReactMarkdown
key={`text-${index}`}
remarkPlugins={[remarkGfm]}
components={{
code: CodeComponent,
}}
>
{item}
</ReactMarkdown>
);
} else {
return item; // React element (expander)
}
})}
</div>
);
}
// Plain text content, render with markdown
return ( return (
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
@@ -194,7 +74,7 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreamin
code: CodeComponent, code: CodeComponent,
}} }}
> >
{processedContent as string} {content}
</ReactMarkdown> </ReactMarkdown>
); );
}; };
@@ -368,7 +368,7 @@ const GeneralSettings: React.FC = () => {
<input <input
type="text" type="text"
className="settings-input" className="settings-input"
placeholder="e.g. https://openrouter.ai/api/v1/chat/completions" placeholder="e.g. https://openrouter.ai/api/v1"
value={claudeOpenRouterBaseUrl} value={claudeOpenRouterBaseUrl}
onChange={(e) => handleClaudeOpenRouterBaseUrlChange(e.target.value)} onChange={(e) => handleClaudeOpenRouterBaseUrlChange(e.target.value)}
/> />
@@ -424,7 +424,7 @@ const GeneralSettings: React.FC = () => {
<input <input
type="text" type="text"
className="settings-input" className="settings-input"
placeholder="e.g. https://openrouter.ai/api/v1/chat/completions" placeholder="e.g. https://openrouter.ai/api/v1"
value={compatibleBaseUrl} value={compatibleBaseUrl}
onChange={(e) => handleCompatibleBaseUrlChange(e.target.value)} onChange={(e) => handleCompatibleBaseUrlChange(e.target.value)}
/> />
+65 -123
View File
@@ -7,9 +7,8 @@ import { KGCore } from './KGCore';
import { KGMidiRegion } from './region/KGMidiRegion'; import { KGMidiRegion } from './region/KGMidiRegion';
import { convertRegionToABCNotation } from '../util/abcNotationUtil'; import { convertRegionToABCNotation } from '../util/abcNotationUtil';
import { extractXMLFromString } from '../util/xmlUtil'; import { extractXMLFromString } from '../util/xmlUtil';
import { XMLToolExecutor } from '../agent/core/XMLToolExecutor';
import { AgentCore } from '../agent/core/AgentCore'; import { AgentCore } from '../agent/core/AgentCore';
import { AttemptCompletionTool } from '../agent/tools/AttemptCompletionTool'; import { AVAILABLE_TOOLS } from '../agent/tools';
import type { TimeSignature } from '../types/projectTypes'; import type { TimeSignature } from '../types/projectTypes';
import { useProjectStore } from '../stores/projectStore'; import { useProjectStore } from '../stores/projectStore';
@@ -28,8 +27,7 @@ export class KGDebugger {
'debugSelectedItems()', 'debugSelectedItems()',
'createTestRegion()', 'createTestRegion()',
'testExtractXMLFromString(input)', 'testExtractXMLFromString(input)',
'testXMLToolExecution(input)', 'testToolCall(jsonInput)',
'testAttemptCompletion(comment)',
'inputChatBox(content, interval?)' 'inputChatBox(content, interval?)'
]); ]);
} }
@@ -264,129 +262,72 @@ export class KGDebugger {
} }
/** /**
* Test the complete XML tool execution pipeline * Test native tool calling by executing tool calls from a JSON string.
* @param input - String containing XML tool invocations to execute * Accepts a single tool call object or an array of tool call objects.
*
* Usage examples in browser console:
*
* // Single tool call:
* await KGStudio.KGDebugger.testToolCall('{"name":"read_music","arguments":{"start_beat":0,"length":8}}')
*
* // Multiple tool calls:
* await KGStudio.KGDebugger.testToolCall('[{"name":"remove_notes","arguments":{"start_beat":0,"end_beat":4}},{"name":"add_notes","arguments":{"notes":[{"pitch":"C4","start_beat":0,"length":1}]}}]')
*
* // Can also pass a JS object directly (no need to stringify):
* await KGStudio.KGDebugger.testToolCall({name:"read_music",arguments:{start_beat:0}})
*
* @param input - JSON string, object, or array of tool call(s).
* Each tool call should have: { name: string, arguments: object }
*/ */
public async testXMLToolExecution(input: string): Promise<void> { public async testToolCall(input: string | Record<string, unknown> | Record<string, unknown>[]): Promise<void> {
console.log('------------ ASSISTANT ------------');
console.log(input);
console.log('-----------------------------------');
try { try {
// Extract XML blocks first to get tool names (same logic as ChatBox) // Parse input
const xmlBlocks = extractXMLFromString(input); let calls: Array<{ name: string; arguments: Record<string, unknown> }>;
if (xmlBlocks.length === 0) { if (typeof input === 'string') {
console.log('------------ USER ------------'); const parsed = JSON.parse(input);
console.log('No XML tool invocations found in the input string.'); calls = Array.isArray(parsed) ? parsed : [parsed];
console.log('------------------------------'); } else if (Array.isArray(input)) {
return; calls = input as Array<{ name: string; arguments: Record<string, unknown> }>;
}
const executor = XMLToolExecutor.instance();
let accumulatedResults = '';
// Execute tools sequentially and format like ChatBox
for (let i = 0; i < xmlBlocks.length; i++) {
// Determine tool name from XML block (same as ChatBox lines 148-149)
const toolNameMatch = xmlBlocks[i].match(/<([a-zA-Z_][a-zA-Z0-9_-]*)/);
const toolName = toolNameMatch ? toolNameMatch[1] : 'unknown_tool';
try {
// Execute single XML block
const results = await executor.executeXMLTools(xmlBlocks[i]);
const result = results[0]; // Single block should give single result
if (result) {
// Format exactly like ChatBox lines 161-162
const formattedResult = `tool: ${toolName}\nsuccess: ${result.success}\nresult:\n${result.result}\n------------\n`;
accumulatedResults += formattedResult;
}
} catch (error) {
// Handle individual tool error (same format)
const formattedResult = `tool: ${toolName}\nsuccess: false\nresult:\nTool execution failed: ${error}\n------------\n`;
accumulatedResults += formattedResult;
}
}
// Log accumulated results as USER (what gets sent back to LLM)
console.log('------------ USER ------------');
console.log(accumulatedResults);
console.log('------------------------------');
// Copy results to clipboard if possible
if (navigator.clipboard) {
navigator.clipboard.writeText(accumulatedResults).then(() => {
console.log("Tool execution results copied to clipboard!");
}).catch(() => {
console.log("Could not copy to clipboard (requires HTTPS)");
});
}
} catch (error) {
console.log('------------ USER ------------');
console.log(`Error testing XML tool execution: ${error}`);
console.log('------------------------------');
}
}
/**
* Test the AttemptCompletionTool with agent state integration
* @param comment - Completion comment to test with
*/
public async testAttemptCompletion(comment: string): Promise<void> {
console.log("🎯 Testing AttemptCompletionTool...");
console.log(`📝 Comment: "${comment}"`);
try {
// Get current agent state before test
const agentCore = AgentCore.instance();
const agentState = agentCore.getAgentState();
const initialTaskState = agentState.getIsWorkingOnTask();
console.log(`📊 Initial agent state:`);
console.log(` • isWorkingOnTask: ${initialTaskState}`);
// Set to working state to test the completion properly
if (!initialTaskState) {
console.log("🔄 Setting isWorkingOnTask to true for testing...");
agentState.setIsWorkingOnTask(true);
}
// Create and execute the tool
const completionTool = new AttemptCompletionTool();
const result = await completionTool.execute({ comment });
console.log(`✅ Tool execution result:`);
console.log(` • Success: ${result.success}`);
console.log(` • Result: ${result.result}`);
// Check final agent state
const finalTaskState = agentState.getIsWorkingOnTask();
console.log(`📊 Final agent state:`);
console.log(` • isWorkingOnTask: ${finalTaskState}`);
// Verify state change
if (result.success && finalTaskState === false) {
console.log("🎉 Success! Agent state correctly updated to not working on task.");
} else if (!result.success) {
console.log("⚠️ Tool execution failed - state may not have changed.");
} else { } else {
console.log("⚠️ Warning: State did not change as expected."); calls = [input as { name: string; arguments: Record<string, unknown> }];
} }
// Copy result to clipboard if possible console.log(`🔧 Executing ${calls.length} tool call(s)...\n`);
if (navigator.clipboard) {
const clipboardContent = JSON.stringify(result, null, 2); for (let i = 0; i < calls.length; i++) {
navigator.clipboard.writeText(clipboardContent).then(() => { const call = calls[i];
console.log("📋 Test results copied to clipboard!"); const toolName = call.name;
}).catch(() => { const toolArgs = call.arguments ?? {};
console.log("📋 Could not copy to clipboard (requires HTTPS)");
}); console.log(`── Tool call ${i + 1}/${calls.length}: ${toolName}`);
console.log(` Arguments: ${JSON.stringify(toolArgs, null, 2)}`);
const ToolClass = AVAILABLE_TOOLS[toolName as keyof typeof AVAILABLE_TOOLS];
if (!ToolClass) {
console.error(` ❌ Unknown tool: "${toolName}". Available tools: ${Object.keys(AVAILABLE_TOOLS).join(', ')}`);
continue;
} }
const toolInstance = new ToolClass();
const result = await toolInstance.execute(toolArgs);
// Sync UI state on success
if (result.success) {
useProjectStore.getState().refreshProjectState();
}
const icon = result.success ? '✅' : '❌';
console.log(` ${icon} Success: ${result.success}`);
console.log(` Result: ${result.result}\n`);
}
console.log('🔧 Tool execution complete.');
} catch (error) { } catch (error) {
console.error("❌ Error testing AttemptCompletionTool:", error); console.error('❌ Error in testToolCall:', error);
console.log('💡 Expected format: {"name":"tool_name","arguments":{...}}');
console.log(' Or an array: [{"name":"tool1","arguments":{...}}, ...]');
} }
} }
@@ -401,8 +342,7 @@ export class KGDebugger {
console.log(" debugSelectedItems() - Show info about selected items"); console.log(" debugSelectedItems() - Show info about selected items");
console.log(" createTestRegion() - Create test region (not implemented)"); console.log(" createTestRegion() - Create test region (not implemented)");
console.log(" testExtractXMLFromString(input) - Test XML extraction from string"); console.log(" testExtractXMLFromString(input) - Test XML extraction from string");
console.log(" testXMLToolExecution(input) - Test complete XML tool execution pipeline"); console.log(" testToolCall(input) - Execute tool call(s) from JSON and show results");
console.log(" testAttemptCompletion(comment) - Test AttemptCompletionTool with agent state");
console.log(" inputChatBox(content, interval?) - Type into ChatBox textarea and submit with Enter"); console.log(" inputChatBox(content, interval?) - Type into ChatBox textarea and submit with Enter");
console.log(" help() - Show this help"); console.log(" help() - Show this help");
console.log(""); console.log("");
@@ -410,9 +350,11 @@ export class KGDebugger {
console.log(" - Select regions in the DAW first, then run debug methods"); console.log(" - Select regions in the DAW first, then run debug methods");
console.log(" - Results are logged to console and copied to clipboard when possible"); console.log(" - Results are logged to console and copied to clipboard when possible");
console.log(" - Use browser developer tools for best experience"); console.log(" - Use browser developer tools for best experience");
console.log(" - For XML testing, try: testExtractXMLFromString('I will <add_notes><note>...</note></add_notes> create notes');"); console.log("");
console.log(" - For full tool execution, try: await testXMLToolExecution('Create notes: <add_notes><note><pitch>C4</pitch><start_beat>0</start_beat><length>1</length></note></add_notes>');"); console.log("💡 testToolCall examples:");
console.log(" - For completion testing, try: await testAttemptCompletion('Successfully created a C major chord');"); console.log(' await KGStudio.KGDebugger.testToolCall(\'{"name":"read_music","arguments":{"start_beat":0,"length":8}}\')');
console.log(' await KGStudio.KGDebugger.testToolCall({name:"add_notes",arguments:{notes:[{pitch:"C4",start_beat:0,length:1}]}})');
console.log(' await KGStudio.KGDebugger.testToolCall([{name:"remove_notes",arguments:{start_beat:0,end_beat:4}},{name:"read_music",arguments:{}}])');
} }
/** /**
+1 -1
View File
@@ -190,7 +190,7 @@ export class ConfigManager {
}, },
claude_openrouter: { claude_openrouter: {
api_key: '', api_key: '',
base_url: 'https://openrouter.ai/api/v1/chat/completions', base_url: 'https://openrouter.ai/api/v1',
model: 'anthropic/claude-sonnet-4.5' model: 'anthropic/claude-sonnet-4.5'
}, },
openai_compatible: { openai_compatible: {
+61 -28
View File
@@ -1,11 +1,12 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { AgentCore } from '../agent/core/AgentCore'; import { AgentCore } from '../agent/core/AgentCore';
import { createStreamingMessage } from '../utils/chatMessageUtils'; import { createStreamingMessage, createMessage } from '../utils/chatMessageUtils';
import type { ChatMessage } from '../types/projectTypes'; import type { ChatMessage } from '../types/projectTypes';
interface StreamProcessorOptions { interface StreamProcessorOptions {
onMessageUpdate: (messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void; onMessageUpdate: (messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
onMessageAdd: (message: ChatMessage) => void; onMessageAdd: (message: ChatMessage) => void;
onMessageRemove: (messageId: string) => void;
onProcessingChange: (isProcessing: boolean) => void; onProcessingChange: (isProcessing: boolean) => void;
} }
@@ -16,7 +17,7 @@ interface StreamProcessorResult {
} }
export const useStreamProcessor = (options: StreamProcessorOptions): StreamProcessorResult => { export const useStreamProcessor = (options: StreamProcessorOptions): StreamProcessorResult => {
const { onMessageUpdate, onMessageAdd, onProcessingChange } = options; const { onMessageUpdate, onMessageAdd, onMessageRemove, onProcessingChange } = options;
const [abortController, setAbortController] = useState<AbortController | null>(null); const [abortController, setAbortController] = useState<AbortController | null>(null);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
@@ -24,27 +25,24 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce
setIsProcessing(true); setIsProcessing(true);
onProcessingChange(true); onProcessingChange(true);
// Create abort controller for this request
const controller = new AbortController(); const controller = new AbortController();
setAbortController(controller); setAbortController(controller);
// Add streaming assistant message // Track the current streaming message ID (mutable)
const streamingMessage = createStreamingMessage(); let currentStreamingId = createStreamingMessage().id;
onMessageAdd(streamingMessage); onMessageAdd({ id: currentStreamingId, role: 'assistant', content: '', isStreaming: true, tokenCount: 0 } as ChatMessage);
try { try {
const agentCore = AgentCore.instance(); const agentCore = AgentCore.instance();
let assistantResponse = ''; let assistantResponse = '';
let tokenCount = 0; let tokenCount = 0;
let streamCompleted = false; let hasTextContent = false;
// Log the input being sent to LLM
console.log(`------------ ${logPrefix} ------------`); console.log(`------------ ${logPrefix} ------------`);
console.log(input); console.log(input);
console.log('------------------------------'); console.log('------------------------------');
for await (const chunk of agentCore.processUserInput(input)) { for await (const chunk of agentCore.processUserInput(input)) {
// Check if request was aborted
if (controller.signal.aborted) { if (controller.signal.aborted) {
return ''; return '';
} }
@@ -52,54 +50,89 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce
if (chunk.type === 'text') { if (chunk.type === 'text') {
assistantResponse += chunk.content; assistantResponse += chunk.content;
tokenCount++; tokenCount++;
hasTextContent = true;
// Update streaming message with token count and abort link onMessageUpdate(currentStreamingId, (msg) => ({
onMessageUpdate(streamingMessage.id, (msg) => ({
...msg, ...msg,
content: `<span class="processing-wave">Processing...</span> ${tokenCount} tokens received. click here to abort.`, content: `<span class="processing-wave">Processing...</span> ${tokenCount} tokens received. click here to abort.`,
tokenCount tokenCount
})); }));
} else if (chunk.type === 'done') { } else if (chunk.type === 'tool_call' && chunk.toolCall) {
streamCompleted = true; // Finalize or remove the current streaming message
// Replace with final response if (hasTextContent) {
onMessageUpdate(streamingMessage.id, (msg) => ({ onMessageUpdate(currentStreamingId, (msg) => ({
...msg, ...msg,
content: assistantResponse, content: assistantResponse,
isStreaming: false, isStreaming: false,
tokenCount: undefined tokenCount: undefined
})); }));
// Log the complete assistant response
console.log('------------ ASSISTANT ------------'); console.log('------------ ASSISTANT ------------');
console.log(assistantResponse); console.log(assistantResponse);
console.log('-----------------------------------'); console.log('-----------------------------------');
} else {
break; // No text before this tool call — remove the empty streaming placeholder
} onMessageRemove(currentStreamingId);
} }
// If stream didn't complete normally, finalize the message // Show tool call in UI
if (!streamCompleted && !controller.signal.aborted) { const toolName = chunk.toolCall.function.name;
onMessageUpdate(streamingMessage.id, (msg) => ({ let argsDisplay = '';
try {
const args = JSON.parse(chunk.toolCall.function.arguments);
argsDisplay = JSON.stringify(args, null, 2);
} catch {
argsDisplay = chunk.toolCall.function.arguments;
}
const toolCallMsg = createMessage('assistant', `🔧 **Calling tool: ${toolName}**\n\n\`\`\`json\n${argsDisplay}\n\`\`\``);
onMessageAdd(toolCallMsg);
} else if (chunk.type === 'tool_result' && chunk.toolResult) {
// Show tool result in UI
const { name, success, result } = chunk.toolResult;
const icon = success ? '✅' : '❌';
const toolResultMsg = createMessage('assistant', `${icon} **${name}**\n\n └── ${result}`);
onMessageAdd(toolResultMsg);
// Reset for the next LLM turn in the agentic loop
assistantResponse = '';
tokenCount = 0;
hasTextContent = false;
// Create a fresh streaming placeholder for the next LLM response
const nextMsg = createStreamingMessage();
currentStreamingId = nextMsg.id;
onMessageAdd(nextMsg);
} else if (chunk.type === 'done') {
// Finalize the streaming message
if (hasTextContent) {
onMessageUpdate(currentStreamingId, (msg) => ({
...msg, ...msg,
content: assistantResponse || 'Stream was interrupted unexpectedly', content: assistantResponse,
isStreaming: false, isStreaming: false,
tokenCount: undefined tokenCount: undefined
})); }));
} else {
// No text in final response — remove empty placeholder
onMessageRemove(currentStreamingId);
}
console.log('------------ ASSISTANT ------------');
console.log(assistantResponse);
console.log('-----------------------------------');
break;
}
} }
return assistantResponse; return assistantResponse;
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === 'AbortError') { if (error instanceof Error && error.name === 'AbortError') {
// Request was aborted, don't show error
return ''; return '';
} }
console.error('Error processing stream:', error); console.error('Error processing stream:', error);
// Update with error message onMessageUpdate(currentStreamingId, (msg) => ({
onMessageUpdate(streamingMessage.id, (msg) => ({
...msg, ...msg,
content: 'Error: Failed to process message', content: `Error: ${error instanceof Error ? error.message : 'Failed to process message'}`,
isStreaming: false, isStreaming: false,
tokenCount: undefined tokenCount: undefined
})); }));
@@ -109,7 +142,7 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce
setIsProcessing(false); setIsProcessing(false);
onProcessingChange(false); onProcessingChange(false);
} }
}, [onMessageUpdate, onMessageAdd, onProcessingChange]); }, [onMessageUpdate, onMessageAdd, onMessageRemove, onProcessingChange]);
return { return {
processStream, processStream,
-102
View File
@@ -1,102 +0,0 @@
import { XMLToolExecutor } from '../agent/core/XMLToolExecutor';
import { extractXMLFromString } from '../util/xmlUtil';
import { createToolResultMessage } from './chatMessageUtils';
import type { ChatMessage } from '../types/projectTypes';
interface ToolExecutionResult {
success: boolean;
result: string;
}
interface ToolExecutionOptions {
onMessageAdd: (message: ChatMessage) => void;
onStatusUpdate: (status: string) => void;
}
export const extractActionableTools = (response: string): string[] => {
const xmlBlocks = extractXMLFromString(response);
// Consider only actionable tools (exclude think/thinking)
return xmlBlocks.filter((block) => {
const match = block.match(/<([a-zA-Z_][a-zA-Z0-9_-]*)/);
const name = match ? match[1].toLowerCase() : '';
return name !== 'think' && name !== 'thinking';
});
};
export const extractToolName = (xmlBlock: string): string => {
const toolNameMatch = xmlBlock.match(/<([a-zA-Z_][a-zA-Z0-9_-]*)/);
return toolNameMatch ? toolNameMatch[1] : 'unknown_tool';
};
export const executeSingleTool = async (
xmlBlock: string,
toolName: string,
options: ToolExecutionOptions
): Promise<ToolExecutionResult> => {
const { onMessageAdd } = options;
try {
const executor = XMLToolExecutor.instance();
const results = await executor.executeXMLTools(xmlBlock);
const result = results[0]; // Single block should give single result
if (result) {
// Add friendly display message
const toolMessage = createToolResultMessage(toolName, result.success, result.result);
onMessageAdd(toolMessage);
return {
success: result.success,
result: result.result
};
}
return {
success: false,
result: 'No result returned from tool execution'
};
} catch (error) {
// Handle individual tool error
const errorMessage = `Tool execution failed: ${error}`;
const toolMessage = createToolResultMessage(toolName, false, errorMessage);
onMessageAdd(toolMessage);
return {
success: false,
result: errorMessage
};
}
};
export const formatToolResultForLLM = (toolName: string, result: ToolExecutionResult): string => {
// Skip thinking tools
if (toolName === 'thinking' || toolName === 'think') {
return '';
}
return `tool: ${toolName}\nsuccess: ${result.success}\nresult:\n${result.result}\n------------\n`;
};
export const executeAllTools = async (
actionableBlocks: string[],
options: ToolExecutionOptions
): Promise<string> => {
const { onStatusUpdate } = options;
onStatusUpdate(`Executing ${actionableBlocks.length} tool(s)...`);
let accumulatedResults = '';
// Execute tools sequentially with real-time updates
for (let i = 0; i < actionableBlocks.length; i++) {
onStatusUpdate(`Executing tool ${i + 1} of ${actionableBlocks.length}...`);
const toolName = extractToolName(actionableBlocks[i]);
const result = await executeSingleTool(actionableBlocks[i], toolName, options);
// Accumulate formatted result for LLM
accumulatedResults += formatToolResultForLLM(toolName, result);
}
return accumulatedResults;
};