mirror of
https://github.com/LostRuins/koboldcpp.git
synced 2026-09-20 01:31:42 +02:00
Merge commit '34af94cd9ab277632e27caeec2d41de2fd091b31' into concedo_experimental
# Conflicts: # .github/workflows/docker.yml # .github/workflows/make-release.yml # .github/workflows/release.yml # .pi/gg/SYSTEM.md # CMakeLists.txt # build-xcframework.sh # docs/development/HOWTO-add-model.md # docs/ops.md # docs/ops/SYCL.csv # docs/speculative.md # examples/sycl/update-ops-doc.sh # ggml/CMakeLists.txt # ggml/src/ggml-sycl/cpy.cpp # ggml/src/ggml-sycl/ggml-sycl.cpp # scripts/make-release-checks.sh # scripts/sync-ggml.last # tests/test-chat-auto-parser.cpp # tests/test-chat.cpp # tests/test-jinja.cpp # tests/test-llama-archs.cpp # tests/testing.h # tools/llama-bench/llama-bench.cpp # tools/server/README.md
This commit is contained in:
@@ -1606,12 +1606,16 @@ mtmd_image_preproc_out mtmd_image_preprocessor_granite::preprocess(const clip_im
|
||||
|
||||
const clip_image_size orig_size = img.get_size();
|
||||
const int tile_size = hparams.image_size;
|
||||
GGML_ASSERT(tile_size > 0);
|
||||
|
||||
// llava-next always encodes an overview plus a grid of tiles, even for small images
|
||||
const clip_image_size refined_size = select_best_resolution(orig_size, hparams.image_res_candidates);
|
||||
const int grid_x = refined_size.width / tile_size;
|
||||
const int grid_y = refined_size.height / tile_size;
|
||||
|
||||
// the tiles are stacked on the Y axis, a big grid overflows the stacked image height
|
||||
GGML_ASSERT(grid_x >= 0 && grid_x <= 1024 && grid_y >= 0 && grid_y <= 1024);
|
||||
|
||||
clip_image_u8 overview;
|
||||
img_tool::resize(img, overview, {tile_size, tile_size}, hparams.image_resize_algo_ov,
|
||||
hparams.image_pad_ov, hparams.image_pad_color_ov);
|
||||
|
||||
@@ -2947,8 +2947,10 @@ private:
|
||||
});
|
||||
|
||||
// generate the actual drafts (if any)
|
||||
{
|
||||
common_speculative_draft(spec.get());
|
||||
if (!drafting.empty()) {
|
||||
queue_tasks.yield_to_queue([&]() {
|
||||
common_speculative_draft(spec.get());
|
||||
});
|
||||
}
|
||||
|
||||
// make checkpoints if needed
|
||||
@@ -3578,8 +3580,8 @@ private:
|
||||
has_output |= batch.tokens[i].output;
|
||||
}
|
||||
|
||||
// decode on the worker thread, so we can still handle metrics tasks while waiting
|
||||
// note: the sync is done here too, so that the wait also happens off the main thread
|
||||
// yield to the queue, so we can still handle metrics tasks while decoding
|
||||
// note: the sync is done here too, so that the wait is also covered by the yield
|
||||
int ret = 0;
|
||||
queue_tasks.yield_to_queue([&]() {
|
||||
ret = llama_decode(ctx_tgt, batch_view);
|
||||
@@ -3644,11 +3646,18 @@ private:
|
||||
// TODO: avoid restoring the draft context and re-evaluating the drafted tokens when not needed [TAG_SPEC_AVOID_DRAFT_REEVAL]
|
||||
// for now, always re-evaluate for simplicity
|
||||
// ref: https://github.com/ggml-org/llama.cpp/pull/22728#issuecomment-4400925384
|
||||
if (!common_speculative_process(spec.get(), batch_view)) {
|
||||
SRV_ERR("%s", "failed to process speculative batch\n");
|
||||
if (spec) {
|
||||
bool ok = true;
|
||||
queue_tasks.yield_to_queue([&]() {
|
||||
ok = common_speculative_process(spec.get(), batch_view);
|
||||
});
|
||||
|
||||
// TODO: handle error
|
||||
throw std::runtime_error("failed to process speculative batch");
|
||||
if (!ok) {
|
||||
SRV_ERR("%s", "failed to process speculative batch\n");
|
||||
|
||||
// TODO: handle error
|
||||
throw std::runtime_error("failed to process speculative batch");
|
||||
}
|
||||
}
|
||||
|
||||
// handle `n_cmpl > 1` tasks - when the main prompt is processed, activate all child tasks too
|
||||
|
||||
@@ -150,31 +150,46 @@ bool server_queue::process_new_tasks(bool is_yielding) {
|
||||
|
||||
void server_queue::worker_loop() {
|
||||
while (true) {
|
||||
std::function<void()> work;
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(mutex_tasks);
|
||||
// wait on busy instead of yielding - busy stays set even when the yield already ended
|
||||
worker.cv.wait(lock, [&]{
|
||||
return worker.stop || worker.work != nullptr;
|
||||
return worker.stop || worker.busy;
|
||||
});
|
||||
if (worker.stop) {
|
||||
return;
|
||||
}
|
||||
work = std::move(worker.work);
|
||||
worker.work = nullptr;
|
||||
}
|
||||
|
||||
// note: do not hold any lock here, work() may post new tasks
|
||||
std::exception_ptr exception;
|
||||
try {
|
||||
work();
|
||||
} catch (...) {
|
||||
exception = std::current_exception();
|
||||
// process tasks while the yield is active
|
||||
while (true) {
|
||||
bool terminated = false;
|
||||
try {
|
||||
// note: do not hold any lock here, the callback may post new tasks
|
||||
terminated = process_new_tasks(true);
|
||||
} catch (...) {
|
||||
std::unique_lock<std::mutex> lock(mutex_tasks);
|
||||
worker.exception = std::current_exception();
|
||||
break;
|
||||
}
|
||||
|
||||
std::unique_lock<std::mutex> lock(mutex_tasks);
|
||||
if (terminated || worker.stop || !worker.yielding) {
|
||||
break;
|
||||
}
|
||||
if (!queue_tasks.empty()) {
|
||||
continue; // a new task arrived in the meantime
|
||||
}
|
||||
condition_tasks.wait(lock, [&]{
|
||||
return worker.stop || !running || !worker.yielding || !queue_tasks.empty();
|
||||
});
|
||||
}
|
||||
|
||||
// signal completion to yield_to_queue()
|
||||
std::unique_lock<std::mutex> lock(mutex_tasks);
|
||||
worker.exception = std::move(exception);
|
||||
worker.busy = false;
|
||||
// signal to yield_to_queue() that no more tasks will be processed
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(mutex_tasks);
|
||||
worker.busy = false;
|
||||
}
|
||||
condition_tasks.notify_all();
|
||||
}
|
||||
}
|
||||
@@ -188,6 +203,7 @@ void server_queue::worker_stop() {
|
||||
worker.stop = true;
|
||||
}
|
||||
worker.cv.notify_one();
|
||||
condition_tasks.notify_all();
|
||||
worker.thread.join();
|
||||
}
|
||||
|
||||
@@ -199,29 +215,29 @@ void server_queue::yield_to_queue(std::function<void()> && work) {
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(mutex_tasks);
|
||||
GGML_ASSERT(!worker.busy && "yield_to_queue() cannot be nested");
|
||||
worker.busy = true;
|
||||
worker.work = std::move(work);
|
||||
worker.busy = true;
|
||||
worker.yielding = true;
|
||||
}
|
||||
worker.cv.notify_one();
|
||||
|
||||
while (true) {
|
||||
// note: on terminate this is a no-op, but we still wait for the work to finish
|
||||
process_new_tasks(true);
|
||||
|
||||
std::unique_lock<std::mutex> lock(mutex_tasks);
|
||||
// declined tasks are moved to queue_tasks_unhandled, so a non-empty queue always has something new
|
||||
condition_tasks.wait(lock, [&]{
|
||||
return !worker.busy || (running && !queue_tasks.empty());
|
||||
});
|
||||
if (!worker.busy) {
|
||||
break;
|
||||
}
|
||||
// run the work on the current thread, so that all ggml compute stays on the same thread
|
||||
std::exception_ptr exception;
|
||||
try {
|
||||
work();
|
||||
} catch (...) {
|
||||
exception = std::current_exception();
|
||||
}
|
||||
|
||||
std::exception_ptr exception;
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(mutex_tasks);
|
||||
|
||||
// the yield is over, wait for the worker to finish its current task
|
||||
worker.yielding = false;
|
||||
condition_tasks.notify_all();
|
||||
condition_tasks.wait(lock, [&]{
|
||||
return !worker.busy;
|
||||
});
|
||||
|
||||
// put the declined tasks back, keeping their order
|
||||
while (!queue_tasks_unhandled.empty()) {
|
||||
queue_tasks.push_front(std::move(queue_tasks_unhandled.back()));
|
||||
@@ -231,8 +247,12 @@ void server_queue::yield_to_queue(std::function<void()> && work) {
|
||||
// make sure to avoid idle timeout here
|
||||
time_last_task = ggml_time_ms();
|
||||
|
||||
// the worker is idle now, take the exception it may have left behind
|
||||
std::swap(exception, worker.exception);
|
||||
// an exception from work() takes precedence over the one from the worker
|
||||
if (!exception) {
|
||||
std::swap(exception, worker.exception);
|
||||
} else {
|
||||
worker.exception = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
QUE_DBG("%s", "done yielding to queue\n");
|
||||
@@ -249,7 +269,9 @@ void server_queue::start_loop(int64_t idle_sleep_ms) {
|
||||
|
||||
// spawn the worker thread used by yield_to_queue()
|
||||
GGML_ASSERT(!worker.thread.joinable() && "start_loop() is already running");
|
||||
worker.stop = false;
|
||||
worker.stop = false;
|
||||
worker.busy = false;
|
||||
worker.yielding = false;
|
||||
worker.thread = std::thread([this]() { worker_loop(); });
|
||||
|
||||
constexpr auto max_wait_time = std::chrono::seconds(1);
|
||||
|
||||
@@ -33,11 +33,11 @@ private:
|
||||
// used by yield_to_queue, all fields are guarded by mutex_tasks
|
||||
struct worker_t {
|
||||
std::thread thread;
|
||||
std::condition_variable cv; // the worker sleeps on this until there is work
|
||||
std::function<void()> work; // pending work, picked up by the thread
|
||||
std::exception_ptr exception; // exception thrown by work(), if any
|
||||
bool stop = false;
|
||||
bool busy = false;
|
||||
std::condition_variable cv; // the worker sleeps on this until a yield starts
|
||||
std::exception_ptr exception; // exception thrown while processing tasks, if any
|
||||
bool stop = false;
|
||||
bool busy = false; // set by yield_to_queue(), cleared by the worker once it is done processing tasks
|
||||
bool yielding = false; // work() is still running on the start_loop() thread
|
||||
};
|
||||
worker_t worker;
|
||||
|
||||
@@ -93,7 +93,7 @@ public:
|
||||
*/
|
||||
void start_loop(int64_t idle_sleep_ms = -1);
|
||||
|
||||
// run work() on a separate thread, while the current thread calls process_new_tasks
|
||||
// while waiting for work() to finish, run process_new_tasks on the worker thread
|
||||
// returns once work() is done (may throw exceptions)
|
||||
// must be called from start_loop() thread (ideally inside callback_update_slots)
|
||||
// use case: return metrics while encode/decode is running
|
||||
@@ -116,6 +116,7 @@ public:
|
||||
// the second argument tells whether the queue is currently yielding (see yield_to_queue)
|
||||
// only then may the callback return false to decline the task, and it must leave it
|
||||
// untouched, so that it can be put back in the queue later
|
||||
// note: while yielding, the callback runs on worker thread, not main thread
|
||||
void on_new_task(std::function<bool(server_task &&, bool)> callback) {
|
||||
callback_new_task = std::move(callback);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
#include <regex>
|
||||
#include <thread>
|
||||
#include <chrono>
|
||||
#include <ctime>
|
||||
#include <atomic>
|
||||
#include <cstring>
|
||||
#include <cctype>
|
||||
@@ -1692,61 +1691,6 @@ private:
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// get_datetime: returns the current date and time
|
||||
//
|
||||
|
||||
struct server_tool_get_datetime : server_tool {
|
||||
server_tool_get_datetime() {
|
||||
name = "get_datetime";
|
||||
display_name = "Get Date & Time";
|
||||
permission_write = false;
|
||||
}
|
||||
|
||||
json get_definition() const override {
|
||||
return {
|
||||
{"type", "function"},
|
||||
{"function", {
|
||||
{"name", name},
|
||||
{"description", "Returns the current date and time in UTC"},
|
||||
{"parameters", {
|
||||
{"type", "object"},
|
||||
{"properties", {
|
||||
{"format", {
|
||||
{"type", "string"},
|
||||
{"description",
|
||||
"strftime()-style format string for the output (default: \"%Y-%m-%dT%H:%M:%SZ\", "
|
||||
"e.g. ISO 8601). Choose your own format if you need something else, "
|
||||
"e.g. \"%A, %B %d %Y\" for a human-readable date."},
|
||||
}},
|
||||
}},
|
||||
}},
|
||||
}},
|
||||
};
|
||||
}
|
||||
|
||||
json invoke(json params, server_tool::stream *) const override {
|
||||
std::string format = json_value(params, "format", std::string("%Y-%m-%dT%H:%M:%SZ"));
|
||||
|
||||
auto now = std::chrono::system_clock::now();
|
||||
auto time = std::chrono::system_clock::to_time_t(now);
|
||||
std::tm tm_utc;
|
||||
#ifdef _WIN32
|
||||
gmtime_s(&tm_utc, &time);
|
||||
#else
|
||||
gmtime_r(&time, &tm_utc);
|
||||
#endif
|
||||
|
||||
char buf[256];
|
||||
size_t len = std::strftime(buf, sizeof(buf), format.c_str(), &tm_utc);
|
||||
if (len == 0) {
|
||||
return {{"error", "invalid format string"}};
|
||||
}
|
||||
|
||||
return {{"result", std::string(buf, len)}};
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// get_info: returns runtime info (OS name/version and cwd)
|
||||
//
|
||||
@@ -2005,6 +1949,10 @@ static server_tool & find_tool(std::vector<std::unique_ptr<server_tool>> & tools
|
||||
//
|
||||
|
||||
static std::vector<std::unique_ptr<server_tool>> build_tools() {
|
||||
// IMPORTANT: for contributors, please keep this array of tools as minimal as possible
|
||||
// we only accept minimal i/o and shell command tools here
|
||||
// for example, do not add: web search, get date time, etc.
|
||||
// high-level functionality should be added either via MCP or web UI
|
||||
std::vector<std::unique_ptr<server_tool>> tools;
|
||||
tools.push_back(std::make_unique<server_tool_read_file>());
|
||||
tools.push_back(std::make_unique<server_tool_file_glob_search>());
|
||||
@@ -2012,7 +1960,6 @@ static std::vector<std::unique_ptr<server_tool>> build_tools() {
|
||||
tools.push_back(std::make_unique<server_tool_exec_shell_command>());
|
||||
tools.push_back(std::make_unique<server_tool_write_file>());
|
||||
tools.push_back(std::make_unique<server_tool_edit_file>());
|
||||
tools.push_back(std::make_unique<server_tool_get_datetime>());
|
||||
tools.push_back(std::make_unique<server_tool_get_info>());
|
||||
return tools;
|
||||
}
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@
|
||||
if (typeof obj.result === 'string') return { dateString: obj.result.trim() };
|
||||
}
|
||||
} catch {
|
||||
return { dateString: toolResultString.trim() };
|
||||
// not JSON - nothing to show
|
||||
}
|
||||
|
||||
return {};
|
||||
|
||||
@@ -158,6 +158,8 @@
|
||||
<div class="relative">
|
||||
<Input
|
||||
id="api-key-input"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
placeholder="Enter your API key..."
|
||||
bind:value={apiKeyInput}
|
||||
onkeydown={handleApiKeyKeydown}
|
||||
|
||||
@@ -81,7 +81,8 @@
|
||||
<div class="relative w-full">
|
||||
<Input
|
||||
id={field.key}
|
||||
type={field.isPositiveInteger ? 'number' : 'text'}
|
||||
type={field.isPrivate ? 'password' : field.isPositiveInteger ? 'number' : 'text'}
|
||||
autocomplete={field.isPrivate ? 'new-password' : undefined}
|
||||
{...field.isPositiveInteger
|
||||
? {
|
||||
min: String(field.min ?? 1),
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { CLI_FLAGS } from './cli-flags.constants';
|
||||
import { BuiltInTool, JsonSchemaType, ToolCallType } from '$lib/enums';
|
||||
import type { OpenAIToolDefinition } from '$lib/types';
|
||||
|
||||
export const BROWSER_INFO_TOOL_NAME = BuiltInTool.GET_INFO;
|
||||
|
||||
/** UA token to OS name, first match wins - Android and iOS UAs also carry the Linux / Mac OS X tokens */
|
||||
export const BROWSER_INFO_OS_UA_PATTERNS: readonly [RegExp, string][] = [
|
||||
[/Windows NT/, 'Windows'],
|
||||
[/Android/, 'Android'],
|
||||
[/iPhone|iPad|iPod/, 'iOS'],
|
||||
[/CrOS/, 'ChromeOS'],
|
||||
[/Mac OS X/, 'macOS'],
|
||||
[/Linux/, 'Linux']
|
||||
];
|
||||
|
||||
export const BROWSER_INFO_OS_UNKNOWN = 'unknown';
|
||||
|
||||
/** Sent to the model as the `note` field of the tool result, next to the OS name */
|
||||
export const BROWSER_INFO_NOTE = `This environment is browser-only, it cannot read or modify local files, and it cannot run shell commands. To get local file access, tell user to launch llama-server with the ${CLI_FLAGS.AGENT} argument.`;
|
||||
|
||||
export function buildBrowserInfoToolDefinition(): OpenAIToolDefinition {
|
||||
return {
|
||||
function: {
|
||||
description:
|
||||
'Get runtime info (OS name), may call when user asks about local files or shell commands',
|
||||
name: BROWSER_INFO_TOOL_NAME,
|
||||
parameters: {
|
||||
properties: {},
|
||||
required: [],
|
||||
type: JsonSchemaType.OBJECT
|
||||
}
|
||||
},
|
||||
type: ToolCallType.FUNCTION
|
||||
};
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export const BUILTIN_TOOL_UI: Readonly<Record<BuiltInTool, BuiltinToolUiEntry>>
|
||||
label: 'Search files',
|
||||
source: ToolSource.BUILTIN
|
||||
},
|
||||
[BuiltInTool.GET_DATETIME]: { icon: Clock, label: 'Current time', source: ToolSource.BUILTIN },
|
||||
[BuiltInTool.GET_DATETIME]: { icon: Clock, label: 'Current time', source: ToolSource.FRONTEND },
|
||||
[BuiltInTool.GET_INFO]: { icon: Info, label: 'Runtime info', source: ToolSource.BUILTIN },
|
||||
[BuiltInTool.GREP_SEARCH]: {
|
||||
icon: SearchCode,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const CLI_FLAGS = {
|
||||
AGENT: '--agent',
|
||||
API_KEY: '--api-key',
|
||||
MCP_PROXY: '--ui-mcp-proxy',
|
||||
SLOTS: '--slots',
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { BuiltInTool, JsonSchemaType, ToolCallType } from '$lib/enums';
|
||||
import type { OpenAIToolDefinition } from '$lib/types';
|
||||
|
||||
export const GET_DATETIME_TOOL_NAME = BuiltInTool.GET_DATETIME;
|
||||
|
||||
export function buildGetDatetimeToolDefinition(): OpenAIToolDefinition {
|
||||
return {
|
||||
function: {
|
||||
description:
|
||||
'Returns the current local date and time in ISO 8601 format, with the IANA time zone name',
|
||||
name: GET_DATETIME_TOOL_NAME,
|
||||
parameters: {
|
||||
properties: {},
|
||||
required: [],
|
||||
type: JsonSchemaType.OBJECT
|
||||
}
|
||||
},
|
||||
type: ToolCallType.FUNCTION
|
||||
};
|
||||
}
|
||||
@@ -59,3 +59,5 @@ export * from './uri-template.constants';
|
||||
export * from './url.constants';
|
||||
export * from './working-directory.constants';
|
||||
export * from './read-media';
|
||||
export * from './get-datetime';
|
||||
export * from './browser-info';
|
||||
|
||||
@@ -324,6 +324,7 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
|
||||
{
|
||||
defaultValue: '',
|
||||
help: `Set the API Key if you are using <code> ${CLI_FLAGS.API_KEY} </code> option for the server.`,
|
||||
isPrivate: true,
|
||||
key: SETTINGS_KEYS.API_KEY,
|
||||
label: 'API Key',
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
@@ -713,6 +714,7 @@ export const SETTINGS_CHAT_SECTIONS: SettingsSection[] = [
|
||||
help: s.help,
|
||||
isExperimental: s.isExperimental,
|
||||
isPositiveInteger: s.isPositiveInteger,
|
||||
isPrivate: s.isPrivate,
|
||||
key: s.key,
|
||||
label: s.label,
|
||||
max: s.max,
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
DEFAULT_CLIENT_VERSION,
|
||||
DEFAULT_IMAGE_MIME_TYPE,
|
||||
DEFAULT_MCP_CONFIG,
|
||||
HEADERS
|
||||
HEADERS,
|
||||
NEWLINE
|
||||
} from '$lib/constants';
|
||||
import {
|
||||
MCPConnectionPhase,
|
||||
@@ -70,6 +71,7 @@ interface ToolResultContentItem {
|
||||
|
||||
interface ToolCallResult {
|
||||
content?: ToolResultContentItem[];
|
||||
structuredContent?: Record<string, unknown>;
|
||||
isError?: boolean;
|
||||
_meta?: Record<string, unknown>;
|
||||
}
|
||||
@@ -1012,10 +1014,20 @@ export class MCPService {
|
||||
|
||||
if (!Array.isArray(content)) return '';
|
||||
|
||||
return content
|
||||
const formatted = content
|
||||
.map((item) => this.formatSingleContent(item))
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
.join(NEWLINE);
|
||||
|
||||
if (formatted !== '') {
|
||||
return formatted;
|
||||
}
|
||||
|
||||
if (result.structuredContent && typeof result.structuredContent === 'object') {
|
||||
return JSON.stringify(result.structuredContent);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private static formatSingleContent(content: ToolResultContentItem): string {
|
||||
|
||||
@@ -56,7 +56,8 @@ import type {
|
||||
AgenticSession,
|
||||
McpServerOverride,
|
||||
MCPToolCall,
|
||||
SettingsConfigType
|
||||
SettingsConfigType,
|
||||
ToolExecutionResult
|
||||
} from '$lib/types';
|
||||
import type {
|
||||
AgenticFlowCallbacks,
|
||||
@@ -83,7 +84,12 @@ import type {
|
||||
DatabaseMessageExtraAudioFile,
|
||||
DatabaseMessageExtraImageFile
|
||||
} from '$lib/types/database';
|
||||
import { getAudioInputFormat, isAbortError } from '$lib/utils';
|
||||
import {
|
||||
executeBrowserInfoTool,
|
||||
executeGetDatetimeTool,
|
||||
getAudioInputFormat,
|
||||
isAbortError
|
||||
} from '$lib/utils';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
function createDefaultSession(): AgenticSession {
|
||||
@@ -942,18 +948,26 @@ class AgenticStore {
|
||||
if (executionResult.isError) toolSuccess = false;
|
||||
} else if (toolSource === ToolSource.FRONTEND) {
|
||||
const args = this.parseToolArguments(toolCall.function.arguments);
|
||||
const executionResult =
|
||||
toolName === BuiltInTool.READ_MEDIA
|
||||
? await ReadMediaService.executeTool(
|
||||
args,
|
||||
{
|
||||
audio: modelsStore.modelSupportsAudio(effectiveModel),
|
||||
vision: modelsStore.modelSupportsVision(effectiveModel)
|
||||
},
|
||||
signal,
|
||||
conversationsStore.activeConversation?.cwd
|
||||
)
|
||||
: await SandboxService.executeTool(toolName, args, signal);
|
||||
|
||||
let executionResult: ToolExecutionResult;
|
||||
|
||||
if (toolName === BuiltInTool.GET_DATETIME) {
|
||||
executionResult = executeGetDatetimeTool();
|
||||
} else if (toolName === BuiltInTool.GET_INFO) {
|
||||
executionResult = executeBrowserInfoTool();
|
||||
} else if (toolName === BuiltInTool.READ_MEDIA) {
|
||||
executionResult = await ReadMediaService.executeTool(
|
||||
args,
|
||||
{
|
||||
audio: modelsStore.modelSupportsAudio(effectiveModel),
|
||||
vision: modelsStore.modelSupportsVision(effectiveModel)
|
||||
},
|
||||
signal,
|
||||
conversationsStore.activeConversation?.cwd
|
||||
);
|
||||
} else {
|
||||
executionResult = await SandboxService.executeTool(toolName, args, signal);
|
||||
}
|
||||
|
||||
result = executionResult.content;
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { browser } from '$app/environment';
|
||||
import {
|
||||
buildBrowserInfoToolDefinition,
|
||||
buildGetDatetimeToolDefinition,
|
||||
buildReadMediaToolDefinition,
|
||||
DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
|
||||
HOME_TILDE,
|
||||
@@ -175,7 +177,7 @@ class ToolsStore {
|
||||
}
|
||||
|
||||
get frontendTools(): OpenAIToolDefinition[] {
|
||||
const tools: OpenAIToolDefinition[] = [];
|
||||
const tools: OpenAIToolDefinition[] = [buildGetDatetimeToolDefinition()];
|
||||
|
||||
if (settingsStore.config.jsSandboxEnabled) {
|
||||
tools.push(buildSandboxToolDefinition(!!settingsStore.config.symbolicMathEnabled));
|
||||
@@ -185,9 +187,18 @@ class ToolsStore {
|
||||
|
||||
if (readMedia) tools.push(readMedia);
|
||||
|
||||
// provide browser's get_info tool if server doesn't provide one
|
||||
if (!this.hasBuiltinTool(BuiltInTool.GET_INFO)) {
|
||||
tools.push(buildBrowserInfoToolDefinition());
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
private hasBuiltinTool(name: BuiltInTool): boolean {
|
||||
return this._builtinTools.some((def) => def.function.name === name);
|
||||
}
|
||||
|
||||
/**
|
||||
* `read_media` runs in the frontend on top of the server's `read_file`, so it
|
||||
* exists only when that tool is served and the active model can perceive the
|
||||
@@ -195,11 +206,7 @@ class ToolsStore {
|
||||
* conversation uses.
|
||||
*/
|
||||
private readMediaTool(): OpenAIToolDefinition | null {
|
||||
const hasReadFile = this._builtinTools.some(
|
||||
(def) => def.function.name === BuiltInTool.READ_FILE
|
||||
);
|
||||
|
||||
if (!hasReadFile) return null;
|
||||
if (!this.hasBuiltinTool(BuiltInTool.READ_FILE)) return null;
|
||||
|
||||
const model = modelsStore.selectedModelName ?? modelsStore.models[0]?.model ?? '';
|
||||
|
||||
|
||||
Vendored
+2
@@ -31,6 +31,7 @@ export interface SettingsEntry {
|
||||
radioOptions?: Array<{ value: string; label: string; key: string; isExperimental?: boolean }>;
|
||||
isExperimental?: boolean;
|
||||
isPositiveInteger?: boolean;
|
||||
isPrivate?: boolean;
|
||||
placeholder?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
@@ -55,6 +56,7 @@ export interface SettingsFieldConfig {
|
||||
type: SettingsFieldType;
|
||||
isExperimental?: boolean;
|
||||
isPositiveInteger?: boolean;
|
||||
isPrivate?: boolean;
|
||||
placeholder?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Browser fallback for the server's `get_info` tool, offered only when the
|
||||
* server does not serve one (llama-server without --agent). It tells the model
|
||||
* which OS the browser runs on and that there is no local file or shell access,
|
||||
* so it does not plan around tools that are not there.
|
||||
*
|
||||
* @see server_tool_get_info in tools/server/server-tools.cpp - the served variant
|
||||
* @see buildBrowserInfoToolDefinition in constants/browser-info.ts - tool schema sent to the LLM
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import {
|
||||
BROWSER_INFO_NOTE,
|
||||
BROWSER_INFO_OS_UA_PATTERNS,
|
||||
BROWSER_INFO_OS_UNKNOWN
|
||||
} from '$lib/constants';
|
||||
import type { ToolExecutionResult } from '$lib/types';
|
||||
|
||||
function detectOs(userAgent: string): string {
|
||||
for (const [pattern, os] of BROWSER_INFO_OS_UA_PATTERNS) {
|
||||
if (pattern.test(userAgent)) return os;
|
||||
}
|
||||
|
||||
return BROWSER_INFO_OS_UNKNOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result shape mirrors the server tool's JSON so the `get_info` renderer reads
|
||||
* `os` the same way, minus `cwd` - there is no working directory to report.
|
||||
*/
|
||||
export function executeBrowserInfoTool(): ToolExecutionResult {
|
||||
return {
|
||||
content: JSON.stringify({
|
||||
note: BROWSER_INFO_NOTE,
|
||||
os: browser ? detectOs(navigator.userAgent) : BROWSER_INFO_OS_UNKNOWN
|
||||
}),
|
||||
isError: false
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Frontend executor for the `get_datetime` tool. It runs in the browser, so it
|
||||
* reports the user's own clock and time zone instead of the server's UTC time -
|
||||
* a chat about "tomorrow" means the user's tomorrow, not the host's.
|
||||
*
|
||||
* @see buildGetDatetimeToolDefinition in constants/get-datetime.ts - tool schema sent to the LLM
|
||||
*/
|
||||
|
||||
import type { ToolExecutionResult } from '$lib/types';
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
/** ISO 8601 in local time, e.g. `2026-08-17T14:05:09+02:00` */
|
||||
function localIsoString(date: Date): string {
|
||||
// getTimezoneOffset() counts minutes behind UTC, ISO 8601 counts them ahead
|
||||
const offset = -date.getTimezoneOffset();
|
||||
const sign = offset < 0 ? '-' : '+';
|
||||
const absOffset = Math.abs(offset);
|
||||
const day = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
const time = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
|
||||
return `${day}T${time}${sign}${pad(Math.floor(absOffset / 60))}:${pad(absOffset % 60)}`;
|
||||
}
|
||||
|
||||
/** The `result` field keeps the shape the `get_datetime` renderer already reads. */
|
||||
export function executeGetDatetimeTool(): ToolExecutionResult {
|
||||
const now = new Date();
|
||||
|
||||
return {
|
||||
content: JSON.stringify({
|
||||
result: localIsoString(now),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
}),
|
||||
isError: false
|
||||
};
|
||||
}
|
||||
@@ -331,6 +331,12 @@ export { getChatCommands } from './chat-commands';
|
||||
// SANDBOX_TOOL_DEFINITION is deprecated; kept for backward compatibility.
|
||||
export { buildSandboxToolDefinition, SANDBOX_TOOL_DEFINITION } from './sandbox-tool';
|
||||
|
||||
// Frontend `get_datetime` executor (the browser clock, not the server's)
|
||||
export { executeGetDatetimeTool } from './get-datetime';
|
||||
|
||||
// Browser fallback for the server's get_info tool
|
||||
export { executeBrowserInfoTool } from './browser-info';
|
||||
|
||||
// Cryptography utilities
|
||||
|
||||
export { uuid } from './uuid';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Client } from '@modelcontextprotocol/sdk/client';
|
||||
import { CORS_PROXY } from '$lib/constants';
|
||||
import { MCPConnectionPhase, MCPTransportType } from '$lib/enums';
|
||||
import { MCPService } from '$lib/services/mcp.service';
|
||||
import type { MCPConnectionLog, MCPServerConfig } from '$lib/types';
|
||||
import type { MCPConnection, MCPConnectionLog, MCPServerConfig } from '$lib/types';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
type DiagnosticFetchFactory = (
|
||||
@@ -329,4 +329,21 @@ describe('MCPService', () => {
|
||||
)
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('falls back to structuredContent when content array is empty', async () => {
|
||||
const connection = {
|
||||
client: {
|
||||
callTool: vi.fn().mockResolvedValue({
|
||||
content: [],
|
||||
structuredContent: { accounts: [{ id: 1 }], total: 1 }
|
||||
})
|
||||
},
|
||||
requestTimeoutMs: 9000,
|
||||
serverName: 'test-server'
|
||||
} as unknown as MCPConnection;
|
||||
const result = await MCPService.callTool(connection, { arguments: {}, name: 'tool' });
|
||||
|
||||
expect(result.isError).toBe(false);
|
||||
expect(result.content).toBe('{"accounts":[{"id":1}],"total":1}');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { SETTINGS_CHAT_SECTIONS, SETTINGS_KEYS } from '$lib/constants';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('checkApiKeyField', () => {
|
||||
it('should have isPrivate set to true', () => {
|
||||
const fields = SETTINGS_CHAT_SECTIONS.flatMap((section) => section.fields);
|
||||
const apiKeyField = fields.find((field) => field?.key === SETTINGS_KEYS.API_KEY);
|
||||
|
||||
expect(apiKeyField).toBeDefined();
|
||||
expect(apiKeyField?.isPrivate).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user