Compare commits
6 Commits
v2.3.0-sam
...
v2.5.0-tas
| Author | SHA1 | Date | |
|---|---|---|---|
| fcc7c5a86e | |||
| bcfc94fa47 | |||
| 90a6761b07 | |||
| a938cf1d42 | |||
| 6f6b3afb5d | |||
| 154ef78f7c |
@@ -11,6 +11,11 @@ POSTGRES_PASSWORD=CHANGE_ME
|
|||||||
# point BooCode at a different SearXNG instance.
|
# point BooCode at a different SearXNG instance.
|
||||||
SEARXNG_URL=http://100.114.205.53:8888
|
SEARXNG_URL=http://100.114.205.53:8888
|
||||||
|
|
||||||
|
# Task model: lightweight model for auto-naming, search rewrite, etc.
|
||||||
|
# Direct llama-server instance (NOT llama-swap). Falls back to LLAMA_SWAP_URL
|
||||||
|
# with FAST_MODEL when unset.
|
||||||
|
# TASK_MODEL_URL=http://100.90.172.55:7995
|
||||||
|
|
||||||
# v1.13.15-tools: BOOCODE_TOOLS narrows the tool whitelist sent to the LLM.
|
# v1.13.15-tools: BOOCODE_TOOLS narrows the tool whitelist sent to the LLM.
|
||||||
# Unset (default) → all tools (~21k schema). Useful primarily for single-purpose
|
# Unset (default) → all tools (~21k schema). Useful primarily for single-purpose
|
||||||
# sessions where the model only needs read-only filesystem access.
|
# sessions where the model only needs read-only filesystem access.
|
||||||
|
|||||||
661
LICENSE
Normal file
661
LICENSE
Normal file
@@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
@@ -23,5 +23,6 @@
|
|||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.11.10",
|
||||||
"tsx": "^4.16.2",
|
"tsx": "^4.16.2",
|
||||||
"typescript": "^5.5.0"
|
"typescript": "^5.5.0"
|
||||||
}
|
},
|
||||||
|
"license": "AGPL-3.0-only"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,5 +29,6 @@
|
|||||||
"tsx": "^4.16.2",
|
"tsx": "^4.16.2",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^3.0.0"
|
||||||
}
|
},
|
||||||
|
"license": "AGPL-3.0-only"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,9 @@ async function main() {
|
|||||||
type: 'permission_requested',
|
type: 'permission_requested',
|
||||||
task_id: prompt.taskId,
|
task_id: prompt.taskId,
|
||||||
session_id: prompt.sessionId,
|
session_id: prompt.sessionId,
|
||||||
|
kind: prompt.kind,
|
||||||
tool_title: prompt.toolTitle,
|
tool_title: prompt.toolTitle,
|
||||||
|
...(prompt.input ? { input: prompt.input } : {}),
|
||||||
options: prompt.options.map((o) => ({ option_id: o.optionId, label: o.label })),
|
options: prompt.options.map((o) => ({ option_id: o.optionId, label: o.label })),
|
||||||
} as WsFrame);
|
} as WsFrame);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,33 @@ import type { Broker } from '@boocode/server/broker';
|
|||||||
import type { WsFrame } from '@boocode/server/ws-frames';
|
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||||
import { resolveChatId } from './chat-resolve.js';
|
import { resolveChatId } from './chat-resolve.js';
|
||||||
|
|
||||||
|
const AnswerUserInputBody = z.object({
|
||||||
|
tool_call_id: z.string().min(1),
|
||||||
|
answers: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
question: z.string(),
|
||||||
|
selected_options: z.array(z.string()),
|
||||||
|
free_text: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1)
|
||||||
|
.max(3),
|
||||||
|
});
|
||||||
|
|
||||||
|
const AskUserInputArgs = z.object({
|
||||||
|
questions: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
question: z.string(),
|
||||||
|
type: z.enum(['single_select', 'multi_select']),
|
||||||
|
options: z.array(z.string()).min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1)
|
||||||
|
.max(3),
|
||||||
|
});
|
||||||
|
|
||||||
const SendBody = z.object({
|
const SendBody = z.object({
|
||||||
content: z.string().min(1).max(64_000),
|
content: z.string().min(1).max(64_000),
|
||||||
pane_id: z.string().min(1).max(200),
|
pane_id: z.string().min(1).max(200),
|
||||||
@@ -219,6 +246,138 @@ export function registerMessageRoutes(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// POST /api/chats/:id/answer_user_input — answer a pending ask_user_input
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/api/chats/:id/answer_user_input',
|
||||||
|
async (req, reply) => {
|
||||||
|
const parsed = AnswerUserInputBody.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'invalid_body', details: parsed.error.flatten() };
|
||||||
|
}
|
||||||
|
const { tool_call_id, answers } = parsed.data;
|
||||||
|
|
||||||
|
const chatRows = await sql<{ id: string; session_id: string }[]>`
|
||||||
|
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
|
||||||
|
`;
|
||||||
|
if (chatRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'chat_not_found' };
|
||||||
|
}
|
||||||
|
const chat = chatRows[0]!;
|
||||||
|
const sessionId = chat.session_id;
|
||||||
|
|
||||||
|
const callerRows = await sql<{
|
||||||
|
message_id: string;
|
||||||
|
payload: { id: string; name: string; args: Record<string, unknown> };
|
||||||
|
}[]>`
|
||||||
|
SELECT p.message_id, p.payload
|
||||||
|
FROM message_parts p
|
||||||
|
JOIN messages m ON m.id = p.message_id
|
||||||
|
WHERE m.chat_id = ${chat.id}
|
||||||
|
AND m.role = 'assistant'
|
||||||
|
AND p.kind = 'tool_call'
|
||||||
|
AND p.payload->>'id' = ${tool_call_id}
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
if (!callerRows[0]) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'unknown_tool_call_id' };
|
||||||
|
}
|
||||||
|
const foundCall = callerRows[0].payload;
|
||||||
|
if (foundCall.name !== 'ask_user_input') {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'tool_call_not_ask_user_input' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const argsParsed = AskUserInputArgs.safeParse(foundCall.args);
|
||||||
|
if (!argsParsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'mismatched_answer_shape', detail: 'tool_call args invalid' };
|
||||||
|
}
|
||||||
|
const questions = argsParsed.data.questions;
|
||||||
|
if (answers.length !== questions.length) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'mismatched_answer_shape', detail: `expected ${questions.length} answer(s), got ${answers.length}` };
|
||||||
|
}
|
||||||
|
for (let i = 0; i < questions.length; i++) {
|
||||||
|
const q = questions[i]!;
|
||||||
|
const a = answers[i]!;
|
||||||
|
for (const sel of a.selected_options) {
|
||||||
|
if (!q.options.includes(sel)) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'mismatched_answer_shape', detail: `answer ${i + 1} option not in question: ${sel}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (q.type === 'single_select' && a.selected_options.length > 1) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'mismatched_answer_shape', detail: `answer ${i + 1} multi on single_select` };
|
||||||
|
}
|
||||||
|
if (a.selected_options.length === 0 && (!a.free_text || !a.free_text.trim())) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'mismatched_answer_shape', detail: `answer ${i + 1} is empty` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolRows = await sql<{
|
||||||
|
message_id: string;
|
||||||
|
payload: { tool_call_id: string; output: unknown };
|
||||||
|
}[]>`
|
||||||
|
SELECT p.message_id, p.payload
|
||||||
|
FROM message_parts p
|
||||||
|
JOIN messages m ON m.id = p.message_id
|
||||||
|
WHERE m.chat_id = ${chat.id}
|
||||||
|
AND m.role = 'tool'
|
||||||
|
AND p.kind = 'tool_result'
|
||||||
|
AND p.payload->>'tool_call_id' = ${tool_call_id}
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
if (!toolRows[0]) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'unknown_tool_call_id', detail: 'tool message not found' };
|
||||||
|
}
|
||||||
|
if (toolRows[0].payload?.output !== null) {
|
||||||
|
reply.code(409);
|
||||||
|
return { error: 'tool_call_already_answered' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const answerSet = { answers };
|
||||||
|
const newToolResults = { tool_call_id, output: answerSet, truncated: false };
|
||||||
|
const toolMessageId = toolRows[0].message_id;
|
||||||
|
|
||||||
|
const result = await sql.begin(async (tx) => {
|
||||||
|
await tx`DELETE FROM message_parts WHERE message_id = ${toolMessageId} AND kind = 'tool_result'`;
|
||||||
|
await tx`
|
||||||
|
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||||
|
VALUES (${toolMessageId}, 0, 'tool_result', ${tx.json(newToolResults as never)})
|
||||||
|
`;
|
||||||
|
const [assistantMsg] = await tx<{ id: string }[]>`
|
||||||
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
|
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
|
||||||
|
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
|
||||||
|
return { tool_message_id: toolMessageId, assistant_message_id: assistantMsg!.id };
|
||||||
|
});
|
||||||
|
|
||||||
|
broker.publishFrame(sessionId, {
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_message_id: result.tool_message_id,
|
||||||
|
tool_call_id,
|
||||||
|
chat_id: chat.id,
|
||||||
|
output: answerSet,
|
||||||
|
truncated: false,
|
||||||
|
} as unknown as WsFrame);
|
||||||
|
inference.enqueue(sessionId, chat.id, result.assistant_message_id, 'default');
|
||||||
|
|
||||||
|
reply.code(202);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// POST /api/sessions/:sessionId/stop — cancel active inference
|
// POST /api/sessions/:sessionId/stop — cancel active inference
|
||||||
app.post<{ Params: { sessionId: string } }>(
|
app.post<{ Params: { sessionId: string } }>(
|
||||||
'/api/sessions/:sessionId/stop',
|
'/api/sessions/:sessionId/stop',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const CreateBody = z.object({
|
|||||||
|
|
||||||
const PermissionBody = z.object({
|
const PermissionBody = z.object({
|
||||||
option_id: z.string().max(200).nullable(),
|
option_id: z.string().max(200).nullable(),
|
||||||
|
updated_input: z.record(z.unknown()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const ListQuery = z.object({
|
const ListQuery = z.object({
|
||||||
@@ -164,7 +165,7 @@ export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: In
|
|||||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||||
}
|
}
|
||||||
|
|
||||||
const ok = respondToPermission(req.params.id, parsed.data.option_id);
|
const ok = respondToPermission(req.params.id, parsed.data.option_id, parsed.data.updated_input as Record<string, unknown> | undefined);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
reply.code(404);
|
reply.code(404);
|
||||||
return { error: 'no pending permission' };
|
return { error: 'no pending permission' };
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
type WriteTextFileResponse,
|
type WriteTextFileResponse,
|
||||||
type CreateTerminalRequest,
|
type CreateTerminalRequest,
|
||||||
type CreateTerminalResponse,
|
type CreateTerminalResponse,
|
||||||
|
type CreateElicitationRequest,
|
||||||
|
type CreateElicitationResponse,
|
||||||
type SessionConfigOption,
|
type SessionConfigOption,
|
||||||
type ClientSideConnection as ConnectionType,
|
type ClientSideConnection as ConnectionType,
|
||||||
} from '@agentclientprotocol/sdk';
|
} from '@agentclientprotocol/sdk';
|
||||||
@@ -26,7 +28,7 @@ import { spawn } from 'node:child_process';
|
|||||||
import { findThoughtLevelConfigId } from './acp-derive.js';
|
import { findThoughtLevelConfigId } from './acp-derive.js';
|
||||||
import { resolveAcpSpawnArgs } from './acp-spawn.js';
|
import { resolveAcpSpawnArgs } from './acp-spawn.js';
|
||||||
import { createAcpNdJsonStream } from './acp-stream.js';
|
import { createAcpNdJsonStream } from './acp-stream.js';
|
||||||
import { waitForPermissionResponse, cancelPendingPermission } from './permission-waiter.js';
|
import { waitForPermissionResponse, waitForElicitationResponse, cancelPendingPermission } from './permission-waiter.js';
|
||||||
import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
|
import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
|
||||||
import { readWorktreeTextFile, writeWorktreeTextFile } from './acp-client-fs.js';
|
import { readWorktreeTextFile, writeWorktreeTextFile } from './acp-client-fs.js';
|
||||||
import {
|
import {
|
||||||
@@ -254,6 +256,12 @@ class AcpStreamContext {
|
|||||||
createTerminal: async (_params: CreateTerminalRequest): Promise<CreateTerminalResponse> => {
|
createTerminal: async (_params: CreateTerminalRequest): Promise<CreateTerminalResponse> => {
|
||||||
return { terminalId: 'noop' };
|
return { terminalId: 'noop' };
|
||||||
},
|
},
|
||||||
|
unstable_createElicitation: async (params: CreateElicitationRequest): Promise<CreateElicitationResponse> => {
|
||||||
|
if (taskId && sessionId) {
|
||||||
|
return waitForElicitationResponse(taskId, sessionId, agent, modeId, params);
|
||||||
|
}
|
||||||
|
return { action: 'decline' };
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Blocks ACP dispatch on permission prompts until the user responds via API.
|
* Blocks ACP dispatch on permission/elicitation prompts until the user responds via API.
|
||||||
*/
|
*/
|
||||||
import type { RequestPermissionRequest, RequestPermissionResponse } from '@agentclientprotocol/sdk';
|
import type { RequestPermissionRequest, RequestPermissionResponse, CreateElicitationRequest, CreateElicitationResponse } from '@agentclientprotocol/sdk';
|
||||||
import { isUnattendedMode } from './provider-manifest.js';
|
import { isUnattendedMode } from './provider-manifest.js';
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
interface PendingPermission {
|
interface PendingPermission {
|
||||||
|
type: 'permission';
|
||||||
request: RequestPermissionRequest;
|
request: RequestPermissionRequest;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
resolve: (response: RequestPermissionResponse) => void;
|
resolve: (response: RequestPermissionResponse) => void;
|
||||||
@@ -14,11 +15,27 @@ interface PendingPermission {
|
|||||||
timer: ReturnType<typeof setTimeout>;
|
timer: ReturnType<typeof setTimeout>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pendingByTask = new Map<string, PendingPermission>();
|
interface PendingElicitation {
|
||||||
|
type: 'elicitation';
|
||||||
|
request: CreateElicitationRequest;
|
||||||
|
sessionId: string;
|
||||||
|
resolve: (response: CreateElicitationResponse) => void;
|
||||||
|
reject: (err: Error) => void;
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PendingEntry = PendingPermission | PendingElicitation;
|
||||||
|
|
||||||
|
const pendingByTask = new Map<string, PendingEntry>();
|
||||||
|
|
||||||
|
export type PermissionKind = 'tool' | 'question' | 'plan' | 'elicitation';
|
||||||
|
|
||||||
export interface PermissionPrompt {
|
export interface PermissionPrompt {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
|
kind: PermissionKind;
|
||||||
toolTitle?: string;
|
toolTitle?: string;
|
||||||
|
description?: string;
|
||||||
|
input?: Record<string, unknown>;
|
||||||
options: Array<{ optionId: string; label: string }>;
|
options: Array<{ optionId: string; label: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,10 +50,25 @@ export function setPermissionHooks(next: PermissionHooks): void {
|
|||||||
hooks = next;
|
hooks = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveKind(params: RequestPermissionRequest): PermissionKind {
|
||||||
|
const input = params.toolCall?.rawInput;
|
||||||
|
if (input && typeof input === 'object' && !Array.isArray(input) && 'questions' in input && Array.isArray((input as Record<string, unknown>).questions)) {
|
||||||
|
return 'question';
|
||||||
|
}
|
||||||
|
return 'tool';
|
||||||
|
}
|
||||||
|
|
||||||
function toPrompt(taskId: string, params: RequestPermissionRequest): PermissionPrompt {
|
function toPrompt(taskId: string, params: RequestPermissionRequest): PermissionPrompt {
|
||||||
|
const kind = resolveKind(params);
|
||||||
|
const rawInput = params.toolCall?.rawInput;
|
||||||
|
const input = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
|
||||||
|
? rawInput as Record<string, unknown>
|
||||||
|
: undefined;
|
||||||
return {
|
return {
|
||||||
taskId,
|
taskId,
|
||||||
|
kind,
|
||||||
toolTitle: params.toolCall?.title ?? undefined,
|
toolTitle: params.toolCall?.title ?? undefined,
|
||||||
|
...(input ? { input } : {}),
|
||||||
options: params.options.map((o) => ({
|
options: params.options.map((o) => ({
|
||||||
optionId: o.optionId,
|
optionId: o.optionId,
|
||||||
label: o.name,
|
label: o.name,
|
||||||
@@ -73,24 +105,33 @@ export function waitForPermissionResponse(
|
|||||||
resolve({ outcome: { outcome: 'cancelled' } });
|
resolve({ outcome: { outcome: 'cancelled' } });
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
|
|
||||||
pendingByTask.set(taskId, { request: params, sessionId, resolve, reject, timer });
|
pendingByTask.set(taskId, { type: 'permission', request: params, sessionId, resolve, reject, timer });
|
||||||
|
|
||||||
const prompt = toPrompt(taskId, params);
|
const prompt = toPrompt(taskId, params);
|
||||||
void hooks.onPrompt?.({ ...prompt, sessionId });
|
void hooks.onPrompt?.({ ...prompt, sessionId });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function respondToPermission(taskId: string, optionId: string | null): boolean {
|
export function respondToPermission(taskId: string, optionId: string | null, updatedInput?: Record<string, unknown>): boolean {
|
||||||
const pending = pendingByTask.get(taskId);
|
const pending = pendingByTask.get(taskId);
|
||||||
if (!pending) return false;
|
if (!pending) return false;
|
||||||
|
|
||||||
clearTimeout(pending.timer);
|
clearTimeout(pending.timer);
|
||||||
pendingByTask.delete(taskId);
|
pendingByTask.delete(taskId);
|
||||||
|
|
||||||
if (optionId) {
|
if (pending.type === 'elicitation') {
|
||||||
pending.resolve({ outcome: { outcome: 'selected', optionId } });
|
if (updatedInput) {
|
||||||
|
const content = updatedInput as { [key: string]: string | number | boolean | string[] };
|
||||||
|
pending.resolve({ action: 'accept', content });
|
||||||
|
} else {
|
||||||
|
pending.resolve({ action: 'decline' });
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
pending.resolve({ outcome: { outcome: 'cancelled' } });
|
if (optionId) {
|
||||||
|
pending.resolve({ outcome: { outcome: 'selected', optionId } });
|
||||||
|
} else {
|
||||||
|
pending.resolve({ outcome: { outcome: 'cancelled' } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void hooks.onResolved?.(taskId, pending.sessionId);
|
void hooks.onResolved?.(taskId, pending.sessionId);
|
||||||
@@ -100,14 +141,67 @@ export function respondToPermission(taskId: string, optionId: string | null): bo
|
|||||||
export function getPendingPermission(taskId: string): PermissionPrompt | null {
|
export function getPendingPermission(taskId: string): PermissionPrompt | null {
|
||||||
const pending = pendingByTask.get(taskId);
|
const pending = pendingByTask.get(taskId);
|
||||||
if (!pending) return null;
|
if (!pending) return null;
|
||||||
|
if (pending.type === 'elicitation') {
|
||||||
|
return elicitationToPrompt(taskId, pending.request);
|
||||||
|
}
|
||||||
return toPrompt(taskId, pending.request);
|
return toPrompt(taskId, pending.request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function elicitationToPrompt(taskId: string, params: CreateElicitationRequest): PermissionPrompt {
|
||||||
|
const input: Record<string, unknown> = { message: params.message };
|
||||||
|
if ('requestedSchema' in params) {
|
||||||
|
input.requestedSchema = params.requestedSchema;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
taskId,
|
||||||
|
kind: 'elicitation',
|
||||||
|
toolTitle: params.message,
|
||||||
|
input,
|
||||||
|
options: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function waitForElicitationResponse(
|
||||||
|
taskId: string,
|
||||||
|
sessionId: string,
|
||||||
|
provider: string,
|
||||||
|
modeId: string | undefined,
|
||||||
|
params: CreateElicitationRequest,
|
||||||
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||||
|
): Promise<CreateElicitationResponse> {
|
||||||
|
if (isUnattendedMode(provider, modeId)) {
|
||||||
|
return Promise.resolve({ action: 'decline' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const existing = pendingByTask.get(taskId);
|
||||||
|
if (existing) {
|
||||||
|
clearTimeout(existing.timer);
|
||||||
|
existing.reject(new Error('superseded by newer elicitation request'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pendingByTask.delete(taskId);
|
||||||
|
void hooks.onResolved?.(taskId, sessionId);
|
||||||
|
resolve({ action: 'cancel' });
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
pendingByTask.set(taskId, { type: 'elicitation', request: params, sessionId, resolve, reject, timer });
|
||||||
|
|
||||||
|
const prompt = elicitationToPrompt(taskId, params);
|
||||||
|
void hooks.onPrompt?.({ ...prompt, sessionId });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function cancelPendingPermission(taskId: string): void {
|
export function cancelPendingPermission(taskId: string): void {
|
||||||
const pending = pendingByTask.get(taskId);
|
const pending = pendingByTask.get(taskId);
|
||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
clearTimeout(pending.timer);
|
clearTimeout(pending.timer);
|
||||||
pendingByTask.delete(taskId);
|
pendingByTask.delete(taskId);
|
||||||
pending.resolve({ outcome: { outcome: 'cancelled' } });
|
if (pending.type === 'elicitation') {
|
||||||
|
pending.resolve({ action: 'cancel' });
|
||||||
|
} else {
|
||||||
|
pending.resolve({ outcome: { outcome: 'cancelled' } });
|
||||||
|
}
|
||||||
void hooks.onResolved?.(taskId, pending.sessionId);
|
void hooks.onResolved?.(taskId, pending.sessionId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,23 +5,74 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
".": {
|
||||||
"./inference": { "types": "./dist/services/inference/index.d.ts", "default": "./dist/services/inference/index.js" },
|
"types": "./dist/index.d.ts",
|
||||||
"./tools": { "types": "./dist/services/tools.d.ts", "default": "./dist/services/tools.js" },
|
"default": "./dist/index.js"
|
||||||
"./broker": { "types": "./dist/services/broker.d.ts", "default": "./dist/services/broker.js" },
|
},
|
||||||
"./compaction": { "types": "./dist/services/compaction.d.ts", "default": "./dist/services/compaction.js" },
|
"./inference": {
|
||||||
"./model-context": { "types": "./dist/services/model-context.d.ts", "default": "./dist/services/model-context.js" },
|
"types": "./dist/services/inference/index.d.ts",
|
||||||
"./system-prompt": { "types": "./dist/services/system-prompt.d.ts", "default": "./dist/services/system-prompt.js" },
|
"default": "./dist/services/inference/index.js"
|
||||||
"./agents": { "types": "./dist/services/agents.d.ts", "default": "./dist/services/agents.js" },
|
},
|
||||||
"./truncate": { "types": "./dist/services/truncate.d.ts", "default": "./dist/services/truncate.js" },
|
"./tools": {
|
||||||
"./path-guard": { "types": "./dist/services/path_guard.d.ts", "default": "./dist/services/path_guard.js" },
|
"types": "./dist/services/tools.d.ts",
|
||||||
"./file-ops": { "types": "./dist/services/file_ops.d.ts", "default": "./dist/services/file_ops.js" },
|
"default": "./dist/services/tools.js"
|
||||||
"./types": { "types": "./dist/types/api.d.ts", "default": "./dist/types/api.js" },
|
},
|
||||||
"./ws-frames": { "types": "./dist/types/ws-frames.d.ts", "default": "./dist/types/ws-frames.js" },
|
"./broker": {
|
||||||
"./db": { "types": "./dist/db.d.ts", "default": "./dist/db.js" },
|
"types": "./dist/services/broker.d.ts",
|
||||||
"./config": { "types": "./dist/config.d.ts", "default": "./dist/config.js" },
|
"default": "./dist/services/broker.js"
|
||||||
"./skills": { "types": "./dist/services/skills.d.ts", "default": "./dist/services/skills.js" },
|
},
|
||||||
"./skill-invoke": { "types": "./dist/services/skill-invoke.d.ts", "default": "./dist/services/skill-invoke.js" }
|
"./compaction": {
|
||||||
|
"types": "./dist/services/compaction.d.ts",
|
||||||
|
"default": "./dist/services/compaction.js"
|
||||||
|
},
|
||||||
|
"./model-context": {
|
||||||
|
"types": "./dist/services/model-context.d.ts",
|
||||||
|
"default": "./dist/services/model-context.js"
|
||||||
|
},
|
||||||
|
"./system-prompt": {
|
||||||
|
"types": "./dist/services/system-prompt.d.ts",
|
||||||
|
"default": "./dist/services/system-prompt.js"
|
||||||
|
},
|
||||||
|
"./agents": {
|
||||||
|
"types": "./dist/services/agents.d.ts",
|
||||||
|
"default": "./dist/services/agents.js"
|
||||||
|
},
|
||||||
|
"./truncate": {
|
||||||
|
"types": "./dist/services/truncate.d.ts",
|
||||||
|
"default": "./dist/services/truncate.js"
|
||||||
|
},
|
||||||
|
"./path-guard": {
|
||||||
|
"types": "./dist/services/path_guard.d.ts",
|
||||||
|
"default": "./dist/services/path_guard.js"
|
||||||
|
},
|
||||||
|
"./file-ops": {
|
||||||
|
"types": "./dist/services/file_ops.d.ts",
|
||||||
|
"default": "./dist/services/file_ops.js"
|
||||||
|
},
|
||||||
|
"./types": {
|
||||||
|
"types": "./dist/types/api.d.ts",
|
||||||
|
"default": "./dist/types/api.js"
|
||||||
|
},
|
||||||
|
"./ws-frames": {
|
||||||
|
"types": "./dist/types/ws-frames.d.ts",
|
||||||
|
"default": "./dist/types/ws-frames.js"
|
||||||
|
},
|
||||||
|
"./db": {
|
||||||
|
"types": "./dist/db.d.ts",
|
||||||
|
"default": "./dist/db.js"
|
||||||
|
},
|
||||||
|
"./config": {
|
||||||
|
"types": "./dist/config.d.ts",
|
||||||
|
"default": "./dist/config.js"
|
||||||
|
},
|
||||||
|
"./skills": {
|
||||||
|
"types": "./dist/services/skills.d.ts",
|
||||||
|
"default": "./dist/services/skills.js"
|
||||||
|
},
|
||||||
|
"./skill-invoke": {
|
||||||
|
"types": "./dist/services/skill-invoke.d.ts",
|
||||||
|
"default": "./dist/services/skill-invoke.js"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
@@ -36,6 +87,7 @@
|
|||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"ai": "^6.0.190",
|
"ai": "^6.0.190",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
|
"parse5": "^8.0.1",
|
||||||
"postgres": "^3.4.4",
|
"postgres": "^3.4.4",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
@@ -46,5 +98,6 @@
|
|||||||
"tsx": "^4.16.2",
|
"tsx": "^4.16.2",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
}
|
},
|
||||||
|
"license": "AGPL-3.0-only"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ const ConfigSchema = z.object({
|
|||||||
// v2.0.5: cheaper model for titles, summaries, labeling. Falls back to
|
// v2.0.5: cheaper model for titles, summaries, labeling. Falls back to
|
||||||
// session model (auto_name) or DEFAULT_MODEL when unset.
|
// session model (auto_name) or DEFAULT_MODEL when unset.
|
||||||
FAST_MODEL: z.string().optional(),
|
FAST_MODEL: z.string().optional(),
|
||||||
|
TASK_MODEL_URL: z.string().url().optional(),
|
||||||
|
LLAMA_SIDECAR_URL: z.string().url().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Config = z.infer<typeof ConfigSchema>;
|
export type Config = z.infer<typeof ConfigSchema>;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { cleanupTruncations } from './services/truncate.js';
|
|||||||
import { loadMcpConfig } from './services/mcp-config.js';
|
import { loadMcpConfig } from './services/mcp-config.js';
|
||||||
import { initialize as initMcp, getTools as getMcpTools, shutdown as shutdownMcp } from './services/mcp-client.js';
|
import { initialize as initMcp, getTools as getMcpTools, shutdown as shutdownMcp } from './services/mcp-client.js';
|
||||||
import { appendMcpTools } from './services/tools.js';
|
import { appendMcpTools } from './services/tools.js';
|
||||||
import { refreshToolNames } from './services/agents.js';
|
import { refreshToolNames, getAgentsForProject } from './services/agents.js';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
@@ -91,6 +91,20 @@ async function main() {
|
|||||||
}
|
}
|
||||||
app.addHook('onClose', async () => { await shutdownMcp(); });
|
app.addHook('onClose', async () => { await shutdownMcp(); });
|
||||||
|
|
||||||
|
// Boot-time guard: if any agent has llama_extra_args but LLAMA_SIDECAR_URL
|
||||||
|
// is unset, fail fast. Silent fallback would defeat per-agent flags.
|
||||||
|
if (!config.LLAMA_SIDECAR_URL) {
|
||||||
|
const { agents } = await getAgentsForProject('');
|
||||||
|
const offending = agents.find(a => a.llama_extra_args && a.llama_extra_args.length > 0);
|
||||||
|
if (offending) {
|
||||||
|
app.log.fatal(
|
||||||
|
{ agent: offending.name },
|
||||||
|
`Agent "${offending.name}" has llama_extra_args but LLAMA_SIDECAR_URL is not set`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await app.register(fastifyWebsocket);
|
await app.register(fastifyWebsocket);
|
||||||
|
|
||||||
app.get('/api/health', async () => {
|
app.get('/api/health', async () => {
|
||||||
|
|||||||
@@ -344,6 +344,7 @@ INSERT INTO settings (key, value) VALUES ('theme_mode', '"dark"') ON CONFLICT (k
|
|||||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_system_prompt TEXT NOT NULL DEFAULT '';
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_system_prompt TEXT NOT NULL DEFAULT '';
|
||||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_web_search_enabled BOOLEAN NOT NULL DEFAULT false;
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_web_search_enabled BOOLEAN NOT NULL DEFAULT false;
|
||||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS web_search_enabled BOOLEAN;
|
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS web_search_enabled BOOLEAN;
|
||||||
|
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS tags TEXT[] DEFAULT '{}';
|
||||||
|
|
||||||
-- v1.11: anchored rolling compaction.
|
-- v1.11: anchored rolling compaction.
|
||||||
-- compacted_at — marks rows that are "behind the curtain" of the latest
|
-- compacted_at — marks rows that are "behind the curtain" of the latest
|
||||||
@@ -366,3 +367,39 @@ ALTER TABLE messages ADD COLUMN IF NOT EXISTS summary BOOLEAN NOT NULL DEFAULT F
|
|||||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS tail_start_id UUID REFERENCES messages(id) ON DELETE SET NULL;
|
ALTER TABLE messages ADD COLUMN IF NOT EXISTS tail_start_id UUID REFERENCES messages(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE chats ADD COLUMN IF NOT EXISTS needs_compaction BOOLEAN NOT NULL DEFAULT FALSE;
|
ALTER TABLE chats ADD COLUMN IF NOT EXISTS needs_compaction BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_chat_compacted ON messages (chat_id, compacted_at);
|
CREATE INDEX IF NOT EXISTS idx_messages_chat_compacted ON messages (chat_id, compacted_at);
|
||||||
|
|
||||||
|
-- tasks table (provider dispatch, arena)
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
session_id UUID REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
|
parent_task_id UUID REFERENCES tasks(id),
|
||||||
|
arena_id UUID,
|
||||||
|
state TEXT NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK (state IN ('pending','running','completed','failed','blocked','cancelled')),
|
||||||
|
input TEXT NOT NULL,
|
||||||
|
output_summary TEXT,
|
||||||
|
agent TEXT,
|
||||||
|
model TEXT,
|
||||||
|
mode_id TEXT,
|
||||||
|
thinking_option_id TEXT,
|
||||||
|
feature_values JSONB,
|
||||||
|
execution_path TEXT CHECK (execution_path IS NULL OR execution_path IN ('native','acp','pty','qwen')),
|
||||||
|
worktree_path TEXT,
|
||||||
|
cost_tokens INTEGER,
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
ended_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Fix tasks FK to cascade on session delete (existing tables without CASCADE)
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'tasks_session_id_fkey'
|
||||||
|
AND confdeltype != 'c'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE tasks DROP CONSTRAINT tasks_session_id_fkey;
|
||||||
|
ALTER TABLE tasks ADD CONSTRAINT tasks_session_id_fkey
|
||||||
|
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|||||||
223
apps/server/src/services/__tests__/html-to-md.test.ts
Normal file
223
apps/server/src/services/__tests__/html-to-md.test.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { htmlToMarkdown } from '../web/html-to-md.js';
|
||||||
|
|
||||||
|
describe('htmlToMarkdown', () => {
|
||||||
|
it('converts h1 heading', () => {
|
||||||
|
expect(htmlToMarkdown('<h1>Title</h1>')).toBe('# Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts h1 through h6', () => {
|
||||||
|
const html = '<h1>One</h1><h2>Two</h2><h3>Three</h3><h4>Four</h4><h5>Five</h5><h6>Six</h6>';
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
expect(md).toContain('# One');
|
||||||
|
expect(md).toContain('## Two');
|
||||||
|
expect(md).toContain('### Three');
|
||||||
|
expect(md).toContain('#### Four');
|
||||||
|
expect(md).toContain('##### Five');
|
||||||
|
expect(md).toContain('###### Six');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts anchor with href', () => {
|
||||||
|
expect(htmlToMarkdown('<a href="https://example.com">click here</a>'))
|
||||||
|
.toBe('[click here](https://example.com)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts anchor without href to plain text', () => {
|
||||||
|
expect(htmlToMarkdown('<a>just text</a>')).toBe('just text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts bold and italic', () => {
|
||||||
|
expect(htmlToMarkdown('<strong>bold</strong>')).toBe('**bold**');
|
||||||
|
expect(htmlToMarkdown('<b>bold</b>')).toBe('**bold**');
|
||||||
|
expect(htmlToMarkdown('<em>italic</em>')).toBe('*italic*');
|
||||||
|
expect(htmlToMarkdown('<i>italic</i>')).toBe('*italic*');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles combined bold+italic', () => {
|
||||||
|
const md = htmlToMarkdown('<strong><em>bold italic</em></strong>');
|
||||||
|
expect(md).toBe('***bold italic***');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts unordered list', () => {
|
||||||
|
const html = '<ul><li>one</li><li>two</li><li>three</li></ul>';
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
expect(md).toContain('* one');
|
||||||
|
expect(md).toContain('* two');
|
||||||
|
expect(md).toContain('* three');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts ordered list', () => {
|
||||||
|
const html = '<ol><li>first</li><li>second</li></ol>';
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
expect(md).toContain('1. first');
|
||||||
|
expect(md).toContain('2. second');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles nested lists', () => {
|
||||||
|
const html = '<ul><li>outer<ul><li>inner</li></ul></li></ul>';
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
expect(md).toContain('* outer');
|
||||||
|
expect(md).toContain(' * inner');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts 3-column GFM table with header', () => {
|
||||||
|
const html = `
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Name</th><th>Age</th><th>City</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Alice</td><td>30</td><td>NYC</td></tr>
|
||||||
|
<tr><td>Bob</td><td>25</td><td>LA</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>`;
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
expect(md).toContain('| Name | Age | City |');
|
||||||
|
expect(md).toContain('| --- | --- | --- |');
|
||||||
|
expect(md).toContain('| Alice | 30 | NYC |');
|
||||||
|
expect(md).toContain('| Bob | 25 | LA |');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes pipe characters in table cells', () => {
|
||||||
|
const html = '<table><tr><th>A</th></tr><tr><td>x | y</td></tr></table>';
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
expect(md).toContain('x \\| y');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts blockquote', () => {
|
||||||
|
const html = '<blockquote><p>quoted text</p></blockquote>';
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
expect(md).toContain('> quoted text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts multi-line blockquote', () => {
|
||||||
|
const html = '<blockquote><p>line one</p><p>line two</p></blockquote>';
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
expect(md).toContain('> line one');
|
||||||
|
expect(md).toContain('> line two');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts fenced code block', () => {
|
||||||
|
const html = '<pre><code>const x = 1;</code></pre>';
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
expect(md).toContain('```\nconst x = 1;\n```');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves language hint from code class', () => {
|
||||||
|
const html = '<pre><code class="language-py">print("hello")</code></pre>';
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
expect(md).toContain('```py\nprint("hello")\n```');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts inline code', () => {
|
||||||
|
expect(htmlToMarkdown('use <code>npm install</code> to install'))
|
||||||
|
.toContain('`npm install`');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes HTML entities', () => {
|
||||||
|
expect(htmlToMarkdown('& < > "')).toBe('& < > "');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes numeric character references', () => {
|
||||||
|
expect(htmlToMarkdown(''')).toBe("'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes as space', () => {
|
||||||
|
const md = htmlToMarkdown('hello world');
|
||||||
|
expect(md).toMatch(/hello\s+world/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips script content', () => {
|
||||||
|
const html = '<p>before</p><script>alert("xss")</script><p>after</p>';
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
expect(md).not.toContain('alert');
|
||||||
|
expect(md).toContain('before');
|
||||||
|
expect(md).toContain('after');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips style content', () => {
|
||||||
|
const html = '<p>text</p><style>body { color: red }</style>';
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
expect(md).not.toContain('color');
|
||||||
|
expect(md).toContain('text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw on malformed HTML', () => {
|
||||||
|
expect(() => htmlToMarkdown('<p>unclosed <b>bold <i>italic')).not.toThrow();
|
||||||
|
const md = htmlToMarkdown('<p>unclosed <b>bold <i>italic');
|
||||||
|
expect(md).toContain('bold');
|
||||||
|
expect(md).toContain('italic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for empty input', () => {
|
||||||
|
expect(htmlToMarkdown('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for whitespace-only input', () => {
|
||||||
|
expect(htmlToMarkdown(' \n\n ')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts hr to horizontal rule', () => {
|
||||||
|
const md = htmlToMarkdown('<p>above</p><hr><p>below</p>');
|
||||||
|
expect(md).toContain('---');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts br to newline', () => {
|
||||||
|
const md = htmlToMarkdown('line one<br>line two');
|
||||||
|
expect(md).toContain('line one\nline two');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles ol with start attribute', () => {
|
||||||
|
const html = '<ol start="5"><li>five</li><li>six</li></ol>';
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
expect(md).toContain('5. five');
|
||||||
|
expect(md).toContain('6. six');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses excessive blank lines', () => {
|
||||||
|
const html = '<p>one</p><p></p><p></p><p></p><p>two</p>';
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
const blankRuns = md.match(/\n{3,}/g);
|
||||||
|
expect(blankRuns).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Golden test: small Hacker News-style snippet
|
||||||
|
it('golden: HN-style snippet produces structured markdown', () => {
|
||||||
|
const html = `
|
||||||
|
<html>
|
||||||
|
<head><title>Test Page</title></head>
|
||||||
|
<body>
|
||||||
|
<h1>Welcome</h1>
|
||||||
|
<p>This is a <strong>test</strong> page with <a href="https://example.com">a link</a>.</p>
|
||||||
|
<h2>Features</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Fast</li>
|
||||||
|
<li>Reliable</li>
|
||||||
|
<li>Secure</li>
|
||||||
|
</ul>
|
||||||
|
<h2>Data</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Metric</th><th>Value</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Uptime</td><td>99.9%</td></tr>
|
||||||
|
<tr><td>Latency</td><td>42ms</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<blockquote><p>This tool is amazing.</p></blockquote>
|
||||||
|
<pre><code class="language-js">console.log("hello");</code></pre>
|
||||||
|
<script>evil();</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
expect(md).toContain('# Welcome');
|
||||||
|
expect(md).toContain('**test**');
|
||||||
|
expect(md).toContain('[a link](https://example.com)');
|
||||||
|
expect(md).toContain('## Features');
|
||||||
|
expect(md).toContain('* Fast');
|
||||||
|
expect(md).toContain('| Metric | Value |');
|
||||||
|
expect(md).toContain('| --- | --- |');
|
||||||
|
expect(md).toContain('| Uptime | 99.9% |');
|
||||||
|
expect(md).toContain('> This tool is amazing.');
|
||||||
|
expect(md).toContain('```js\nconsole.log("hello");\n```');
|
||||||
|
expect(md).not.toContain('evil');
|
||||||
|
expect(md).not.toContain('<title>');
|
||||||
|
});
|
||||||
|
});
|
||||||
160
apps/server/src/services/__tests__/llama-args-validator.test.ts
Normal file
160
apps/server/src/services/__tests__/llama-args-validator.test.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
validateExtraArgs,
|
||||||
|
isManagedFlag,
|
||||||
|
stripShadowingFlags,
|
||||||
|
} from '../inference/llama-args-validator.js';
|
||||||
|
import { parseAgentsMd } from '../agents.js';
|
||||||
|
|
||||||
|
describe('validateExtraArgs', () => {
|
||||||
|
describe('deny list — each alias rejected', () => {
|
||||||
|
const denied = [
|
||||||
|
'-m', '--model',
|
||||||
|
'-mu', '--model-url',
|
||||||
|
'-dr', '--docker-repo',
|
||||||
|
'-hf', '-hfr', '--hf-repo',
|
||||||
|
'-hff', '--hf-file',
|
||||||
|
'-hfv', '-hfrv', '--hf-repo-v',
|
||||||
|
'-hffv', '--hf-file-v',
|
||||||
|
'-hft', '--hf-token',
|
||||||
|
'-mm', '--mmproj',
|
||||||
|
'-mmu', '--mmproj-url',
|
||||||
|
'--host', '--port', '--path', '--api-prefix', '--reuse-port',
|
||||||
|
'--api-key', '--api-key-file',
|
||||||
|
'--ssl-key-file', '--ssl-cert-file',
|
||||||
|
'--webui', '--no-webui', '--ui', '--no-ui',
|
||||||
|
'--ui-config', '--ui-config-file',
|
||||||
|
'--ui-mcp-proxy', '--no-ui-mcp-proxy',
|
||||||
|
'--models-dir', '--models-preset', '--models-max',
|
||||||
|
'--models-autoload', '--no-models-autoload',
|
||||||
|
];
|
||||||
|
for (const flag of denied) {
|
||||||
|
it(`rejects ${flag}`, () => {
|
||||||
|
expect(() => validateExtraArgs([flag])).toThrow(/managed/);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('safe flags accepted', () => {
|
||||||
|
const safe = [
|
||||||
|
'-c', '--ctx-size', '-ngl', '--gpu-layers',
|
||||||
|
'--top-k', '--cache-type-k', '--jinja', '--no-jinja',
|
||||||
|
'--spec-draft-n-max', '-fa', '--flash-attn',
|
||||||
|
'-t', '--threads', '-np', '--parallel',
|
||||||
|
];
|
||||||
|
for (const flag of safe) {
|
||||||
|
it(`accepts ${flag}`, () => {
|
||||||
|
expect(() => validateExtraArgs([flag])).not.toThrow();
|
||||||
|
expect(validateExtraArgs([flag])).toEqual([flag]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles --flag=value shape (denies the flag part)', () => {
|
||||||
|
expect(() => validateExtraArgs(['--model=evil.gguf'])).toThrow(/managed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles --flag=value shape (accepts safe flag)', () => {
|
||||||
|
expect(validateExtraArgs(['--ctx-size=4096'])).toEqual(['--ctx-size=4096']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for undefined input', () => {
|
||||||
|
expect(validateExtraArgs(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for empty input', () => {
|
||||||
|
expect(validateExtraArgs([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats negative numbers as values, not flags', () => {
|
||||||
|
expect(validateExtraArgs(['--seed', '-1'])).toEqual(['--seed', '-1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isManagedFlag', () => {
|
||||||
|
it('returns true for denied flags', () => {
|
||||||
|
expect(isManagedFlag('--model')).toBe(true);
|
||||||
|
expect(isManagedFlag('-m')).toBe(true);
|
||||||
|
expect(isManagedFlag('--api-key')).toBe(true);
|
||||||
|
expect(isManagedFlag('--port')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for safe flags', () => {
|
||||||
|
expect(isManagedFlag('-c')).toBe(false);
|
||||||
|
expect(isManagedFlag('--ctx-size')).toBe(false);
|
||||||
|
expect(isManagedFlag('--top-k')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stripShadowingFlags', () => {
|
||||||
|
it('strips auto -c when user supplies -c', () => {
|
||||||
|
const result = stripShadowingFlags(['-c', '4096', '--top-k', '40']);
|
||||||
|
expect(result).toEqual(['--top-k', '40']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains both when no overlap', () => {
|
||||||
|
const result = stripShadowingFlags(['--top-k', '40', '--top-p', '0.95']);
|
||||||
|
expect(result).toEqual(['--top-k', '40', '--top-p', '0.95']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips --ctx-size=value form', () => {
|
||||||
|
const result = stripShadowingFlags(['--ctx-size=4096']);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips boolean --jinja flag (no value consumed)', () => {
|
||||||
|
const result = stripShadowingFlags(['--jinja', '--top-k', '40']);
|
||||||
|
expect(result).toEqual(['--top-k', '40']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects stripContext=false to keep context flags', () => {
|
||||||
|
const result = stripShadowingFlags(['-c', '4096'], { stripContext: false });
|
||||||
|
expect(result).toEqual(['-c', '4096']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips cache flags by default', () => {
|
||||||
|
const result = stripShadowingFlags(['--cache-type-k', 'q8_0']);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips spec flags by default', () => {
|
||||||
|
const result = stripShadowingFlags(['--spec-draft-n-max', '16']);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AGENTS.md frontmatter validation', () => {
|
||||||
|
it('rejects agent with managed flag in llama_extra_args', () => {
|
||||||
|
const md = `## Evil Agent
|
||||||
|
---
|
||||||
|
llama_extra_args: ["--model", "evil.gguf"]
|
||||||
|
---
|
||||||
|
You are evil.`;
|
||||||
|
const { agents, errors } = parseAgentsMd(md);
|
||||||
|
expect(agents).toHaveLength(0);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0]!.reason).toContain('managed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts agent with safe llama_extra_args', () => {
|
||||||
|
const md = `## Good Agent
|
||||||
|
---
|
||||||
|
llama_extra_args: ["--top-k", "20"]
|
||||||
|
---
|
||||||
|
You are good.`;
|
||||||
|
const { agents, errors } = parseAgentsMd(md);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
expect(agents).toHaveLength(1);
|
||||||
|
expect(agents[0]!.llama_extra_args).toEqual(['--top-k', '20']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('agent without llama_extra_args has null field', () => {
|
||||||
|
const md = `## Simple Agent
|
||||||
|
---
|
||||||
|
temperature: 0.5
|
||||||
|
---
|
||||||
|
You are simple.`;
|
||||||
|
const { agents } = parseAgentsMd(md);
|
||||||
|
expect(agents[0]!.llama_extra_args).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
58
apps/server/src/services/__tests__/provider.test.ts
Normal file
58
apps/server/src/services/__tests__/provider.test.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resolveRoute, upstreamModel } from '../inference/provider.js';
|
||||||
|
|
||||||
|
describe('resolveRoute', () => {
|
||||||
|
it('routes to swap when agent is null', () => {
|
||||||
|
expect(resolveRoute(null)).toEqual({ route: 'swap', flags: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes to swap when agent has no llama_extra_args', () => {
|
||||||
|
expect(resolveRoute({ llama_extra_args: null })).toEqual({ route: 'swap', flags: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes to swap when agent has empty llama_extra_args', () => {
|
||||||
|
expect(resolveRoute({ llama_extra_args: [] })).toEqual({ route: 'swap', flags: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes to sidecar when agent has llama_extra_args', () => {
|
||||||
|
const result = resolveRoute({ llama_extra_args: ['--top-k', '20'] });
|
||||||
|
expect(result.route).toBe('sidecar');
|
||||||
|
expect(result.flags).toEqual(['--top-k', '20']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('upstreamModel', () => {
|
||||||
|
const swapConfig = { LLAMA_SWAP_URL: 'http://localhost:8401' };
|
||||||
|
const fullConfig = {
|
||||||
|
LLAMA_SWAP_URL: 'http://localhost:8401',
|
||||||
|
LLAMA_SIDECAR_URL: 'http://localhost:8402',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('returns a model for swap route (no agent)', () => {
|
||||||
|
const model = upstreamModel(swapConfig, 'test-model');
|
||||||
|
expect(model).toBeDefined();
|
||||||
|
expect((model as any).modelId).toBe('test-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a model for swap route (agent without extra args)', () => {
|
||||||
|
const model = upstreamModel(swapConfig, 'test-model', { llama_extra_args: null });
|
||||||
|
expect(model).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a model for sidecar route', () => {
|
||||||
|
const model = upstreamModel(fullConfig, 'test-model', { llama_extra_args: ['--top-k', '20'] });
|
||||||
|
expect(model).toBeDefined();
|
||||||
|
expect((model as any).modelId).toBe('test-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when sidecar route requested but URL missing', () => {
|
||||||
|
expect(() =>
|
||||||
|
upstreamModel(swapConfig, 'test-model', { llama_extra_args: ['--top-k', '20'] }),
|
||||||
|
).toThrow(/LLAMA_SIDECAR_URL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes to swap for empty llama_extra_args array', () => {
|
||||||
|
const model = upstreamModel(swapConfig, 'test-model', { llama_extra_args: [] });
|
||||||
|
expect(model).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,25 +1,24 @@
|
|||||||
// v1.13.16: covers the Qwen/Hermes <tool_call> parser, the new Anthropic
|
|
||||||
// <invoke> parser, the partial-opener detector for both flavors, the unified
|
|
||||||
// extraction helper, and the unknown-tool error formatter that downstream
|
|
||||||
// dispatch uses to give the model a recovery hint when it drifts to a
|
|
||||||
// Claude Code tool name like read_file instead of BooCode's view_file.
|
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
parseXmlToolCall,
|
parseXmlToolCall,
|
||||||
parseInvokeToolCall,
|
parseInvokeToolCall,
|
||||||
partialXmlOpenerStart,
|
partialXmlOpenerStart,
|
||||||
extractToolCallBlocks,
|
extractToolCallBlocks,
|
||||||
|
parseToolCallsFromText,
|
||||||
|
stripToolMarkup,
|
||||||
|
hasToolSignal,
|
||||||
XML_TOOL_OPEN,
|
XML_TOOL_OPEN,
|
||||||
XML_TOOL_CLOSE,
|
XML_TOOL_CLOSE,
|
||||||
INVOKE_TOOL_OPEN,
|
INVOKE_TOOL_OPEN,
|
||||||
INVOKE_TOOL_CLOSE,
|
INVOKE_TOOL_CLOSE,
|
||||||
} from '../inference/xml-parser.js';
|
TOOL_XML_SIGNALS,
|
||||||
import {
|
BUDGET_EXHAUSTED_NUDGE,
|
||||||
levenshtein,
|
DUPLICATE_CALL_NUDGE,
|
||||||
suggestToolName,
|
TOOL_ERROR_NUDGE,
|
||||||
formatUnknownToolError,
|
TOOL_ERROR_PREFIXES,
|
||||||
} from '../inference/tool-suggestions.js';
|
} from '../inference/tool-call-parser.js';
|
||||||
|
|
||||||
|
// ── Ported from xml-parser.test.ts ───────────────────────────────────────
|
||||||
|
|
||||||
describe('parseXmlToolCall (Qwen/Hermes <tool_call>)', () => {
|
describe('parseXmlToolCall (Qwen/Hermes <tool_call>)', () => {
|
||||||
it('parses a well-formed single-parameter call', () => {
|
it('parses a well-formed single-parameter call', () => {
|
||||||
@@ -66,7 +65,6 @@ describe('parseXmlToolCall (Qwen/Hermes <tool_call>)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('parseInvokeToolCall (Anthropic <invoke>) — v1.13.16', () => {
|
describe('parseInvokeToolCall (Anthropic <invoke>) — v1.13.16', () => {
|
||||||
// Spec case 1
|
|
||||||
it('parses a well-formed single-parameter call (spec case 1)', () => {
|
it('parses a well-formed single-parameter call (spec case 1)', () => {
|
||||||
const block = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
const block = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||||
expect(parseInvokeToolCall(block)).toEqual({
|
expect(parseInvokeToolCall(block)).toEqual({
|
||||||
@@ -75,7 +73,6 @@ describe('parseInvokeToolCall (Anthropic <invoke>) — v1.13.16', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Spec case 2
|
|
||||||
it('parses a multi-parameter call (spec case 2)', () => {
|
it('parses a multi-parameter call (spec case 2)', () => {
|
||||||
const block = '<invoke name="grep"><parameter name="pattern">foo</parameter><parameter name="path">src/</parameter></invoke>';
|
const block = '<invoke name="grep"><parameter name="pattern">foo</parameter><parameter name="path">src/</parameter></invoke>';
|
||||||
expect(parseInvokeToolCall(block)).toEqual({
|
expect(parseInvokeToolCall(block)).toEqual({
|
||||||
@@ -84,7 +81,6 @@ describe('parseInvokeToolCall (Anthropic <invoke>) — v1.13.16', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Spec case 3
|
|
||||||
it('tolerates newlines and spaces in attributes (spec case 3)', () => {
|
it('tolerates newlines and spaces in attributes (spec case 3)', () => {
|
||||||
const block = `<invoke
|
const block = `<invoke
|
||||||
name="view_file"
|
name="view_file"
|
||||||
@@ -99,7 +95,6 @@ describe('parseInvokeToolCall (Anthropic <invoke>) — v1.13.16', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Spec case 4 (parser portion — the not-found enrichment is tested below)
|
|
||||||
it('parses a call whose name is not a registered BooCode tool (spec case 4)', () => {
|
it('parses a call whose name is not a registered BooCode tool (spec case 4)', () => {
|
||||||
const block = '<invoke name="read_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
const block = '<invoke name="read_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||||
expect(parseInvokeToolCall(block)).toEqual({
|
expect(parseInvokeToolCall(block)).toEqual({
|
||||||
@@ -187,7 +182,6 @@ describe('partialXmlOpenerStart (v1.13.16 — both flavors)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
|
describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
|
||||||
// Spec case 1 (extraction-level)
|
|
||||||
it('extracts a single <invoke> block (spec case 1)', () => {
|
it('extracts a single <invoke> block (spec case 1)', () => {
|
||||||
const input = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
const input = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||||
const result = extractToolCallBlocks(input);
|
const result = extractToolCallBlocks(input);
|
||||||
@@ -196,7 +190,6 @@ describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
|
|||||||
expect(result.remaining).toBe('');
|
expect(result.remaining).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Spec case 5: opener arrives in one chunk, closer in the next.
|
|
||||||
it('holds the partial <invoke> chunk when the closer has not arrived (spec case 5, first chunk)', () => {
|
it('holds the partial <invoke> chunk when the closer has not arrived (spec case 5, first chunk)', () => {
|
||||||
const firstChunk = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter>';
|
const firstChunk = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter>';
|
||||||
const result = extractToolCallBlocks(firstChunk);
|
const result = extractToolCallBlocks(firstChunk);
|
||||||
@@ -215,7 +208,6 @@ describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
|
|||||||
expect(r2.remaining).toBe('');
|
expect(r2.remaining).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Spec case 6: prose interleaving
|
|
||||||
it('flushes prose around a recognized block but not the markup itself (spec case 6)', () => {
|
it('flushes prose around a recognized block but not the markup itself (spec case 6)', () => {
|
||||||
const input = 'I will read the file.\n<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>\nThanks.';
|
const input = 'I will read the file.\n<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>\nThanks.';
|
||||||
const result = extractToolCallBlocks(input);
|
const result = extractToolCallBlocks(input);
|
||||||
@@ -224,7 +216,6 @@ describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
|
|||||||
expect(result.remaining).toBe('');
|
expect(result.remaining).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Spec case 7 regression
|
|
||||||
it('extracts a <tool_call> Qwen block alongside the new code path (spec case 7 regression)', () => {
|
it('extracts a <tool_call> Qwen block alongside the new code path (spec case 7 regression)', () => {
|
||||||
const input = '<tool_call><function=view_file><parameter=path>/tmp/foo</parameter></function></tool_call>';
|
const input = '<tool_call><function=view_file><parameter=path>/tmp/foo</parameter></function></tool_call>';
|
||||||
const result = extractToolCallBlocks(input);
|
const result = extractToolCallBlocks(input);
|
||||||
@@ -310,86 +301,245 @@ describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('levenshtein', () => {
|
// ── New tests: Unsloth-ported functions ──────────────────────────────────
|
||||||
it('returns 0 for identical strings', () => {
|
|
||||||
expect(levenshtein('view_file', 'view_file')).toBe(0);
|
describe('hasToolSignal', () => {
|
||||||
|
it('returns true for <tool_call>', () => {
|
||||||
|
expect(hasToolSignal('prefix <tool_call> suffix')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the length when one string is empty', () => {
|
it('returns true for <function=', () => {
|
||||||
expect(levenshtein('', 'view_file')).toBe(9);
|
expect(hasToolSignal('prefix <function=view_file> suffix')).toBe(true);
|
||||||
expect(levenshtein('view_file', '')).toBe(9);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('computes a small distance for a single-character substitution', () => {
|
it('returns true for <invoke', () => {
|
||||||
expect(levenshtein('cat', 'bat')).toBe(1);
|
expect(hasToolSignal('prefix <invoke name="x"> suffix')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('computes a known case: read_file → view_file is 4', () => {
|
it('returns false for near-miss <tool>', () => {
|
||||||
// r→v, e→i, a→e, d→w → 4 substitutions, same length
|
expect(hasToolSignal('prefix <tool> suffix')).toBe(false);
|
||||||
expect(levenshtein('read_file', 'view_file')).toBe(4);
|
});
|
||||||
|
|
||||||
|
it('returns false for near-miss <function>', () => {
|
||||||
|
expect(hasToolSignal('prefix <function> suffix')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for near-miss <tool_call_thing>', () => {
|
||||||
|
expect(hasToolSignal('<tool_call_thing>')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for plain text', () => {
|
||||||
|
expect(hasToolSignal('just some text')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('suggestToolName (v1.13.16)', () => {
|
describe('stripToolMarkup', () => {
|
||||||
const tools = [
|
it('strips closed <tool_call> blocks', () => {
|
||||||
'view_file',
|
const input = 'before <tool_call>{"name":"x"}</tool_call> after';
|
||||||
'list_dir',
|
expect(stripToolMarkup(input)).toBe('before after');
|
||||||
'grep',
|
|
||||||
'find_files',
|
|
||||||
'view_truncated_output',
|
|
||||||
'ask_user_input',
|
|
||||||
'web_search',
|
|
||||||
];
|
|
||||||
|
|
||||||
it('suggests the closest match when distance is small', () => {
|
|
||||||
expect(suggestToolName('view_files', tools)).toBe('view_file');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('suggests via substring match when distance alone would miss', () => {
|
it('strips closed <function=...> blocks', () => {
|
||||||
// 'file' is a substring of multiple tools; closest by distance wins.
|
const input = 'before <function=x><parameter=y>z</parameter></function> after';
|
||||||
expect(suggestToolName('file', tools)).toBe('view_file');
|
expect(stripToolMarkup(input)).toBe('before after');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null when nothing is close', () => {
|
it('strips closed <invoke> blocks', () => {
|
||||||
expect(suggestToolName('xxxx_yyyy_zzzz', tools)).toBeNull();
|
const input = 'before <invoke name="x"><parameter name="y">z</parameter></invoke> after';
|
||||||
|
expect(stripToolMarkup(input)).toBe('before after');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is case-insensitive in the distance check', () => {
|
it('leaves trailing unclosed block when final=false', () => {
|
||||||
expect(suggestToolName('VIEW_FILE', tools)).toBe('view_file');
|
const input = 'text <tool_call>{"name":"x"';
|
||||||
|
expect(stripToolMarkup(input)).toBe('text <tool_call>{"name":"x"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips trailing unclosed <tool_call> when final=true', () => {
|
||||||
|
const input = 'text <tool_call>{"name":"x"';
|
||||||
|
expect(stripToolMarkup(input, { final: true })).toBe('text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips trailing unclosed <function= when final=true', () => {
|
||||||
|
const input = 'text <function=run_bash><parameter=command>ls';
|
||||||
|
expect(stripToolMarkup(input, { final: true })).toBe('text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips trailing unclosed <invoke when final=true', () => {
|
||||||
|
const input = 'text <invoke name="x"><parameter name="y">val';
|
||||||
|
expect(stripToolMarkup(input, { final: true })).toBe('text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims whitespace when final=true', () => {
|
||||||
|
const input = ' text <tool_call>partial';
|
||||||
|
expect(stripToolMarkup(input, { final: true })).toBe('text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips multiple closed blocks', () => {
|
||||||
|
const input = '<tool_call>a</tool_call> mid <tool_call>b</tool_call>';
|
||||||
|
expect(stripToolMarkup(input)).toBe(' mid ');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('formatUnknownToolError (v1.13.16)', () => {
|
describe('parseToolCallsFromText', () => {
|
||||||
const tools = ['view_file', 'list_dir', 'grep', 'find_files'];
|
describe('pattern 1: <tool_call>{json}</tool_call>', () => {
|
||||||
|
it('parses a well-formed JSON tool call', () => {
|
||||||
|
const input = '<tool_call>{"name":"web_search","arguments":{"query":"hello"}}</tool_call>';
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]!.id).toBe('call_0');
|
||||||
|
expect(calls[0]!.type).toBe('function');
|
||||||
|
expect(calls[0]!.function.name).toBe('web_search');
|
||||||
|
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ query: 'hello' });
|
||||||
|
});
|
||||||
|
|
||||||
it('includes the wrong name and the available tools list', () => {
|
it('handles string arguments field', () => {
|
||||||
const msg = formatUnknownToolError('read_file', tools);
|
const input = '<tool_call>{"name":"x","arguments":"already a string"}</tool_call>';
|
||||||
expect(msg).toContain("Tool 'read_file' not found");
|
const calls = parseToolCallsFromText(input);
|
||||||
expect(msg).toContain('Available tools:');
|
expect(calls[0]!.function.arguments).toBe('already a string');
|
||||||
expect(msg).toContain('view_file');
|
});
|
||||||
expect(msg).toContain('find_files');
|
|
||||||
|
it('handles balanced braces inside JSON strings', () => {
|
||||||
|
const input = '<tool_call>{"name":"x","arguments":{"q":"} { extra "}}</tool_call>';
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
const parsed = JSON.parse(calls[0]!.function.arguments);
|
||||||
|
expect(parsed.q).toBe('} { extra ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects idOffset', () => {
|
||||||
|
const input = '<tool_call>{"name":"a","arguments":{}}</tool_call>';
|
||||||
|
const calls = parseToolCallsFromText(input, { idOffset: 5 });
|
||||||
|
expect(calls[0]!.id).toBe('call_5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses multiple JSON tool calls', () => {
|
||||||
|
const input =
|
||||||
|
'<tool_call>{"name":"a","arguments":{}}</tool_call>' +
|
||||||
|
'<tool_call>{"name":"b","arguments":{}}</tool_call>';
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(2);
|
||||||
|
expect(calls[0]!.id).toBe('call_0');
|
||||||
|
expect(calls[1]!.id).toBe('call_1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips malformed JSON', () => {
|
||||||
|
const input = '<tool_call>{not json}</tool_call>';
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing closing tag', () => {
|
||||||
|
const input = '<tool_call>{"name":"x","arguments":{"q":"hello"}}';
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]!.function.name).toBe('x');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes a suggestion when the drifted name is within threshold', () => {
|
describe('pattern 2: <function=name><parameter=key>value', () => {
|
||||||
// distance(view_files, view_file) = 1 (one extra char)
|
it('parses a single-parameter function call', () => {
|
||||||
const msg = formatUnknownToolError('view_files', tools);
|
const input = '<function=view_file><parameter=path>/tmp/foo</parameter></function>';
|
||||||
expect(msg).toContain('Did you mean: view_file?');
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]!.function.name).toBe('view_file');
|
||||||
|
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('single-param fast path preserves embedded </parameter>', () => {
|
||||||
|
const input = '<function=run_bash><parameter=command>echo "</parameter>"</parameter></function>';
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(JSON.parse(calls[0]!.function.arguments).command).toBe('echo "</parameter>"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multi-param: value of first stops at start of second', () => {
|
||||||
|
const input = '<function=grep><parameter=pattern>foo</parameter><parameter=path>src/</parameter></function>';
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
const args = JSON.parse(calls[0]!.function.arguments);
|
||||||
|
expect(args.pattern).toBe('foo');
|
||||||
|
expect(args.path).toBe('src/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates missing closing tags', () => {
|
||||||
|
const input = '<function=view_file><parameter=path>/tmp/foo';
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]!.function.name).toBe('view_file');
|
||||||
|
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fire when pattern 1 found results', () => {
|
||||||
|
const input = '<tool_call>{"name":"a","arguments":{}}</tool_call><function=b><parameter=x>y</parameter></function>';
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]!.function.name).toBe('a');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omits the suggestion clause when no tool is close enough', () => {
|
describe('pattern 3: <invoke name="..."><parameter name="...">value (Anthropic)', () => {
|
||||||
const msg = formatUnknownToolError('zzzzzzz', tools);
|
it('parses a single-parameter invoke call', () => {
|
||||||
expect(msg).toContain("Tool 'zzzzzzz' not found");
|
const input = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||||
expect(msg).toContain('Available tools:');
|
const calls = parseToolCallsFromText(input);
|
||||||
expect(msg).not.toContain('Did you mean');
|
expect(calls).toHaveLength(1);
|
||||||
});
|
expect(calls[0]!.function.name).toBe('view_file');
|
||||||
|
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' });
|
||||||
|
});
|
||||||
|
|
||||||
// The drift incident in the recon (chat 30d8…1be7167, msg 7ff558f4) had the
|
it('parses multi-parameter invoke call', () => {
|
||||||
// model emit <invoke name="read_file">. lev(read_file, view_file) = 4, so
|
const input = '<invoke name="grep"><parameter name="pattern">foo</parameter><parameter name="path">src/</parameter></invoke>';
|
||||||
// the spec's threshold (<=3) doesn't suggest view_file — the model still
|
const calls = parseToolCallsFromText(input);
|
||||||
// gets the available-tools list to pick from. This pins that behavior so a
|
expect(calls).toHaveLength(1);
|
||||||
// future loosening of the threshold is a deliberate choice.
|
const args = JSON.parse(calls[0]!.function.arguments);
|
||||||
it('does not suggest view_file for the read_file drift case (distance is 4, over threshold)', () => {
|
expect(args.pattern).toBe('foo');
|
||||||
const msg = formatUnknownToolError('read_file', tools);
|
expect(args.path).toBe('src/');
|
||||||
expect(msg).not.toContain('Did you mean');
|
});
|
||||||
|
|
||||||
|
it('does not fire when pattern 1 found results', () => {
|
||||||
|
const input = '<tool_call>{"name":"a","arguments":{}}</tool_call><invoke name="b"><parameter name="x">y</parameter></invoke>';
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]!.function.name).toBe('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fire when pattern 2 found results', () => {
|
||||||
|
const input = '<function=a><parameter=x>y</parameter></function><invoke name="b"><parameter name="x">y</parameter></invoke>';
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]!.function.name).toBe('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates missing closing tags', () => {
|
||||||
|
const input = '<invoke name="view_file"><parameter name="path">/tmp/foo';
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports single-quoted attributes', () => {
|
||||||
|
const input = "<invoke name='view_file'><parameter name='path'>/tmp/foo</parameter></invoke>";
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]!.function.name).toBe('view_file');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constants', () => {
|
||||||
|
it('TOOL_XML_SIGNALS includes all three signal prefixes', () => {
|
||||||
|
expect(TOOL_XML_SIGNALS).toContain('<tool_call>');
|
||||||
|
expect(TOOL_XML_SIGNALS).toContain('<function=');
|
||||||
|
expect(TOOL_XML_SIGNALS).toContain('<invoke');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nudge constants are non-empty strings', () => {
|
||||||
|
expect(BUDGET_EXHAUSTED_NUDGE.length).toBeGreaterThan(0);
|
||||||
|
expect(DUPLICATE_CALL_NUDGE.length).toBeGreaterThan(0);
|
||||||
|
expect(TOOL_ERROR_NUDGE.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TOOL_ERROR_PREFIXES is a non-empty tuple', () => {
|
||||||
|
expect(TOOL_ERROR_PREFIXES.length).toBeGreaterThan(0);
|
||||||
|
expect(TOOL_ERROR_PREFIXES).toContain('Error');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
82
apps/server/src/services/__tests__/tool-suggestions.test.ts
Normal file
82
apps/server/src/services/__tests__/tool-suggestions.test.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
levenshtein,
|
||||||
|
suggestToolName,
|
||||||
|
formatUnknownToolError,
|
||||||
|
} from '../inference/tool-suggestions.js';
|
||||||
|
|
||||||
|
describe('levenshtein', () => {
|
||||||
|
it('returns 0 for identical strings', () => {
|
||||||
|
expect(levenshtein('view_file', 'view_file')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the length when one string is empty', () => {
|
||||||
|
expect(levenshtein('', 'view_file')).toBe(9);
|
||||||
|
expect(levenshtein('view_file', '')).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes a small distance for a single-character substitution', () => {
|
||||||
|
expect(levenshtein('cat', 'bat')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes a known case: read_file → view_file is 4', () => {
|
||||||
|
expect(levenshtein('read_file', 'view_file')).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('suggestToolName (v1.13.16)', () => {
|
||||||
|
const tools = [
|
||||||
|
'view_file',
|
||||||
|
'list_dir',
|
||||||
|
'grep',
|
||||||
|
'find_files',
|
||||||
|
'view_truncated_output',
|
||||||
|
'ask_user_input',
|
||||||
|
'web_search',
|
||||||
|
];
|
||||||
|
|
||||||
|
it('suggests the closest match when distance is small', () => {
|
||||||
|
expect(suggestToolName('view_files', tools)).toBe('view_file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suggests via substring match when distance alone would miss', () => {
|
||||||
|
expect(suggestToolName('file', tools)).toBe('view_file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when nothing is close', () => {
|
||||||
|
expect(suggestToolName('xxxx_yyyy_zzzz', tools)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is case-insensitive in the distance check', () => {
|
||||||
|
expect(suggestToolName('VIEW_FILE', tools)).toBe('view_file');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatUnknownToolError (v1.13.16)', () => {
|
||||||
|
const tools = ['view_file', 'list_dir', 'grep', 'find_files'];
|
||||||
|
|
||||||
|
it('includes the wrong name and the available tools list', () => {
|
||||||
|
const msg = formatUnknownToolError('read_file', tools);
|
||||||
|
expect(msg).toContain("Tool 'read_file' not found");
|
||||||
|
expect(msg).toContain('Available tools:');
|
||||||
|
expect(msg).toContain('view_file');
|
||||||
|
expect(msg).toContain('find_files');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes a suggestion when the drifted name is within threshold', () => {
|
||||||
|
const msg = formatUnknownToolError('view_files', tools);
|
||||||
|
expect(msg).toContain('Did you mean: view_file?');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the suggestion clause when no tool is close enough', () => {
|
||||||
|
const msg = formatUnknownToolError('zzzzzzz', tools);
|
||||||
|
expect(msg).toContain("Tool 'zzzzzzz' not found");
|
||||||
|
expect(msg).toContain('Available tools:');
|
||||||
|
expect(msg).not.toContain('Did you mean');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not suggest view_file for the read_file drift case (distance is 4, over threshold)', () => {
|
||||||
|
const msg = formatUnknownToolError('read_file', tools);
|
||||||
|
expect(msg).not.toContain('Did you mean');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { promises as fs } from 'node:fs';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import type { Agent, AgentsResponse, AgentParseError } from '../types/api.js';
|
import type { Agent, AgentsResponse, AgentParseError } from '../types/api.js';
|
||||||
import { ALL_TOOLS, resolveToolTier } from './tools.js';
|
import { ALL_TOOLS, resolveToolTier } from './tools.js';
|
||||||
|
import { validateExtraArgs } from './inference/llama-args-validator.js';
|
||||||
|
|
||||||
// v1.8.1: global agents live at /data/AGENTS.md inside the container
|
// v1.8.1: global agents live at /data/AGENTS.md inside the container
|
||||||
// (./data:/data:ro mount on the host). Per-project AGENTS.md at the project
|
// (./data:/data:ro mount on the host). Per-project AGENTS.md at the project
|
||||||
@@ -97,6 +98,7 @@ interface ParsedFrontmatter {
|
|||||||
// (200) in the outer loop. Integer ≥ 0; steps: 0 means "no tool calls
|
// (200) in the outer loop. Integer ≥ 0; steps: 0 means "no tool calls
|
||||||
// allowed" — the model responds text-only.
|
// allowed" — the model responds text-only.
|
||||||
steps?: number;
|
steps?: number;
|
||||||
|
llama_extra_args?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripQuotes(s: string): string {
|
function stripQuotes(s: string): string {
|
||||||
@@ -227,6 +229,34 @@ function parseFrontmatter(yaml: string): { data: ParsedFrontmatter; errors: stri
|
|||||||
} else {
|
} else {
|
||||||
errors.push(`steps must be a non-negative integer (got "${valueRaw}")`);
|
errors.push(`steps must be a non-negative integer (got "${valueRaw}")`);
|
||||||
}
|
}
|
||||||
|
} else if (key === 'llama_extra_args') {
|
||||||
|
if (valueRaw === '') {
|
||||||
|
data.llama_extra_args = [];
|
||||||
|
// No arrayKey support — llama_extra_args uses inline list only.
|
||||||
|
} else if (valueRaw.startsWith('[') && valueRaw.endsWith(']')) {
|
||||||
|
const inner = valueRaw.slice(1, -1);
|
||||||
|
const parsed = inner
|
||||||
|
.split(',')
|
||||||
|
.map((s) => stripQuotes(s.trim()))
|
||||||
|
.filter((s) => s.length > 0);
|
||||||
|
try {
|
||||||
|
validateExtraArgs(parsed);
|
||||||
|
data.llama_extra_args = parsed;
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const parsed = valueRaw
|
||||||
|
.split(',')
|
||||||
|
.map((s) => stripQuotes(s.trim()))
|
||||||
|
.filter((s) => s.length > 0);
|
||||||
|
try {
|
||||||
|
validateExtraArgs(parsed);
|
||||||
|
data.llama_extra_args = parsed;
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Unknown keys silently ignored — forward-compat.
|
// Unknown keys silently ignored — forward-compat.
|
||||||
}
|
}
|
||||||
@@ -328,6 +358,7 @@ function parseAgentSection(section: RawSection): Omit<Agent, 'source'> {
|
|||||||
model: typeof fm.model === 'string' && fm.model.length > 0 ? fm.model : null,
|
model: typeof fm.model === 'string' && fm.model.length > 0 ? fm.model : null,
|
||||||
max_tool_calls: typeof fm.max_tool_calls === 'number' ? fm.max_tool_calls : null,
|
max_tool_calls: typeof fm.max_tool_calls === 'number' ? fm.max_tool_calls : null,
|
||||||
steps: typeof fm.steps === 'number' ? fm.steps : null,
|
steps: typeof fm.steps === 'number' ? fm.steps : null,
|
||||||
|
llama_extra_args: Array.isArray(fm.llama_extra_args) ? fm.llama_extra_args : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { InferenceContext } from './inference/index.js';
|
import type { InferenceContext } from './inference/index.js';
|
||||||
|
import { taskModelCompletion } from './task-model.js';
|
||||||
|
|
||||||
const NAMING_SYSTEM_PROMPT =
|
const NAMING_SYSTEM_PROMPT =
|
||||||
'You name chat sessions based on what the assistant did. Summarize the topic or outcome — do NOT copy the first few words verbatim. Reply directly with no thinking, reasoning, or explanation. Output ONLY the title, 4 words max, no quotes, no punctuation, no prefix like "Title:".';
|
'You name chat sessions. Reply with ONLY the title. 4 to 6 words. No quotes, no punctuation, no prefix.';
|
||||||
|
|
||||||
const MAX_TITLE_CHARS = 60;
|
const MAX_TITLE_CHARS = 80;
|
||||||
|
|
||||||
function cleanTitle(raw: string): string {
|
function cleanTitle(raw: string): string {
|
||||||
let name = raw.trim();
|
let name = raw.trim();
|
||||||
@@ -18,27 +19,7 @@ function cleanTitle(raw: string): string {
|
|||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NamingResponse {
|
// TODO: wire suggestTags after task model validation
|
||||||
choices?: Array<{
|
|
||||||
message?: {
|
|
||||||
content?: string;
|
|
||||||
reasoning_content?: string;
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickTitleSource(data: NamingResponse): string {
|
|
||||||
const choice = data.choices?.[0]?.message;
|
|
||||||
if (!choice) return '';
|
|
||||||
if (choice.content && choice.content.trim().length > 0) return choice.content;
|
|
||||||
const reasoning = choice.reasoning_content ?? '';
|
|
||||||
if (reasoning.length === 0) return '';
|
|
||||||
const lines = reasoning
|
|
||||||
.split('\n')
|
|
||||||
.map((l) => l.trim())
|
|
||||||
.filter((l) => l.length > 0);
|
|
||||||
return lines[lines.length - 1] ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function maybeAutoNameChat(
|
export async function maybeAutoNameChat(
|
||||||
ctx: InferenceContext,
|
ctx: InferenceContext,
|
||||||
@@ -64,13 +45,6 @@ export async function maybeAutoNameChat(
|
|||||||
if (!chat) return;
|
if (!chat) return;
|
||||||
if (chat.name !== null && chat.name !== '') return;
|
if (chat.name !== null && chat.name !== '') return;
|
||||||
|
|
||||||
const sessionRows = await ctx.sql<{ model: string }[]>`
|
|
||||||
SELECT model FROM sessions WHERE id = ${sessionId}
|
|
||||||
`;
|
|
||||||
// v2.0.5: prefer FAST_MODEL for cheap LLM calls (titles, summaries).
|
|
||||||
const model = ctx.config.FAST_MODEL ?? sessionRows[0]?.model;
|
|
||||||
if (!model) return;
|
|
||||||
|
|
||||||
const assistantMsg = await ctx.sql<{ content: string }[]>`
|
const assistantMsg = await ctx.sql<{ content: string }[]>`
|
||||||
SELECT content FROM messages
|
SELECT content FROM messages
|
||||||
WHERE chat_id = ${chatId}
|
WHERE chat_id = ${chatId}
|
||||||
@@ -84,32 +58,12 @@ export async function maybeAutoNameChat(
|
|||||||
|
|
||||||
const assistantText = assistantMsg[0].content.slice(0, 2000);
|
const assistantText = assistantMsg[0].content.slice(0, 2000);
|
||||||
|
|
||||||
const body = {
|
const raw = await taskModelCompletion({
|
||||||
model,
|
system: NAMING_SYSTEM_PROMPT,
|
||||||
messages: [
|
user: assistantText,
|
||||||
{ role: 'system', content: NAMING_SYSTEM_PROMPT },
|
maxTokens: 30,
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: assistantText,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
max_tokens: 30,
|
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
stream: false,
|
|
||||||
chat_template_kwargs: { enable_thinking: false },
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await fetch(`${ctx.config.LLAMA_SWAP_URL}/v1/chat/completions`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => '');
|
|
||||||
throw new Error(`naming request failed: ${res.status} ${text.slice(0, 200)}`);
|
|
||||||
}
|
|
||||||
const data = (await res.json()) as NamingResponse;
|
|
||||||
const raw = pickTitleSource(data);
|
|
||||||
const name = cleanTitle(raw);
|
const name = cleanTitle(raw);
|
||||||
if (!name) {
|
if (!name) {
|
||||||
ctx.log.warn({ chatId, raw }, 'auto-name: empty title from model');
|
ctx.log.warn({ chatId, raw }, 'auto-name: empty title from model');
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import * as modelContext from '../model-context.js';
|
|||||||
import { maybeFlagForCompaction } from './payload.js';
|
import { maybeFlagForCompaction } from './payload.js';
|
||||||
import { insertParts, partsFromAssistantMessage } from './parts.js';
|
import { insertParts, partsFromAssistantMessage } from './parts.js';
|
||||||
import type { PartInsert } from './parts.js';
|
import type { PartInsert } from './parts.js';
|
||||||
|
import { stripToolMarkup } from './tool-call-parser.js';
|
||||||
import type { InferenceContext, StreamResult, TurnArgs } from './turn.js';
|
import type { InferenceContext, StreamResult, TurnArgs } from './turn.js';
|
||||||
|
|
||||||
export async function handleAbortOrError(
|
export async function handleAbortOrError(
|
||||||
@@ -21,6 +22,7 @@ export async function handleAbortOrError(
|
|||||||
const isAbort = err instanceof Error && err.name === 'AbortError';
|
const isAbort = err instanceof Error && err.name === 'AbortError';
|
||||||
const finalStatus = isAbort ? 'cancelled' : 'failed';
|
const finalStatus = isAbort ? 'cancelled' : 'failed';
|
||||||
const errMsg = err instanceof Error ? err.message : String(err);
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
accumulated = stripToolMarkup(accumulated, { final: true });
|
||||||
// v1.8.2: persist a structured error metadata blob on genuine failures so
|
// v1.8.2: persist a structured error metadata blob on genuine failures so
|
||||||
// the bubble can render the reason on reload without re-deriving from the
|
// the bubble can render the reason on reload without re-deriving from the
|
||||||
// (one-shot) WS error frame. User-initiated abort skips this — there's no
|
// (one-shot) WS error frame. User-initiated abort skips this — there's no
|
||||||
@@ -101,7 +103,8 @@ export async function finalizeCompletion(
|
|||||||
session: Session
|
session: Session
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { sessionId, chatId, assistantMessageId } = args;
|
const { sessionId, chatId, assistantMessageId } = args;
|
||||||
const { content, finishReason, promptTokens, completionTokens } = result;
|
const content = stripToolMarkup(result.content, { final: true });
|
||||||
|
const { finishReason, promptTokens, completionTokens } = result;
|
||||||
|
|
||||||
// v1.11.3: see executeToolPhase for the rationale.
|
// v1.11.3: see executeToolPhase for the rationale.
|
||||||
const mctx = await modelContext.getModelContext(session.model);
|
const mctx = await modelContext.getModelContext(session.model);
|
||||||
|
|||||||
142
apps/server/src/services/inference/llama-args-validator.ts
Normal file
142
apps/server/src/services/inference/llama-args-validator.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||||
|
// Ported from studio/backend/core/inference/llama_server_args.py.
|
||||||
|
// Original: https://github.com/unslothai/unsloth/blob/main/studio/backend/core/inference/llama_server_args.py
|
||||||
|
|
||||||
|
// Each group is the full set of aliases (short + long) for one hard-denied
|
||||||
|
// flag, taken from the llama-server README. Flags NOT in this list pass
|
||||||
|
// through and override auto-set values via llama.cpp's last-wins CLI parsing.
|
||||||
|
const DENYLIST_GROUPS: ReadonlyArray<ReadonlySet<string>> = [
|
||||||
|
// Model identity
|
||||||
|
new Set(['-m', '--model']),
|
||||||
|
new Set(['-mu', '--model-url']),
|
||||||
|
new Set(['-dr', '--docker-repo']),
|
||||||
|
new Set(['-hf', '-hfr', '--hf-repo']),
|
||||||
|
new Set(['-hff', '--hf-file']),
|
||||||
|
new Set(['-hfv', '-hfrv', '--hf-repo-v']),
|
||||||
|
new Set(['-hffv', '--hf-file-v']),
|
||||||
|
new Set(['-hft', '--hf-token']),
|
||||||
|
new Set(['-mm', '--mmproj']),
|
||||||
|
new Set(['-mmu', '--mmproj-url']),
|
||||||
|
// Networking
|
||||||
|
new Set(['--host']),
|
||||||
|
new Set(['--port']),
|
||||||
|
new Set(['--path']),
|
||||||
|
new Set(['--api-prefix']),
|
||||||
|
new Set(['--reuse-port']),
|
||||||
|
// Auth / TLS
|
||||||
|
new Set(['--api-key']),
|
||||||
|
new Set(['--api-key-file']),
|
||||||
|
new Set(['--ssl-key-file']),
|
||||||
|
new Set(['--ssl-cert-file']),
|
||||||
|
// Single-model server / UI
|
||||||
|
new Set(['--webui', '--no-webui']),
|
||||||
|
new Set(['--ui', '--no-ui']),
|
||||||
|
new Set(['--ui-config']),
|
||||||
|
new Set(['--ui-config-file']),
|
||||||
|
new Set(['--ui-mcp-proxy', '--no-ui-mcp-proxy']),
|
||||||
|
new Set(['--models-dir']),
|
||||||
|
new Set(['--models-preset']),
|
||||||
|
new Set(['--models-max']),
|
||||||
|
new Set(['--models-autoload', '--no-models-autoload']),
|
||||||
|
];
|
||||||
|
|
||||||
|
const DENYLIST: ReadonlySet<string> = new Set(
|
||||||
|
DENYLIST_GROUPS.flatMap((g) => [...g]),
|
||||||
|
);
|
||||||
|
|
||||||
|
function flagName(token: string): string | null {
|
||||||
|
if (!token.startsWith('-') || token === '-' || token === '--') return null;
|
||||||
|
if (token.length >= 2 && (token[1]!.match(/\d/) || token[1] === '.')) return null;
|
||||||
|
return token.split('=', 1)[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateExtraArgs(args?: Iterable<string>): string[] {
|
||||||
|
if (!args) return [];
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const raw of args) {
|
||||||
|
const token = String(raw);
|
||||||
|
const flag = flagName(token);
|
||||||
|
if (flag !== null && DENYLIST.has(flag)) {
|
||||||
|
throw new Error(
|
||||||
|
`llama-server flag '${flag}' is managed and cannot be passed as an extra arg`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out.push(token);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isManagedFlag(flag: string): boolean {
|
||||||
|
return DENYLIST.has(flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shadowing flag groups: pass-through flags that shadow first-class settings.
|
||||||
|
const CONTEXT_FLAGS = new Set(['-c', '--ctx-size']);
|
||||||
|
const CACHE_FLAGS = new Set(['-ctk', '--cache-type-k', '-ctv', '--cache-type-v']);
|
||||||
|
const SPEC_FLAGS = new Set([
|
||||||
|
'--spec-default',
|
||||||
|
'--spec-type',
|
||||||
|
'--spec-ngram-size-n',
|
||||||
|
'--spec-ngram-size',
|
||||||
|
'--draft-min',
|
||||||
|
'--draft-max',
|
||||||
|
'--spec-draft-n-max',
|
||||||
|
'--spec-draft-n-min',
|
||||||
|
'--spec-draft-p-min',
|
||||||
|
'--spec-draft-p-split',
|
||||||
|
'--spec-ngram-mod-n-match',
|
||||||
|
'--spec-ngram-mod-n-min',
|
||||||
|
'--spec-ngram-mod-n-max',
|
||||||
|
]);
|
||||||
|
const TEMPLATE_FLAGS = new Set([
|
||||||
|
'--chat-template',
|
||||||
|
'--chat-template-file',
|
||||||
|
'--chat-template-kwargs',
|
||||||
|
'--jinja',
|
||||||
|
'--no-jinja',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const BOOLEAN_SHADOWING_FLAGS = new Set([
|
||||||
|
'--spec-default', '--jinja', '--no-jinja',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export interface StripOptions {
|
||||||
|
stripContext?: boolean;
|
||||||
|
stripCache?: boolean;
|
||||||
|
stripSpec?: boolean;
|
||||||
|
stripTemplate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripShadowingFlags(
|
||||||
|
args: Iterable<string>,
|
||||||
|
opts?: StripOptions,
|
||||||
|
): string[] {
|
||||||
|
const shadowing = new Set<string>();
|
||||||
|
if (opts?.stripContext !== false) for (const f of CONTEXT_FLAGS) shadowing.add(f);
|
||||||
|
if (opts?.stripCache !== false) for (const f of CACHE_FLAGS) shadowing.add(f);
|
||||||
|
if (opts?.stripSpec !== false) for (const f of SPEC_FLAGS) shadowing.add(f);
|
||||||
|
if (opts?.stripTemplate !== false) for (const f of TEMPLATE_FLAGS) shadowing.add(f);
|
||||||
|
|
||||||
|
const tokens = [...args].map(String);
|
||||||
|
const out: string[] = [];
|
||||||
|
let i = 0;
|
||||||
|
const n = tokens.length;
|
||||||
|
while (i < n) {
|
||||||
|
const tok = tokens[i]!;
|
||||||
|
const flag = flagName(tok);
|
||||||
|
if (flag === null || !shadowing.has(flag)) {
|
||||||
|
out.push(tok);
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (BOOLEAN_SHADOWING_FLAGS.has(flag) || tok.includes('=')) {
|
||||||
|
i++;
|
||||||
|
} else if (i + 1 < n && flagName(tokens[i + 1]!) === null) {
|
||||||
|
i += 2;
|
||||||
|
} else {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -6,29 +6,79 @@ import type { LanguageModel } from 'ai';
|
|||||||
// upstream without touching env vars. No apiKey — llama-swap is unauth in our
|
// upstream without touching env vars. No apiKey — llama-swap is unauth in our
|
||||||
// Tailscale topology and exposing it over the public internet is gated by
|
// Tailscale topology and exposing it over the public internet is gated by
|
||||||
// Authelia at the Caddy layer, not by API keys.
|
// Authelia at the Caddy layer, not by API keys.
|
||||||
|
//
|
||||||
|
// v2.4.1-sidecar: when the agent has llama_extra_args, route through
|
||||||
|
// llama-sidecar instead. A fresh provider is created per call (not cached)
|
||||||
|
// because the X-Agent-Flags header varies per agent. The llama-swap path
|
||||||
|
// stays cached since it has no per-request headers.
|
||||||
|
|
||||||
const cache = new Map<string, ReturnType<typeof createOpenAICompatible>>();
|
const swapCache = new Map<string, ReturnType<typeof createOpenAICompatible>>();
|
||||||
|
|
||||||
function getProvider(baseURL: string): ReturnType<typeof createOpenAICompatible> {
|
function getSwapProvider(baseURL: string): ReturnType<typeof createOpenAICompatible> {
|
||||||
let provider = cache.get(baseURL);
|
let provider = swapCache.get(baseURL);
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
provider = createOpenAICompatible({
|
provider = createOpenAICompatible({
|
||||||
name: 'llama-swap',
|
name: 'llama-swap',
|
||||||
baseURL: baseURL.endsWith('/v1') ? baseURL : `${baseURL}/v1`,
|
baseURL: baseURL.endsWith('/v1') ? baseURL : `${baseURL}/v1`,
|
||||||
// v1.13.7: @ai-sdk/openai-compatible defaults includeUsage=false, which
|
|
||||||
// omits `stream_options.include_usage` from the request body. Without
|
|
||||||
// it, llama.cpp / llama-swap never emits the trailing usage block, so
|
|
||||||
// `result.usage` resolves with inputTokens=outputTokens=undefined and
|
|
||||||
// tokens_used / ctx_used land as NULL in every messages row. Setting
|
|
||||||
// true here re-enables the per-stream usage payload across all models
|
|
||||||
// served via the llama-swap provider.
|
|
||||||
includeUsage: true,
|
includeUsage: true,
|
||||||
});
|
});
|
||||||
cache.set(baseURL, provider);
|
swapCache.set(baseURL, provider);
|
||||||
}
|
}
|
||||||
return provider;
|
return provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function upstreamModel(baseURL: string, modelId: string): LanguageModel {
|
function sidecarProvider(
|
||||||
return getProvider(baseURL).chatModel(modelId);
|
baseURL: string,
|
||||||
|
flags: string[],
|
||||||
|
): ReturnType<typeof createOpenAICompatible> {
|
||||||
|
return createOpenAICompatible({
|
||||||
|
name: 'llama-sidecar',
|
||||||
|
baseURL: baseURL.endsWith('/v1') ? baseURL : `${baseURL}/v1`,
|
||||||
|
includeUsage: true,
|
||||||
|
headers: {
|
||||||
|
'X-Agent-Flags': flags.join(' '),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InferenceRoute = 'swap' | 'sidecar';
|
||||||
|
|
||||||
|
export interface RoutingInfo {
|
||||||
|
route: InferenceRoute;
|
||||||
|
flags: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentLike {
|
||||||
|
llama_extra_args: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigLike {
|
||||||
|
LLAMA_SWAP_URL: string;
|
||||||
|
LLAMA_SIDECAR_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRoute(agent: AgentLike | null): RoutingInfo {
|
||||||
|
const flags = agent?.llama_extra_args;
|
||||||
|
if (flags && flags.length > 0) {
|
||||||
|
return { route: 'sidecar', flags };
|
||||||
|
}
|
||||||
|
return { route: 'swap', flags: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upstreamModel(
|
||||||
|
config: ConfigLike,
|
||||||
|
modelId: string,
|
||||||
|
agent?: AgentLike | null,
|
||||||
|
): LanguageModel {
|
||||||
|
const { route, flags } = resolveRoute(agent ?? null);
|
||||||
|
if (route === 'sidecar') {
|
||||||
|
const url = config.LLAMA_SIDECAR_URL;
|
||||||
|
if (!url) {
|
||||||
|
throw new Error(
|
||||||
|
`Agent has llama_extra_args but LLAMA_SIDECAR_URL is not set`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return sidecarProvider(url, flags!).chatModel(modelId);
|
||||||
|
}
|
||||||
|
return getSwapProvider(config.LLAMA_SWAP_URL).chatModel(modelId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ import * as modelContext from '../model-context.js';
|
|||||||
import { toolJsonSchemas, type ToolJsonSchema } from '../tools.js';
|
import { toolJsonSchemas, type ToolJsonSchema } from '../tools.js';
|
||||||
import { matchToolGlob } from '../agents.js';
|
import { matchToolGlob } from '../agents.js';
|
||||||
import type { OpenAiMessage } from './payload.js';
|
import type { OpenAiMessage } from './payload.js';
|
||||||
// v1.13.16: extractToolCallBlocks replaces the inline opener-search loop and
|
import { extractToolCallBlocks } from './tool-call-parser.js';
|
||||||
// recognizes both Qwen <tool_call> and Anthropic <invoke> markup in one pass.
|
|
||||||
import { extractToolCallBlocks } from './xml-parser.js';
|
|
||||||
import { DB_FLUSH_INTERVAL_MS, type StreamPhaseState } from './types.js';
|
import { DB_FLUSH_INTERVAL_MS, type StreamPhaseState } from './types.js';
|
||||||
import type {
|
import type {
|
||||||
InferenceContext,
|
InferenceContext,
|
||||||
@@ -159,7 +157,8 @@ export async function streamCompletion(
|
|||||||
opts: StreamOptions,
|
opts: StreamOptions,
|
||||||
onDelta: (content: string) => void,
|
onDelta: (content: string) => void,
|
||||||
onUsage: ((prompt: number | null, completion: number | null) => void) | undefined,
|
onUsage: ((prompt: number | null, completion: number | null) => void) | undefined,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal,
|
||||||
|
agent?: Agent | null,
|
||||||
): Promise<StreamResult> {
|
): Promise<StreamResult> {
|
||||||
const aiMessages = toModelMessages(messages);
|
const aiMessages = toModelMessages(messages);
|
||||||
const hasTools = opts.tools !== null && opts.tools.length > 0;
|
const hasTools = opts.tools !== null && opts.tools.length > 0;
|
||||||
@@ -197,7 +196,7 @@ export async function streamCompletion(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model: upstreamModel(ctx.config.LLAMA_SWAP_URL, model),
|
model: upstreamModel(ctx.config, model, agent ?? null),
|
||||||
messages: aiMessages,
|
messages: aiMessages,
|
||||||
...(aiTools
|
...(aiTools
|
||||||
? { tools: aiTools, toolChoice: 'auto' as const, experimental_repairToolCall: repairToolCall }
|
? { tools: aiTools, toolChoice: 'auto' as const, experimental_repairToolCall: repairToolCall }
|
||||||
@@ -460,7 +459,8 @@ export async function executeStreamPhase(
|
|||||||
}, USAGE_THROTTLE_MS - elapsed);
|
}, USAGE_THROTTLE_MS - elapsed);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
signal
|
signal,
|
||||||
|
agent,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (pendingFlushTimer) {
|
if (pendingFlushTimer) {
|
||||||
|
|||||||
426
apps/server/src/services/inference/tool-call-parser.ts
Normal file
426
apps/server/src/services/inference/tool-call-parser.ts
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||||
|
// Ported from studio/backend/core/inference/tool_call_parser.py.
|
||||||
|
// Original: https://github.com/unslothai/unsloth/blob/main/studio/backend/core/inference/tool_call_parser.py
|
||||||
|
|
||||||
|
// ── Constants ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const XML_TOOL_OPEN = '<tool_call>';
|
||||||
|
export const XML_TOOL_CLOSE = '</tool_call>';
|
||||||
|
export const INVOKE_TOOL_OPEN = '<invoke';
|
||||||
|
export const INVOKE_TOOL_CLOSE = '</invoke>';
|
||||||
|
|
||||||
|
export const TOOL_XML_SIGNALS = [XML_TOOL_OPEN, '<function=', INVOKE_TOOL_OPEN] as const;
|
||||||
|
|
||||||
|
export const TOOL_ERROR_PREFIXES = [
|
||||||
|
'Error',
|
||||||
|
'Search failed',
|
||||||
|
'Execution error',
|
||||||
|
'Blocked:',
|
||||||
|
'Exit code',
|
||||||
|
'Failed to fetch',
|
||||||
|
'Failed to resolve',
|
||||||
|
'No query provided',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const DUPLICATE_CALL_NUDGE =
|
||||||
|
'You already made this exact call. Do not repeat the same tool ' +
|
||||||
|
'call. Try a different approach: fetch a URL from previous ' +
|
||||||
|
'results, use Python to process data you already have, or ' +
|
||||||
|
'provide your final answer now.';
|
||||||
|
|
||||||
|
export const TOOL_ERROR_NUDGE =
|
||||||
|
'\n\nThe tool call encountered an issue. Please try a different ' +
|
||||||
|
'approach or rephrase your request.';
|
||||||
|
|
||||||
|
export const BUDGET_EXHAUSTED_NUDGE =
|
||||||
|
'You have used all available tool calls. Based on everything you ' +
|
||||||
|
'have found so far, provide your final answer now. Do not call ' +
|
||||||
|
'any more tools.';
|
||||||
|
|
||||||
|
// ── Strip patterns ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TOOL_CLOSED_PATS = [
|
||||||
|
/<tool_call>.*?<\/tool_call>/gs,
|
||||||
|
/<function=\w+>.*?<\/function>/gs,
|
||||||
|
/<invoke\s[^>]*>.*?<\/invoke>/gs,
|
||||||
|
];
|
||||||
|
|
||||||
|
const TOOL_ALL_PATS = [
|
||||||
|
...TOOL_CLOSED_PATS,
|
||||||
|
/<tool_call>.*$/gs,
|
||||||
|
/<function=\w+>.*$/gs,
|
||||||
|
/<invoke\s[^>]*>.*$/gs,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Strip / signal ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function stripToolMarkup(text: string, opts?: { final?: boolean }): string {
|
||||||
|
const pats = opts?.final ? TOOL_ALL_PATS : TOOL_CLOSED_PATS;
|
||||||
|
for (const pat of pats) {
|
||||||
|
text = text.replace(pat, '');
|
||||||
|
}
|
||||||
|
return opts?.final ? text.trim() : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasToolSignal(text: string): boolean {
|
||||||
|
return TOOL_XML_SIGNALS.some((s) => text.includes(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── parseToolCallsFromText (Unsloth port + Anthropic extension) ──────────
|
||||||
|
|
||||||
|
export interface OpenAiToolCall {
|
||||||
|
id: string;
|
||||||
|
type: 'function';
|
||||||
|
function: { name: string; arguments: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const TC_JSON_START_RE = /<tool_call>\s*\{/g;
|
||||||
|
const TC_FUNC_START_RE = /<function=(\w+)>\s*/g;
|
||||||
|
const TC_END_TAG_RE = /<\/tool_call>/;
|
||||||
|
const TC_FUNC_CLOSE_RE = /\s*<\/function>\s*$/;
|
||||||
|
const TC_PARAM_START_RE = /<parameter=(\w+)>\s*/g;
|
||||||
|
const TC_PARAM_CLOSE_RE = /\s*<\/parameter>\s*$/;
|
||||||
|
|
||||||
|
const TC_INVOKE_START_RE = /<invoke\s+name\s*=\s*(?:"([^"]*)"|'([^']*)')\s*>/g;
|
||||||
|
const TC_INVOKE_CLOSE_RE = /\s*<\/invoke>\s*$/;
|
||||||
|
const TC_INVOKE_PARAM_RE = /<parameter\s+name\s*=\s*(?:"([^"]*)"|'([^']*)')\s*>/g;
|
||||||
|
const TC_INVOKE_PARAM_CLOSE_RE = /\s*<\/parameter>\s*$/;
|
||||||
|
|
||||||
|
function scanBalancedBraces(content: string, start: number): number {
|
||||||
|
let depth = 0;
|
||||||
|
let i = start;
|
||||||
|
let inString = false;
|
||||||
|
while (i < content.length) {
|
||||||
|
const ch = content[i]!;
|
||||||
|
if (inString) {
|
||||||
|
if (ch === '\\' && i + 1 < content.length) {
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"') inString = false;
|
||||||
|
} else if (ch === '"') {
|
||||||
|
inString = true;
|
||||||
|
} else if (ch === '{') {
|
||||||
|
depth++;
|
||||||
|
} else if (ch === '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) return i;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseToolCallsFromText(
|
||||||
|
content: string,
|
||||||
|
opts?: { idOffset?: number },
|
||||||
|
): OpenAiToolCall[] {
|
||||||
|
const toolCalls: OpenAiToolCall[] = [];
|
||||||
|
const idOffset = opts?.idOffset ?? 0;
|
||||||
|
|
||||||
|
// Pattern 1: <tool_call>{json}</tool_call> -- balanced-brace JSON scanner.
|
||||||
|
// Skips braces inside JSON strings so nested objects parse correctly.
|
||||||
|
TC_JSON_START_RE.lastIndex = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = TC_JSON_START_RE.exec(content)) !== null) {
|
||||||
|
const braceStart = m.index + m[0].length - 1;
|
||||||
|
const braceEnd = scanBalancedBraces(content, braceStart);
|
||||||
|
if (braceEnd === -1) continue;
|
||||||
|
const jsonStr = content.slice(braceStart, braceEnd + 1);
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(jsonStr) as Record<string, unknown>;
|
||||||
|
const name = typeof obj.name === 'string' ? obj.name : '';
|
||||||
|
let args: string;
|
||||||
|
const rawArgs = obj.arguments ?? {};
|
||||||
|
if (typeof rawArgs === 'string') {
|
||||||
|
args = rawArgs;
|
||||||
|
} else {
|
||||||
|
args = JSON.stringify(rawArgs);
|
||||||
|
}
|
||||||
|
toolCalls.push({
|
||||||
|
id: `call_${idOffset + toolCalls.length}`,
|
||||||
|
type: 'function',
|
||||||
|
function: { name, arguments: args },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// malformed JSON -- skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 2: <function=name><parameter=key>value -- closing tags optional.
|
||||||
|
// Body boundary uses </tool_call> or next <function= (not </function>,
|
||||||
|
// because code parameter values can contain that literal).
|
||||||
|
if (toolCalls.length === 0) {
|
||||||
|
TC_FUNC_START_RE.lastIndex = 0;
|
||||||
|
const funcStarts: Array<{ match: RegExpExecArray; name: string }> = [];
|
||||||
|
while ((m = TC_FUNC_START_RE.exec(content)) !== null) {
|
||||||
|
funcStarts.push({ match: m, name: m[1]! });
|
||||||
|
}
|
||||||
|
for (let idx = 0; idx < funcStarts.length; idx++) {
|
||||||
|
const { match: fm, name: funcName } = funcStarts[idx]!;
|
||||||
|
const bodyStart = fm.index + fm[0].length;
|
||||||
|
const nextFunc = idx + 1 < funcStarts.length
|
||||||
|
? funcStarts[idx + 1]!.match.index
|
||||||
|
: content.length;
|
||||||
|
const endTag = TC_END_TAG_RE.exec(content.slice(bodyStart));
|
||||||
|
let bodyEnd = endTag ? bodyStart + endTag.index : content.length;
|
||||||
|
bodyEnd = Math.min(bodyEnd, nextFunc);
|
||||||
|
let body = content.slice(bodyStart, bodyEnd);
|
||||||
|
body = body.replace(TC_FUNC_CLOSE_RE, '');
|
||||||
|
|
||||||
|
const args: Record<string, string> = {};
|
||||||
|
TC_PARAM_START_RE.lastIndex = 0;
|
||||||
|
const paramStarts: Array<{ match: RegExpExecArray; name: string }> = [];
|
||||||
|
let pm: RegExpExecArray | null;
|
||||||
|
while ((pm = TC_PARAM_START_RE.exec(body)) !== null) {
|
||||||
|
paramStarts.push({ match: pm, name: pm[1]! });
|
||||||
|
}
|
||||||
|
if (paramStarts.length === 1) {
|
||||||
|
// Single param: take everything to body end so embedded
|
||||||
|
// </parameter> in code strings is preserved.
|
||||||
|
const p = paramStarts[0]!;
|
||||||
|
let val = body.slice(p.match.index + p.match[0].length);
|
||||||
|
val = val.replace(TC_PARAM_CLOSE_RE, '');
|
||||||
|
args[p.name] = val.trim();
|
||||||
|
} else {
|
||||||
|
for (let pidx = 0; pidx < paramStarts.length; pidx++) {
|
||||||
|
const p = paramStarts[pidx]!;
|
||||||
|
const valStart = p.match.index + p.match[0].length;
|
||||||
|
const nextParam = pidx + 1 < paramStarts.length
|
||||||
|
? paramStarts[pidx + 1]!.match.index
|
||||||
|
: body.length;
|
||||||
|
let val = body.slice(valStart, nextParam);
|
||||||
|
val = val.replace(TC_PARAM_CLOSE_RE, '');
|
||||||
|
args[p.name] = val.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toolCalls.push({
|
||||||
|
id: `call_${idOffset + toolCalls.length}`,
|
||||||
|
type: 'function',
|
||||||
|
function: { name: funcName, arguments: JSON.stringify(args) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 3: <invoke name="..."><parameter name="...">value -- Anthropic
|
||||||
|
// shape that qwen3.6 drifts to from Claude Code documentation residue.
|
||||||
|
// Closing tags optional; same single-param fast path as pattern 2.
|
||||||
|
if (toolCalls.length === 0) {
|
||||||
|
TC_INVOKE_START_RE.lastIndex = 0;
|
||||||
|
const invokeStarts: Array<{ match: RegExpExecArray; name: string }> = [];
|
||||||
|
while ((m = TC_INVOKE_START_RE.exec(content)) !== null) {
|
||||||
|
const name = (m[1] ?? m[2] ?? '').trim();
|
||||||
|
if (name) invokeStarts.push({ match: m, name });
|
||||||
|
}
|
||||||
|
for (let idx = 0; idx < invokeStarts.length; idx++) {
|
||||||
|
const { match: im, name: invokeName } = invokeStarts[idx]!;
|
||||||
|
const bodyStart = im.index + im[0].length;
|
||||||
|
const nextInvoke = idx + 1 < invokeStarts.length
|
||||||
|
? invokeStarts[idx + 1]!.match.index
|
||||||
|
: content.length;
|
||||||
|
const closeTag = content.slice(bodyStart).match(/<\/invoke>/);
|
||||||
|
let bodyEnd = closeTag ? bodyStart + (closeTag.index ?? 0) : content.length;
|
||||||
|
bodyEnd = Math.min(bodyEnd, nextInvoke);
|
||||||
|
let body = content.slice(bodyStart, bodyEnd);
|
||||||
|
body = body.replace(TC_INVOKE_CLOSE_RE, '');
|
||||||
|
|
||||||
|
const args: Record<string, string> = {};
|
||||||
|
TC_INVOKE_PARAM_RE.lastIndex = 0;
|
||||||
|
const paramStarts: Array<{ match: RegExpExecArray; name: string }> = [];
|
||||||
|
let pm: RegExpExecArray | null;
|
||||||
|
while ((pm = TC_INVOKE_PARAM_RE.exec(body)) !== null) {
|
||||||
|
const pname = (pm[1] ?? pm[2] ?? '').trim();
|
||||||
|
if (pname) paramStarts.push({ match: pm, name: pname });
|
||||||
|
}
|
||||||
|
if (paramStarts.length === 1) {
|
||||||
|
const p = paramStarts[0]!;
|
||||||
|
let val = body.slice(p.match.index + p.match[0].length);
|
||||||
|
val = val.replace(TC_INVOKE_PARAM_CLOSE_RE, '');
|
||||||
|
args[p.name] = val.trim();
|
||||||
|
} else {
|
||||||
|
for (let pidx = 0; pidx < paramStarts.length; pidx++) {
|
||||||
|
const p = paramStarts[pidx]!;
|
||||||
|
const valStart = p.match.index + p.match[0].length;
|
||||||
|
const nextParam = pidx + 1 < paramStarts.length
|
||||||
|
? paramStarts[pidx + 1]!.match.index
|
||||||
|
: body.length;
|
||||||
|
let val = body.slice(valStart, nextParam);
|
||||||
|
val = val.replace(TC_INVOKE_PARAM_CLOSE_RE, '');
|
||||||
|
args[p.name] = val.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toolCalls.push({
|
||||||
|
id: `call_${idOffset + toolCalls.length}`,
|
||||||
|
type: 'function',
|
||||||
|
function: { name: invokeName, arguments: JSON.stringify(args) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return toolCalls;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── BooCode streaming helpers ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ParsedCall {
|
||||||
|
name: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLACEHOLDER_LITERALS = new Set(['...', 'placeholder', '<path>', '<file>']);
|
||||||
|
const ANGLE_BRACKET_SENTINEL_RE = /^<[^>]+>$/;
|
||||||
|
|
||||||
|
export function isPlaceholderArgValue(value: unknown): boolean {
|
||||||
|
if (typeof value !== 'string') return false;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed === '') return true;
|
||||||
|
if (PLACEHOLDER_LITERALS.has(trimmed)) return true;
|
||||||
|
if (ANGLE_BRACKET_SENTINEL_RE.test(trimmed)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPlaceholderArgs(args: Record<string, unknown>): boolean {
|
||||||
|
for (const value of Object.values(args)) {
|
||||||
|
if (isPlaceholderArgValue(value)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logRejectedPlaceholder(parsed: ParsedCall): void {
|
||||||
|
console.debug(
|
||||||
|
{ toolName: parsed.name, args: parsed.args },
|
||||||
|
'rejected placeholder tool call at parse time',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const QWEN_FUNCTION_RE = /<function\s*=\s*([^>\s]+)\s*>/;
|
||||||
|
const QWEN_PARAM_RE = /<parameter\s*=\s*([^>\s]+)\s*>([\s\S]*?)<\/parameter>/g;
|
||||||
|
|
||||||
|
export function parseXmlToolCall(block: string): ParsedCall | null {
|
||||||
|
const nameMatch = block.match(QWEN_FUNCTION_RE);
|
||||||
|
if (!nameMatch || !nameMatch[1]) return null;
|
||||||
|
const name = nameMatch[1].trim();
|
||||||
|
if (!name) return null;
|
||||||
|
const args: Record<string, unknown> = {};
|
||||||
|
for (const m of block.matchAll(QWEN_PARAM_RE)) {
|
||||||
|
const key = (m[1] ?? '').trim();
|
||||||
|
if (!key) continue;
|
||||||
|
const raw = (m[2] ?? '').trim();
|
||||||
|
try {
|
||||||
|
args[key] = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
args[key] = raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { name, args };
|
||||||
|
}
|
||||||
|
|
||||||
|
const INVOKE_NAME_RE =
|
||||||
|
/<invoke\s+name\s*=\s*("([^"]*)"|'([^']*)')\s*>/;
|
||||||
|
const INVOKE_PARAM_RE =
|
||||||
|
/<parameter\s+name\s*=\s*("([^"]*)"|'([^']*)')\s*>([\s\S]*?)<\/parameter>/g;
|
||||||
|
|
||||||
|
export function parseInvokeToolCall(block: string): ParsedCall | null {
|
||||||
|
const nameMatch = block.match(INVOKE_NAME_RE);
|
||||||
|
if (!nameMatch) return null;
|
||||||
|
const name = (nameMatch[2] ?? nameMatch[3] ?? '').trim();
|
||||||
|
if (!name) return null;
|
||||||
|
const args: Record<string, unknown> = {};
|
||||||
|
for (const m of block.matchAll(INVOKE_PARAM_RE)) {
|
||||||
|
const key = ((m[2] ?? m[3] ?? '') as string).trim();
|
||||||
|
if (!key) continue;
|
||||||
|
const raw = (m[4] ?? '').trim();
|
||||||
|
try {
|
||||||
|
args[key] = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
args[key] = raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { name, args };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_OPENERS = [XML_TOOL_OPEN, INVOKE_TOOL_OPEN] as const;
|
||||||
|
|
||||||
|
export function partialXmlOpenerStart(s: string): number {
|
||||||
|
let earliest = -1;
|
||||||
|
for (const op of ALL_OPENERS) {
|
||||||
|
const idx = s.indexOf(op);
|
||||||
|
if (idx === -1) continue;
|
||||||
|
if (earliest === -1 || idx < earliest) earliest = idx;
|
||||||
|
}
|
||||||
|
if (earliest !== -1) return earliest;
|
||||||
|
const lastLt = s.lastIndexOf('<');
|
||||||
|
if (lastLt === -1) return -1;
|
||||||
|
const suffix = s.slice(lastLt);
|
||||||
|
for (const op of ALL_OPENERS) {
|
||||||
|
if (op.startsWith(suffix) && suffix.length < op.length) return lastLt;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolCallExtraction {
|
||||||
|
flushed: string;
|
||||||
|
calls: ParsedCall[];
|
||||||
|
remaining: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenerSpec {
|
||||||
|
open: string;
|
||||||
|
close: string;
|
||||||
|
parse: (block: string) => ParsedCall | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPENER_SPECS: ReadonlyArray<OpenerSpec> = [
|
||||||
|
{ open: XML_TOOL_OPEN, close: XML_TOOL_CLOSE, parse: parseXmlToolCall },
|
||||||
|
{ open: INVOKE_TOOL_OPEN, close: INVOKE_TOOL_CLOSE, parse: parseInvokeToolCall },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function extractToolCallBlocks(buffer: string): ToolCallExtraction {
|
||||||
|
let flushed = '';
|
||||||
|
const calls: ParsedCall[] = [];
|
||||||
|
let pos = 0;
|
||||||
|
|
||||||
|
while (pos < buffer.length) {
|
||||||
|
let next: { spec: OpenerSpec; openIdx: number; closeIdx: number } | null = null;
|
||||||
|
for (const spec of OPENER_SPECS) {
|
||||||
|
const openIdx = buffer.indexOf(spec.open, pos);
|
||||||
|
if (openIdx === -1) continue;
|
||||||
|
const closeIdx = buffer.indexOf(spec.close, openIdx);
|
||||||
|
if (closeIdx === -1) continue;
|
||||||
|
if (next === null || openIdx < next.openIdx) {
|
||||||
|
next = { spec, openIdx, closeIdx };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (next === null) break;
|
||||||
|
|
||||||
|
if (next.openIdx > pos) {
|
||||||
|
flushed += buffer.slice(pos, next.openIdx);
|
||||||
|
}
|
||||||
|
const blockEnd = next.closeIdx + next.spec.close.length;
|
||||||
|
const block = buffer.slice(next.openIdx, blockEnd);
|
||||||
|
const parsed = next.spec.parse(block);
|
||||||
|
if (parsed) {
|
||||||
|
if (hasPlaceholderArgs(parsed.args)) {
|
||||||
|
logRejectedPlaceholder(parsed);
|
||||||
|
flushed += block;
|
||||||
|
} else {
|
||||||
|
calls.push(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pos = blockEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tail = buffer.slice(pos);
|
||||||
|
const partialIdx = partialXmlOpenerStart(tail);
|
||||||
|
if (partialIdx === -1) {
|
||||||
|
flushed += tail;
|
||||||
|
return { flushed, calls, remaining: '' };
|
||||||
|
}
|
||||||
|
if (partialIdx > 0) {
|
||||||
|
flushed += tail.slice(0, partialIdx);
|
||||||
|
}
|
||||||
|
return { flushed, calls, remaining: tail.slice(partialIdx) };
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { formatUnknownToolError } from './tool-suggestions.js';
|
|||||||
// Resolves the grant root before pausing the loop so the user is never
|
// Resolves the grant root before pausing the loop so the user is never
|
||||||
// prompted about paths we couldn't grant anyway (e.g. /etc/passwd).
|
// prompted about paths we couldn't grant anyway (e.g. /etc/passwd).
|
||||||
import { resolveGrantRoot } from '../grant_resolver.js';
|
import { resolveGrantRoot } from '../grant_resolver.js';
|
||||||
|
import { stripToolMarkup } from './tool-call-parser.js';
|
||||||
import type {
|
import type {
|
||||||
InferenceContext,
|
InferenceContext,
|
||||||
StreamResult,
|
StreamResult,
|
||||||
@@ -100,7 +101,8 @@ export async function executeToolPhase(
|
|||||||
projectRoot: string
|
projectRoot: string
|
||||||
): Promise<ToolPhaseResult> {
|
): Promise<ToolPhaseResult> {
|
||||||
const { sessionId, chatId, assistantMessageId } = args;
|
const { sessionId, chatId, assistantMessageId } = args;
|
||||||
const { content, toolCalls, promptTokens, completionTokens } = result;
|
const content = stripToolMarkup(result.content, { final: true });
|
||||||
|
const { toolCalls, promptTokens, completionTokens } = result;
|
||||||
|
|
||||||
// v1.11.3: ctx_max comes from llama-swap /upstream/<model>/props, not the
|
// v1.11.3: ctx_max comes from llama-swap /upstream/<model>/props, not the
|
||||||
// streaming completion (which doesn't emit n_ctx). getModelContext caches
|
// streaming completion (which doesn't emit n_ctx). getModelContext caches
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
import { ALL_TOOLS } from '../tools.js';
|
import { ALL_TOOLS } from '../tools.js';
|
||||||
import { resolveProjectRoot } from '../path_guard.js';
|
import { resolveProjectRoot } from '../path_guard.js';
|
||||||
import { maybeAutoNameChat } from '../auto_name.js';
|
import { maybeAutoNameChat } from '../auto_name.js';
|
||||||
|
import { rewriteSearchQuery } from '../task-search-rewrite.js';
|
||||||
import { getAgentById } from '../agents.js';
|
import { getAgentById } from '../agents.js';
|
||||||
import * as compaction from '../compaction.js';
|
import * as compaction from '../compaction.js';
|
||||||
import type { Broker } from '../broker.js';
|
import type { Broker } from '../broker.js';
|
||||||
@@ -254,6 +255,16 @@ export async function runAssistantTurn(
|
|||||||
const webToolsEnabled =
|
const webToolsEnabled =
|
||||||
iterSession.web_search_enabled ?? iterProject.default_web_search_enabled ?? false;
|
iterSession.web_search_enabled ?? iterProject.default_web_search_enabled ?? false;
|
||||||
|
|
||||||
|
if (stepNumber === 0 && webToolsEnabled && messages.length >= 2) {
|
||||||
|
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user');
|
||||||
|
if (lastUserMsg?.content) {
|
||||||
|
const hint = await rewriteSearchQuery(lastUserMsg.content);
|
||||||
|
if (hint && messages[0]?.role === 'system' && messages[0].content) {
|
||||||
|
messages[0].content += `\n\nThe user's search intent can be summarized as: "${hint}"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, signal };
|
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, signal };
|
||||||
const state: StreamPhaseState = { accumulated: '', startedAt: null };
|
const state: StreamPhaseState = { accumulated: '', startedAt: null };
|
||||||
let result: StreamResult;
|
let result: StreamResult;
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
// v1.10.5: XML-tag tool-call fallback. Some models emit
|
|
||||||
// <tool_call><function=foo><parameter=key>value</parameter></function></tool_call>
|
|
||||||
// in plain content instead of using the OpenAI tool_calls JSON channel.
|
|
||||||
// The streaming loop in stream-phase.ts extracts these blocks via these helpers.
|
|
||||||
//
|
|
||||||
// v1.13.16: also recognize Anthropic <invoke name="..."><parameter name="...">
|
|
||||||
// markup. qwen3.6-35b-a3b-mxfp4 drifts to this format when prompted as an
|
|
||||||
// "Architect"-style agent because Claude Code documentation in its
|
|
||||||
// pre-training data uses this shape. Both formats route through the same
|
|
||||||
// synthetic ToolCall path with shared xml_call_${idx} IDs; downstream
|
|
||||||
// dispatch handles unknown tool names with a richer error (see
|
|
||||||
// tool-suggestions.ts + tool-phase.ts).
|
|
||||||
|
|
||||||
export const XML_TOOL_OPEN = '<tool_call>';
|
|
||||||
export const XML_TOOL_CLOSE = '</tool_call>';
|
|
||||||
|
|
||||||
// v1.13.16: Anthropic <invoke> opener is matched by prefix (not the full
|
|
||||||
// `<invoke ...>` tag) because attributes follow. Closer is the literal tag.
|
|
||||||
export const INVOKE_TOOL_OPEN = '<invoke';
|
|
||||||
export const INVOKE_TOOL_CLOSE = '</invoke>';
|
|
||||||
|
|
||||||
export interface ParsedCall {
|
|
||||||
name: string;
|
|
||||||
args: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PLACEHOLDER_LITERALS = new Set(['...', 'placeholder', '<path>', '<file>']);
|
|
||||||
const ANGLE_BRACKET_SENTINEL_RE = /^<[^>]+>$/;
|
|
||||||
|
|
||||||
/** True when a string arg looks like a model placeholder, not a real path/value. */
|
|
||||||
export function isPlaceholderArgValue(value: unknown): boolean {
|
|
||||||
if (typeof value !== 'string') return false;
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (trimmed === '') return true;
|
|
||||||
if (PLACEHOLDER_LITERALS.has(trimmed)) return true;
|
|
||||||
if (ANGLE_BRACKET_SENTINEL_RE.test(trimmed)) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasPlaceholderArgs(args: Record<string, unknown>): boolean {
|
|
||||||
for (const value of Object.values(args)) {
|
|
||||||
if (isPlaceholderArgValue(value)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function logRejectedPlaceholder(parsed: ParsedCall): void {
|
|
||||||
// Pure helper — no Fastify logger here (stream-phase.ts stays unchanged).
|
|
||||||
console.debug(
|
|
||||||
{ toolName: parsed.name, args: parsed.args },
|
|
||||||
'rejected placeholder tool call at parse time',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// v1.10.5: Qwen-flavor parser. Tightened in v1.13.16 to tolerate whitespace
|
|
||||||
// around `=` (e.g. `<function = view_file>`). Name capture is non-whitespace,
|
|
||||||
// non-`>` so a stray space doesn't get absorbed into the function name.
|
|
||||||
const QWEN_FUNCTION_RE = /<function\s*=\s*([^>\s]+)\s*>/;
|
|
||||||
const QWEN_PARAM_RE = /<parameter\s*=\s*([^>\s]+)\s*>([\s\S]*?)<\/parameter>/g;
|
|
||||||
|
|
||||||
export function parseXmlToolCall(block: string): ParsedCall | null {
|
|
||||||
const nameMatch = block.match(QWEN_FUNCTION_RE);
|
|
||||||
if (!nameMatch || !nameMatch[1]) return null;
|
|
||||||
const name = nameMatch[1].trim();
|
|
||||||
if (!name) return null;
|
|
||||||
const args: Record<string, unknown> = {};
|
|
||||||
for (const m of block.matchAll(QWEN_PARAM_RE)) {
|
|
||||||
const key = (m[1] ?? '').trim();
|
|
||||||
if (!key) continue;
|
|
||||||
const raw = (m[2] ?? '').trim();
|
|
||||||
try {
|
|
||||||
args[key] = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
args[key] = raw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { name, args };
|
|
||||||
}
|
|
||||||
|
|
||||||
// v1.13.16: Anthropic-flavor parser. Same JSON-parse-with-string-fallback
|
|
||||||
// shape as parseXmlToolCall so the dispatch layer doesn't need to care which
|
|
||||||
// flavor produced the call.
|
|
||||||
const INVOKE_NAME_RE =
|
|
||||||
/<invoke\s+name\s*=\s*("([^"]*)"|'([^']*)')\s*>/;
|
|
||||||
const INVOKE_PARAM_RE =
|
|
||||||
/<parameter\s+name\s*=\s*("([^"]*)"|'([^']*)')\s*>([\s\S]*?)<\/parameter>/g;
|
|
||||||
|
|
||||||
export function parseInvokeToolCall(block: string): ParsedCall | null {
|
|
||||||
const nameMatch = block.match(INVOKE_NAME_RE);
|
|
||||||
if (!nameMatch) return null;
|
|
||||||
const name = (nameMatch[2] ?? nameMatch[3] ?? '').trim();
|
|
||||||
if (!name) return null;
|
|
||||||
const args: Record<string, unknown> = {};
|
|
||||||
for (const m of block.matchAll(INVOKE_PARAM_RE)) {
|
|
||||||
const key = ((m[2] ?? m[3] ?? '') as string).trim();
|
|
||||||
if (!key) continue;
|
|
||||||
const raw = (m[4] ?? '').trim();
|
|
||||||
try {
|
|
||||||
args[key] = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
args[key] = raw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { name, args };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Locate the first character that begins (or completely contains) an
|
|
||||||
// unfinished opener (either flavor) in `s`. Returns -1 when `s` can be
|
|
||||||
// flushed to the client in full without risking a partial tag leak.
|
|
||||||
// Case 1: a full opener (`<tool_call>` or `<invoke`) with no matching
|
|
||||||
// closer — caller must keep everything from that index forward
|
|
||||||
// until the next chunk arrives with the closer.
|
|
||||||
// Case 2: `s` ends with a strict prefix of either opener (e.g. `<tool_c`
|
|
||||||
// or `<invo`). Caller must keep just that suffix in the buffer.
|
|
||||||
// Note: case 1 assumes the calling loop already extracted every complete
|
|
||||||
// block before reaching this check.
|
|
||||||
const ALL_OPENERS = [XML_TOOL_OPEN, INVOKE_TOOL_OPEN] as const;
|
|
||||||
|
|
||||||
export function partialXmlOpenerStart(s: string): number {
|
|
||||||
let earliest = -1;
|
|
||||||
for (const op of ALL_OPENERS) {
|
|
||||||
const idx = s.indexOf(op);
|
|
||||||
if (idx === -1) continue;
|
|
||||||
if (earliest === -1 || idx < earliest) earliest = idx;
|
|
||||||
}
|
|
||||||
if (earliest !== -1) return earliest;
|
|
||||||
const lastLt = s.lastIndexOf('<');
|
|
||||||
if (lastLt === -1) return -1;
|
|
||||||
const suffix = s.slice(lastLt);
|
|
||||||
for (const op of ALL_OPENERS) {
|
|
||||||
if (op.startsWith(suffix) && suffix.length < op.length) return lastLt;
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// v1.13.16: unified extraction. Replaces the inline loop that used to live
|
|
||||||
// in stream-phase.ts. Pure function — returns the visible text to flush,
|
|
||||||
// the parsed tool-call payloads in source order, and the buffer remainder
|
|
||||||
// to retain for the next streaming chunk. Parse failures are silently
|
|
||||||
// dropped (matches the pre-v1.13.16 behavior — leaking partial XML to the
|
|
||||||
// chat looks worse than swallowing a bad block).
|
|
||||||
export interface ToolCallExtraction {
|
|
||||||
flushed: string;
|
|
||||||
calls: ParsedCall[];
|
|
||||||
remaining: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OpenerSpec {
|
|
||||||
open: string;
|
|
||||||
close: string;
|
|
||||||
parse: (block: string) => ParsedCall | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const OPENER_SPECS: ReadonlyArray<OpenerSpec> = [
|
|
||||||
{ open: XML_TOOL_OPEN, close: XML_TOOL_CLOSE, parse: parseXmlToolCall },
|
|
||||||
{ open: INVOKE_TOOL_OPEN, close: INVOKE_TOOL_CLOSE, parse: parseInvokeToolCall },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function extractToolCallBlocks(buffer: string): ToolCallExtraction {
|
|
||||||
let flushed = '';
|
|
||||||
const calls: ParsedCall[] = [];
|
|
||||||
let pos = 0;
|
|
||||||
|
|
||||||
while (pos < buffer.length) {
|
|
||||||
let next: { spec: OpenerSpec; openIdx: number; closeIdx: number } | null = null;
|
|
||||||
for (const spec of OPENER_SPECS) {
|
|
||||||
const openIdx = buffer.indexOf(spec.open, pos);
|
|
||||||
if (openIdx === -1) continue;
|
|
||||||
const closeIdx = buffer.indexOf(spec.close, openIdx);
|
|
||||||
if (closeIdx === -1) continue;
|
|
||||||
if (next === null || openIdx < next.openIdx) {
|
|
||||||
next = { spec, openIdx, closeIdx };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (next === null) break;
|
|
||||||
|
|
||||||
if (next.openIdx > pos) {
|
|
||||||
flushed += buffer.slice(pos, next.openIdx);
|
|
||||||
}
|
|
||||||
const blockEnd = next.closeIdx + next.spec.close.length;
|
|
||||||
const block = buffer.slice(next.openIdx, blockEnd);
|
|
||||||
const parsed = next.spec.parse(block);
|
|
||||||
if (parsed) {
|
|
||||||
if (hasPlaceholderArgs(parsed.args)) {
|
|
||||||
logRejectedPlaceholder(parsed);
|
|
||||||
flushed += block;
|
|
||||||
} else {
|
|
||||||
calls.push(parsed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pos = blockEnd;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tail = buffer.slice(pos);
|
|
||||||
const partialIdx = partialXmlOpenerStart(tail);
|
|
||||||
if (partialIdx === -1) {
|
|
||||||
flushed += tail;
|
|
||||||
return { flushed, calls, remaining: '' };
|
|
||||||
}
|
|
||||||
if (partialIdx > 0) {
|
|
||||||
flushed += tail.slice(0, partialIdx);
|
|
||||||
}
|
|
||||||
return { flushed, calls, remaining: tail.slice(partialIdx) };
|
|
||||||
}
|
|
||||||
@@ -21,6 +21,7 @@ import { createHash } from 'node:crypto';
|
|||||||
import { readFile, stat } from 'node:fs/promises';
|
import { readFile, stat } from 'node:fs/promises';
|
||||||
import type { Agent, Project, Session } from '../types/api.js';
|
import type { Agent, Project, Session } from '../types/api.js';
|
||||||
import { getAgentsMtimes } from './agents.js';
|
import { getAgentsMtimes } from './agents.js';
|
||||||
|
import { resolveRoute } from './inference/provider.js';
|
||||||
|
|
||||||
const BASE_SYSTEM_PROMPT = (projectPath: string) =>
|
const BASE_SYSTEM_PROMPT = (projectPath: string) =>
|
||||||
`You are BooCode Chat, a code investigation assistant. The user is working on a project located at ${projectPath}. Use the file-read tools (view_file, list_dir, grep, find_files) to investigate code when needed. Be concise. Cite file paths and line numbers when discussing code. Do not hallucinate file contents — read the file first. Tool results may be truncated; if so, narrow your query rather than guessing.`;
|
`You are BooCode Chat, a code investigation assistant. The user is working on a project located at ${projectPath}. Use the file-read tools (view_file, list_dir, grep, find_files) to investigate code when needed. Be concise. Cite file paths and line numbers when discussing code. Do not hallucinate file contents — read the file first. Tool results may be truncated; if so, narrow your query rather than guessing.`;
|
||||||
@@ -98,6 +99,7 @@ export interface PrefixFingerprint {
|
|||||||
has_agent_system_prompt: boolean;
|
has_agent_system_prompt: boolean;
|
||||||
has_session_override: boolean;
|
has_session_override: boolean;
|
||||||
has_project_override: boolean;
|
has_project_override: boolean;
|
||||||
|
route: 'swap' | 'sidecar';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PrefixDrift {
|
export interface PrefixDrift {
|
||||||
@@ -125,6 +127,7 @@ interface ObservedInputs {
|
|||||||
has_agent_system_prompt: boolean;
|
has_agent_system_prompt: boolean;
|
||||||
has_session_override: boolean;
|
has_session_override: boolean;
|
||||||
has_project_override: boolean;
|
has_project_override: boolean;
|
||||||
|
route: 'swap' | 'sidecar';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ObserverEntry {
|
interface ObserverEntry {
|
||||||
@@ -183,6 +186,7 @@ export async function buildSystemPromptWithFingerprint(
|
|||||||
has_agent_system_prompt: !!(agent && agent.system_prompt.trim().length > 0),
|
has_agent_system_prompt: !!(agent && agent.system_prompt.trim().length > 0),
|
||||||
has_session_override: sessionPrompt.length > 0,
|
has_session_override: sessionPrompt.length > 0,
|
||||||
has_project_override: projectPrompt.length > 0,
|
has_project_override: projectPrompt.length > 0,
|
||||||
|
route: resolveRoute(agent).route,
|
||||||
};
|
};
|
||||||
|
|
||||||
const fingerprint: PrefixFingerprint = {
|
const fingerprint: PrefixFingerprint = {
|
||||||
@@ -199,6 +203,7 @@ export async function buildSystemPromptWithFingerprint(
|
|||||||
has_agent_system_prompt: inputs.has_agent_system_prompt,
|
has_agent_system_prompt: inputs.has_agent_system_prompt,
|
||||||
has_session_override: inputs.has_session_override,
|
has_session_override: inputs.has_session_override,
|
||||||
has_project_override: inputs.has_project_override,
|
has_project_override: inputs.has_project_override,
|
||||||
|
route: inputs.route,
|
||||||
};
|
};
|
||||||
|
|
||||||
let drift: PrefixDrift | null = null;
|
let drift: PrefixDrift | null = null;
|
||||||
|
|||||||
68
apps/server/src/services/task-model.ts
Normal file
68
apps/server/src/services/task-model.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { loadConfig, type Config } from '../config.js';
|
||||||
|
|
||||||
|
const TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
export async function taskModelCompletion(opts: {
|
||||||
|
system: string;
|
||||||
|
user: string;
|
||||||
|
maxTokens?: number;
|
||||||
|
temperature?: number;
|
||||||
|
fallbackModel?: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const config = loadConfig();
|
||||||
|
const maxTokens = opts.maxTokens ?? 30;
|
||||||
|
const temperature = opts.temperature ?? 0.3;
|
||||||
|
|
||||||
|
const { url, model } = resolveEndpoint(config, opts.fallbackModel);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${url}/v1/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: opts.system },
|
||||||
|
{ role: 'user', content: opts.user },
|
||||||
|
],
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
temperature,
|
||||||
|
stream: false,
|
||||||
|
chat_template_kwargs: { enable_thinking: false },
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
console.warn(`task-model: ${res.status} ${text.slice(0, 200)}`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
choices?: Array<{
|
||||||
|
message?: { content?: string; reasoning_content?: string };
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
const choice = data.choices?.[0]?.message;
|
||||||
|
if (!choice) return '';
|
||||||
|
const content = (choice.content ?? '').trim();
|
||||||
|
if (content.length > 0) return content;
|
||||||
|
const reasoning = choice.reasoning_content ?? '';
|
||||||
|
if (reasoning.length === 0) return '';
|
||||||
|
const lines = reasoning.split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
|
||||||
|
return lines[lines.length - 1] ?? '';
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('task-model: request failed', err);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEndpoint(
|
||||||
|
config: Config,
|
||||||
|
fallbackModel?: string,
|
||||||
|
): { url: string; model: string } {
|
||||||
|
if (config.TASK_MODEL_URL) {
|
||||||
|
return { url: config.TASK_MODEL_URL, model: 'gemma-3-270m-it' };
|
||||||
|
}
|
||||||
|
const model = config.FAST_MODEL ?? fallbackModel ?? config.DEFAULT_MODEL;
|
||||||
|
return { url: config.LLAMA_SWAP_URL, model };
|
||||||
|
}
|
||||||
19
apps/server/src/services/task-search-rewrite.ts
Normal file
19
apps/server/src/services/task-search-rewrite.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { taskModelCompletion } from './task-model.js';
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT =
|
||||||
|
'You rewrite user messages into concise web search queries. Reply with ONLY the search query. 3 to 6 words. No quotes, no explanation.';
|
||||||
|
|
||||||
|
const MAX_INPUT_CHARS = 500;
|
||||||
|
const FALLBACK_CHARS = 60;
|
||||||
|
|
||||||
|
export async function rewriteSearchQuery(userMessage: string): Promise<string> {
|
||||||
|
const input = userMessage.slice(0, MAX_INPUT_CHARS);
|
||||||
|
const result = await taskModelCompletion({
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
user: input,
|
||||||
|
maxTokens: 20,
|
||||||
|
temperature: 0.2,
|
||||||
|
});
|
||||||
|
if (result.length > 0) return result;
|
||||||
|
return userMessage.slice(0, FALLBACK_CHARS).trim();
|
||||||
|
}
|
||||||
24
apps/server/src/services/task-summary.ts
Normal file
24
apps/server/src/services/task-summary.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { taskModelCompletion } from './task-model.js';
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT =
|
||||||
|
'Summarize this conversation in one sentence, 15 words max. No quotes, no prefix.';
|
||||||
|
|
||||||
|
const MAX_INPUT_CHARS = 1000;
|
||||||
|
|
||||||
|
export async function oneLineSummary(
|
||||||
|
messages: Array<{ role: string; content: string }>,
|
||||||
|
): Promise<string> {
|
||||||
|
const lastPairs = messages.slice(-6);
|
||||||
|
let input = lastPairs
|
||||||
|
.map((m) => `${m.role}: ${m.content}`)
|
||||||
|
.join('\n');
|
||||||
|
if (input.length > MAX_INPUT_CHARS) {
|
||||||
|
input = input.slice(0, MAX_INPUT_CHARS);
|
||||||
|
}
|
||||||
|
return taskModelCompletion({
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
user: input,
|
||||||
|
maxTokens: 30,
|
||||||
|
temperature: 0.3,
|
||||||
|
});
|
||||||
|
}
|
||||||
22
apps/server/src/services/task-tags.ts
Normal file
22
apps/server/src/services/task-tags.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { taskModelCompletion } from './task-model.js';
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT =
|
||||||
|
'You tag chat sessions. Reply with 1 to 3 lowercase tags separated by commas. Tags should describe the topic. No explanation. Examples: "docker, deployment", "python, debugging", "react, styling".';
|
||||||
|
|
||||||
|
export async function suggestTags(
|
||||||
|
userMessage: string,
|
||||||
|
assistantReply: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const input = `User: ${userMessage.slice(0, 300)}\nAssistant: ${assistantReply.slice(0, 300)}`;
|
||||||
|
const result = await taskModelCompletion({
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
user: input,
|
||||||
|
maxTokens: 30,
|
||||||
|
temperature: 0.3,
|
||||||
|
});
|
||||||
|
if (result.length === 0) return [];
|
||||||
|
return result
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim().toLowerCase())
|
||||||
|
.filter((t) => t.length > 0 && t.length <= 30);
|
||||||
|
}
|
||||||
347
apps/server/src/services/web/html-to-md.ts
Normal file
347
apps/server/src/services/web/html-to-md.ts
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||||
|
// Ported from studio/backend/core/inference/_html_to_md.py.
|
||||||
|
// Original: https://github.com/unslothai/unsloth/blob/main/studio/backend/core/inference/_html_to_md.py
|
||||||
|
|
||||||
|
import { parse, type DefaultTreeAdapterTypes } from 'parse5';
|
||||||
|
|
||||||
|
type Document = DefaultTreeAdapterTypes.Document;
|
||||||
|
type ChildNode = DefaultTreeAdapterTypes.ChildNode;
|
||||||
|
type Element = DefaultTreeAdapterTypes.Element;
|
||||||
|
type TextNode = DefaultTreeAdapterTypes.TextNode;
|
||||||
|
|
||||||
|
const SKIP_TAGS = new Set([
|
||||||
|
'script', 'style', 'head', 'noscript', 'svg', 'math', 'nav', 'footer',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const BLOCK_TAGS = new Set([
|
||||||
|
'p', 'div', 'section', 'article', 'main', 'aside', 'figure',
|
||||||
|
'figcaption', 'details', 'summary', 'dl', 'dt', 'dd',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
|
||||||
|
|
||||||
|
const INLINE_EMPHASIS: Record<string, string> = {
|
||||||
|
strong: '**', b: '**', em: '*', i: '*',
|
||||||
|
};
|
||||||
|
|
||||||
|
function isElement(node: ChildNode): node is Element {
|
||||||
|
return 'tagName' in node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isText(node: ChildNode): node is TextNode {
|
||||||
|
return node.nodeName === '#text';
|
||||||
|
}
|
||||||
|
|
||||||
|
class MarkdownRenderer {
|
||||||
|
private out: string[] = [];
|
||||||
|
|
||||||
|
private inLink = false;
|
||||||
|
private linkHref: string | null = null;
|
||||||
|
private linkTextParts: string[] = [];
|
||||||
|
|
||||||
|
private listStack: string[] = [];
|
||||||
|
private olCounter: number[] = [];
|
||||||
|
|
||||||
|
private inTable = false;
|
||||||
|
private currentRow: string[] = [];
|
||||||
|
private cellParts: string[] = [];
|
||||||
|
private inCell = false;
|
||||||
|
private headerRowDone = false;
|
||||||
|
private rowHasTh = false;
|
||||||
|
private isFirstRow = false;
|
||||||
|
|
||||||
|
private inPre = false;
|
||||||
|
private preParts: string[] = [];
|
||||||
|
private preLanguage: string | null = null;
|
||||||
|
private inInlineCode = false;
|
||||||
|
|
||||||
|
private bqStack: string[][] = [];
|
||||||
|
|
||||||
|
private emit(text: string): void {
|
||||||
|
if (this.inLink) {
|
||||||
|
this.linkTextParts.push(text);
|
||||||
|
} else if (this.inCell) {
|
||||||
|
this.cellParts.push(text);
|
||||||
|
} else if (this.inPre) {
|
||||||
|
this.preParts.push(text);
|
||||||
|
} else if (this.bqStack.length > 0) {
|
||||||
|
this.bqStack[this.bqStack.length - 1]!.push(text);
|
||||||
|
} else {
|
||||||
|
this.out.push(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private prefixBlockquote(content: string): string {
|
||||||
|
content = content.replace(/[ \t]+$/gm, '');
|
||||||
|
content = content.replace(/\n{3,}/g, '\n\n').trim();
|
||||||
|
if (!content) return '';
|
||||||
|
return content.split('\n').map(line =>
|
||||||
|
line.trim() ? '> ' + line : '>'
|
||||||
|
).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishCell(): void {
|
||||||
|
if (!this.inCell) return;
|
||||||
|
this.inCell = false;
|
||||||
|
let cellText = this.cellParts.join('').trim().replace(/\n/g, ' ');
|
||||||
|
cellText = cellText.replace(/\|/g, '\\|');
|
||||||
|
this.currentRow.push(cellText);
|
||||||
|
this.cellParts = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishRow(): void {
|
||||||
|
if (this.currentRow.length === 0) return;
|
||||||
|
const line = '| ' + this.currentRow.join(' | ') + ' |';
|
||||||
|
this.emit(line + '\n');
|
||||||
|
if (!this.headerRowDone && (this.rowHasTh || this.isFirstRow)) {
|
||||||
|
const sep = '| ' + this.currentRow.map(() => '---').join(' | ') + ' |';
|
||||||
|
this.emit(sep + '\n');
|
||||||
|
this.headerRowDone = true;
|
||||||
|
}
|
||||||
|
this.isFirstRow = false;
|
||||||
|
this.currentRow = [];
|
||||||
|
this.rowHasTh = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishLink(): void {
|
||||||
|
const text = this.linkTextParts.join('').replace(/\s+/g, ' ').trim();
|
||||||
|
const href = this.linkHref ?? '';
|
||||||
|
this.inLink = false;
|
||||||
|
if (href && text) {
|
||||||
|
this.emit(`[${text}](${href})`);
|
||||||
|
} else if (text) {
|
||||||
|
this.emit(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAttr(el: Element, name: string): string | undefined {
|
||||||
|
return el.attrs.find(a => a.name === name)?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleOpen(el: Element): void {
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
|
||||||
|
if (HEADING_TAGS.has(tag)) {
|
||||||
|
const level = parseInt(tag[1]!, 10);
|
||||||
|
this.emit('\n\n' + '#'.repeat(level) + ' ');
|
||||||
|
} else if (tag === 'a') {
|
||||||
|
this.linkHref = this.getAttr(el, 'href') ?? null;
|
||||||
|
this.linkTextParts = [];
|
||||||
|
this.inLink = true;
|
||||||
|
} else if (tag in INLINE_EMPHASIS) {
|
||||||
|
this.emit(INLINE_EMPHASIS[tag]!);
|
||||||
|
} else if (tag === 'br') {
|
||||||
|
this.emit('\n');
|
||||||
|
} else if (BLOCK_TAGS.has(tag)) {
|
||||||
|
this.emit('\n\n');
|
||||||
|
} else if (tag === 'hr') {
|
||||||
|
this.emit('\n\n---\n\n');
|
||||||
|
} else if (tag === 'blockquote') {
|
||||||
|
this.emit('\n\n');
|
||||||
|
this.bqStack.push([]);
|
||||||
|
} else if (tag === 'ul') {
|
||||||
|
this.listStack.push('ul');
|
||||||
|
this.emit('\n');
|
||||||
|
} else if (tag === 'ol') {
|
||||||
|
this.listStack.push('ol');
|
||||||
|
const startAttr = this.getAttr(el, 'start');
|
||||||
|
let start = 1;
|
||||||
|
if (startAttr != null) {
|
||||||
|
const parsed = parseInt(startAttr, 10);
|
||||||
|
if (!isNaN(parsed)) start = parsed;
|
||||||
|
}
|
||||||
|
this.olCounter.push(start - 1);
|
||||||
|
this.emit('\n');
|
||||||
|
} else if (tag === 'li') {
|
||||||
|
const indent = ' '.repeat(Math.max(0, this.listStack.length - 1));
|
||||||
|
if (this.listStack.length > 0 && this.listStack[this.listStack.length - 1] === 'ol') {
|
||||||
|
if (this.olCounter.length > 0) {
|
||||||
|
this.olCounter[this.olCounter.length - 1]!++;
|
||||||
|
this.emit(`\n${indent}${this.olCounter[this.olCounter.length - 1]}. `);
|
||||||
|
} else {
|
||||||
|
this.emit(`\n${indent}1. `);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.emit(`\n${indent}* `);
|
||||||
|
}
|
||||||
|
} else if (tag === 'pre') {
|
||||||
|
this.preParts = [];
|
||||||
|
this.inPre = true;
|
||||||
|
this.preLanguage = null;
|
||||||
|
const codeChild = el.childNodes.find(
|
||||||
|
(c): c is Element => isElement(c) && c.tagName === 'code'
|
||||||
|
);
|
||||||
|
if (codeChild) {
|
||||||
|
const cls = this.getAttr(codeChild, 'class') ?? '';
|
||||||
|
const langMatch = cls.match(/(?:^|\s)language-(\S+)/);
|
||||||
|
if (langMatch) this.preLanguage = langMatch[1]!;
|
||||||
|
}
|
||||||
|
} else if (tag === 'code' && !this.inPre) {
|
||||||
|
this.inInlineCode = true;
|
||||||
|
this.emit('`');
|
||||||
|
} else if (tag === 'table') {
|
||||||
|
this.inTable = true;
|
||||||
|
this.headerRowDone = false;
|
||||||
|
this.isFirstRow = true;
|
||||||
|
this.emit('\n\n');
|
||||||
|
} else if (tag === 'tr') {
|
||||||
|
this.finishCell();
|
||||||
|
this.finishRow();
|
||||||
|
} else if (tag === 'th' || tag === 'td') {
|
||||||
|
this.finishCell();
|
||||||
|
this.cellParts = [];
|
||||||
|
this.inCell = true;
|
||||||
|
if (tag === 'th') this.rowHasTh = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleClose(tag: string): void {
|
||||||
|
tag = tag.toLowerCase();
|
||||||
|
|
||||||
|
if (HEADING_TAGS.has(tag)) {
|
||||||
|
this.emit('\n\n');
|
||||||
|
} else if (tag === 'a') {
|
||||||
|
this.finishLink();
|
||||||
|
} else if (tag in INLINE_EMPHASIS) {
|
||||||
|
this.emit(INLINE_EMPHASIS[tag]!);
|
||||||
|
} else if (BLOCK_TAGS.has(tag)) {
|
||||||
|
this.emit('\n\n');
|
||||||
|
} else if (tag === 'blockquote') {
|
||||||
|
if (this.bqStack.length > 0) {
|
||||||
|
const content = this.bqStack.pop()!.join('');
|
||||||
|
const prefixed = this.prefixBlockquote(content);
|
||||||
|
if (prefixed) this.emit('\n\n' + prefixed + '\n\n');
|
||||||
|
}
|
||||||
|
} else if (tag === 'ul') {
|
||||||
|
if (this.listStack.length > 0 && this.listStack[this.listStack.length - 1] === 'ul') {
|
||||||
|
this.listStack.pop();
|
||||||
|
}
|
||||||
|
this.emit('\n');
|
||||||
|
} else if (tag === 'ol') {
|
||||||
|
if (this.listStack.length > 0 && this.listStack[this.listStack.length - 1] === 'ol') {
|
||||||
|
this.listStack.pop();
|
||||||
|
if (this.olCounter.length > 0) this.olCounter.pop();
|
||||||
|
}
|
||||||
|
this.emit('\n');
|
||||||
|
} else if (tag === 'pre') {
|
||||||
|
const raw = this.preParts.join('');
|
||||||
|
this.inPre = false;
|
||||||
|
const lang = this.preLanguage ?? '';
|
||||||
|
const block = '```' + lang + '\n' + raw + '\n```';
|
||||||
|
this.emit('\n\n' + block + '\n\n');
|
||||||
|
this.preLanguage = null;
|
||||||
|
} else if (tag === 'code' && !this.inPre) {
|
||||||
|
this.inInlineCode = false;
|
||||||
|
this.emit('`');
|
||||||
|
} else if (tag === 'th' || tag === 'td') {
|
||||||
|
this.finishCell();
|
||||||
|
} else if (tag === 'tr') {
|
||||||
|
this.finishCell();
|
||||||
|
this.finishRow();
|
||||||
|
} else if (tag === 'table') {
|
||||||
|
this.finishCell();
|
||||||
|
this.finishRow();
|
||||||
|
this.inTable = false;
|
||||||
|
this.emit('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleText(data: string): void {
|
||||||
|
if (this.inPre) {
|
||||||
|
this.preParts.push(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.inInlineCode) {
|
||||||
|
this.emit(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = data.replace(/\s+/g, ' ');
|
||||||
|
if (this.inTable && !this.inCell && !text.trim()) return;
|
||||||
|
this.emit(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(node: ChildNode | Document): void {
|
||||||
|
if (isText(node as ChildNode)) {
|
||||||
|
this.handleText((node as TextNode).value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node.nodeName === '#comment') return;
|
||||||
|
|
||||||
|
if (isElement(node as ChildNode)) {
|
||||||
|
const el = node as Element;
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
if (SKIP_TAGS.has(tag)) return;
|
||||||
|
if (tag === 'img') return;
|
||||||
|
|
||||||
|
this.handleOpen(el);
|
||||||
|
|
||||||
|
if (tag === 'pre') {
|
||||||
|
for (const child of el.childNodes) {
|
||||||
|
if (isElement(child) && child.tagName === 'code') {
|
||||||
|
for (const grandchild of child.childNodes) {
|
||||||
|
this.walk(grandchild);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.walk(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const child of el.childNodes) {
|
||||||
|
this.walk(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.handleClose(tag);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('childNodes' in node) {
|
||||||
|
for (const child of (node as Document).childNodes) {
|
||||||
|
this.walk(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getOutput(): string {
|
||||||
|
return this.out.join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup(text: string): string {
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const out: string[] = [];
|
||||||
|
let inFence = false;
|
||||||
|
let blankRun = 0;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const stripped = line.replace(/[ \t]+$/, '');
|
||||||
|
if (stripped.startsWith('```')) {
|
||||||
|
inFence = !inFence;
|
||||||
|
blankRun = 0;
|
||||||
|
out.push(stripped);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inFence) {
|
||||||
|
out.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!stripped) {
|
||||||
|
blankRun++;
|
||||||
|
if (blankRun <= 1) out.push('');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
blankRun = 0;
|
||||||
|
out.push(stripped);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.join('\n').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function htmlToMarkdown(sourceHtml: string): string {
|
||||||
|
sourceHtml = sourceHtml.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
const doc = parse(sourceHtml);
|
||||||
|
const renderer = new MarkdownRenderer();
|
||||||
|
renderer.walk(doc);
|
||||||
|
return cleanup(renderer.getOutput());
|
||||||
|
}
|
||||||
1
apps/server/src/services/web/index.ts
Normal file
1
apps/server/src/services/web/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { htmlToMarkdown } from './html-to-md.js';
|
||||||
@@ -12,6 +12,7 @@ import { z } from 'zod';
|
|||||||
import { isPublicUrl } from './url_guard.js';
|
import { isPublicUrl } from './url_guard.js';
|
||||||
import type { ToolDef } from './tools.js';
|
import type { ToolDef } from './tools.js';
|
||||||
import { truncateIfNeeded } from './truncate.js';
|
import { truncateIfNeeded } from './truncate.js';
|
||||||
|
import { htmlToMarkdown } from './web/index.js';
|
||||||
|
|
||||||
const WebFetchInput = z.object({
|
const WebFetchInput = z.object({
|
||||||
url: z.string().min(1).max(2048),
|
url: z.string().min(1).max(2048),
|
||||||
@@ -38,29 +39,9 @@ export type WebFetchOutput =
|
|||||||
}
|
}
|
||||||
| { error: string; reason: string; content_type?: string };
|
| { error: string; reason: string; content_type?: string };
|
||||||
|
|
||||||
function stripHtml(html: string): { text: string; title: string | undefined } {
|
function extractTitle(html: string): string | undefined {
|
||||||
// Title first, before we destroy the markup. Trim collapsed whitespace.
|
|
||||||
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
||||||
const title = titleMatch?.[1]?.replace(/\s+/g, ' ').trim() || undefined;
|
return titleMatch?.[1]?.replace(/\s+/g, ' ').trim() || undefined;
|
||||||
// Drop script + style + comments entirely (their CONTENT must not leak —
|
|
||||||
// a regex tag stripper alone would expose inline JS as plain text).
|
|
||||||
const text = html
|
|
||||||
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
|
|
||||||
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
|
|
||||||
.replace(/<noscript\b[^>]*>[\s\S]*?<\/noscript>/gi, ' ')
|
|
||||||
.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
||||||
.replace(/<[^>]+>/g, ' ')
|
|
||||||
// Minimal entity decode — full coverage would need a table; covering
|
|
||||||
// the five common ones plus is enough for snippet readability.
|
|
||||||
.replace(/ /g, ' ')
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, "'")
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
return { text, title };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.11.10: streaming body reader. Aborts the response stream the instant
|
// v1.11.10: streaming body reader. Aborts the response stream the instant
|
||||||
@@ -211,9 +192,8 @@ export async function executeWebFetch(
|
|||||||
let textRaw: string;
|
let textRaw: string;
|
||||||
let title: string | undefined;
|
let title: string | undefined;
|
||||||
if (contentType.includes('text/html') || contentType.includes('application/xhtml')) {
|
if (contentType.includes('text/html') || contentType.includes('application/xhtml')) {
|
||||||
const stripped = stripHtml(body);
|
title = extractTitle(body);
|
||||||
textRaw = stripped.text;
|
textRaw = htmlToMarkdown(body);
|
||||||
title = stripped.title;
|
|
||||||
} else if (
|
} else if (
|
||||||
contentType.includes('text/plain') ||
|
contentType.includes('text/plain') ||
|
||||||
contentType.includes('text/markdown') ||
|
contentType.includes('text/markdown') ||
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export interface Agent {
|
|||||||
// v1.14.0: per-agent step cap for the outer inference loop. null means
|
// v1.14.0: per-agent step cap for the outer inference loop. null means
|
||||||
// bounded only by MAX_STEPS (200). 0 means "no tool calls allowed."
|
// bounded only by MAX_STEPS (200). 0 means "no tool calls allowed."
|
||||||
steps: number | null;
|
steps: number | null;
|
||||||
|
llama_extra_args: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// One entry per malformed `## Name` block. Per-block errors don't fail the
|
// One entry per malformed `## Name` block. Per-block errors don't fail the
|
||||||
|
|||||||
@@ -272,7 +272,9 @@ export const PermissionRequestedFrame = z.object({
|
|||||||
type: z.literal('permission_requested'),
|
type: z.literal('permission_requested'),
|
||||||
task_id: Uuid,
|
task_id: Uuid,
|
||||||
session_id: Uuid,
|
session_id: Uuid,
|
||||||
|
kind: z.enum(['tool', 'question', 'plan', 'elicitation']).optional(),
|
||||||
tool_title: z.string().optional(),
|
tool_title: z.string().optional(),
|
||||||
|
input: z.record(z.unknown()).optional(),
|
||||||
options: z.array(PermissionOptionShape),
|
options: z.array(PermissionOptionShape),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -43,5 +43,6 @@
|
|||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
"vite": "^5.3.4"
|
"vite": "^5.3.4"
|
||||||
}
|
},
|
||||||
|
"license": "AGPL-3.0-only"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -319,10 +319,10 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
getTaskPermission: (taskId: string) =>
|
getTaskPermission: (taskId: string) =>
|
||||||
request<PermissionPrompt>(`/api/coder/tasks/${taskId}/permission`),
|
request<PermissionPrompt>(`/api/coder/tasks/${taskId}/permission`),
|
||||||
respondTaskPermission: (taskId: string, optionId: string | null) =>
|
respondTaskPermission: (taskId: string, optionId: string | null, updatedInput?: Record<string, unknown>) =>
|
||||||
request<{ ok: boolean }>(`/api/coder/tasks/${taskId}/permission`, {
|
request<{ ok: boolean }>(`/api/coder/tasks/${taskId}/permission`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ option_id: optionId }),
|
body: JSON.stringify({ option_id: optionId, ...(updatedInput ? { updated_input: updatedInput } : {}) }),
|
||||||
}),
|
}),
|
||||||
getTaskCommands: (taskId: string) =>
|
getTaskCommands: (taskId: string) =>
|
||||||
request<{ taskId: string; commands: AgentCommand[] }>(`/api/coder/tasks/${taskId}/commands`),
|
request<{ taskId: string; commands: AgentCommand[] }>(`/api/coder/tasks/${taskId}/commands`),
|
||||||
|
|||||||
@@ -250,9 +250,13 @@ export interface AgentSessionConfig {
|
|||||||
thinkingOptionId: string | null;
|
thinkingOptionId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PermissionKind = 'tool' | 'question' | 'plan' | 'elicitation';
|
||||||
|
|
||||||
export interface PermissionPrompt {
|
export interface PermissionPrompt {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
|
kind?: PermissionKind;
|
||||||
toolTitle?: string;
|
toolTitle?: string;
|
||||||
|
input?: Record<string, unknown>;
|
||||||
options: Array<{ optionId: string; label: string }>;
|
options: Array<{ optionId: string; label: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -272,7 +272,9 @@ export const PermissionRequestedFrame = z.object({
|
|||||||
type: z.literal('permission_requested'),
|
type: z.literal('permission_requested'),
|
||||||
task_id: Uuid,
|
task_id: Uuid,
|
||||||
session_id: Uuid,
|
session_id: Uuid,
|
||||||
|
kind: z.enum(['tool', 'question', 'plan', 'elicitation']).optional(),
|
||||||
tool_title: z.string().optional(),
|
tool_title: z.string().optional(),
|
||||||
|
input: z.record(z.unknown()).optional(),
|
||||||
options: z.array(PermissionOptionShape),
|
options: z.array(PermissionOptionShape),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Check } from 'lucide-react';
|
import { Check } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api/client';
|
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import type {
|
import type {
|
||||||
@@ -22,6 +21,7 @@ interface Props {
|
|||||||
toolCall: ToolCall;
|
toolCall: ToolCall;
|
||||||
toolResult: ToolResult | null;
|
toolResult: ToolResult | null;
|
||||||
chatId: string;
|
chatId: string;
|
||||||
|
apiPrefix?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseQuestions(raw: unknown): AskUserQuestion[] {
|
function parseQuestions(raw: unknown): AskUserQuestion[] {
|
||||||
@@ -63,7 +63,7 @@ function parseAnswerSet(raw: unknown): AskUserAnswerSet | null {
|
|||||||
return { answers };
|
return { answers };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AskUserInputCard({ toolCall, toolResult, chatId }: Props) {
|
export function AskUserInputCard({ toolCall, toolResult, chatId, apiPrefix = '' }: Props) {
|
||||||
const questions = useMemo(() => parseQuestions(toolCall.args), [toolCall.args]);
|
const questions = useMemo(() => parseQuestions(toolCall.args), [toolCall.args]);
|
||||||
|
|
||||||
if (questions.length === 0) {
|
if (questions.length === 0) {
|
||||||
@@ -74,9 +74,6 @@ export function AskUserInputCard({ toolCall, toolResult, chatId }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool result with a non-null output means the answer is already submitted.
|
|
||||||
// The pending sentinel uses output=null, so this branch only triggers after
|
|
||||||
// the real WS tool_result frame lands.
|
|
||||||
const answered = toolResult && toolResult.output !== null;
|
const answered = toolResult && toolResult.output !== null;
|
||||||
if (answered) {
|
if (answered) {
|
||||||
const answerSet = parseAnswerSet(toolResult!.output);
|
const answerSet = parseAnswerSet(toolResult!.output);
|
||||||
@@ -84,7 +81,7 @@ export function AskUserInputCard({ toolCall, toolResult, chatId }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PendingView questions={questions} toolCallId={toolCall.id} chatId={chatId} />
|
<PendingView questions={questions} toolCallId={toolCall.id} chatId={chatId} apiPrefix={apiPrefix} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,10 +89,12 @@ function PendingView({
|
|||||||
questions,
|
questions,
|
||||||
toolCallId,
|
toolCallId,
|
||||||
chatId,
|
chatId,
|
||||||
|
apiPrefix = '',
|
||||||
}: {
|
}: {
|
||||||
questions: AskUserQuestion[];
|
questions: AskUserQuestion[];
|
||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
chatId: string;
|
chatId: string;
|
||||||
|
apiPrefix?: string;
|
||||||
}) {
|
}) {
|
||||||
// Per-question selections + free text. Selections are option arrays so the
|
// Per-question selections + free text. Selections are option arrays so the
|
||||||
// multi_select case is uniform; single_select just constrains to length 1.
|
// multi_select case is uniform; single_select just constrains to length 1.
|
||||||
@@ -133,9 +132,16 @@ function PendingView({
|
|||||||
if (submitting) return;
|
if (submitting) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await api.chats.answerUserInput(chatId, toolCallId, answers);
|
const url = `${apiPrefix}/api/chats/${chatId}/answer_user_input`;
|
||||||
// Card stays mounted; the incoming WS tool_result frame will flip it
|
const res = await fetch(url, {
|
||||||
// into AnsweredView via the parent prop change.
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tool_call_id: toolCallId, answers }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({})) as { error?: string; detail?: string };
|
||||||
|
throw new Error(body.detail ?? body.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'submit failed');
|
toast.error(err instanceof Error ? err.message : 'submit failed');
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
|||||||
@@ -1,14 +1,105 @@
|
|||||||
import { ShieldAlert } from 'lucide-react';
|
import { useState } from 'react';
|
||||||
|
import { ShieldAlert, MessageCircleQuestion } from 'lucide-react';
|
||||||
import type { PermissionPrompt } from '@/api/types';
|
import type { PermissionPrompt } from '@/api/types';
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
prompt: PermissionPrompt;
|
prompt: PermissionPrompt;
|
||||||
onRespond: (optionId: string | null) => void;
|
onRespond: (optionId: string | null, updatedInput?: Record<string, unknown>) => void;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Question detection — ACP's RequestPermissionRequest carries the tool input
|
||||||
|
// in `input`. Claude Code's AskUserQuestion puts { questions: [...] } there.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Question {
|
||||||
|
question: string;
|
||||||
|
header?: string;
|
||||||
|
options: string[];
|
||||||
|
multiSelect: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseQuestions(input: Record<string, unknown> | undefined): Question[] | null {
|
||||||
|
if (!input) return null;
|
||||||
|
const raw = input.questions;
|
||||||
|
if (!Array.isArray(raw)) return null;
|
||||||
|
const out: Question[] = [];
|
||||||
|
for (const item of raw) {
|
||||||
|
if (!item || typeof item !== 'object') continue;
|
||||||
|
const q = item as { question?: unknown; header?: unknown; options?: unknown; multiSelect?: unknown };
|
||||||
|
if (typeof q.question !== 'string') continue;
|
||||||
|
const opts = Array.isArray(q.options)
|
||||||
|
? q.options.filter((o): o is string => typeof o === 'string')
|
||||||
|
: [];
|
||||||
|
out.push({
|
||||||
|
question: q.question,
|
||||||
|
header: typeof q.header === 'string' ? q.header : undefined,
|
||||||
|
options: opts,
|
||||||
|
multiSelect: q.multiSelect === true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out.length > 0 ? out : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Elicitation detection — ACP's createElicitation carries a JSON Schema in
|
||||||
|
// `input.requestedSchema`. For now, render each property as a text input.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ElicitationField {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
type: string;
|
||||||
|
enumValues?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseElicitation(input: Record<string, unknown> | undefined): { message: string; fields: ElicitationField[] } | null {
|
||||||
|
if (!input) return null;
|
||||||
|
const schema = input.requestedSchema;
|
||||||
|
if (!schema || typeof schema !== 'object') return null;
|
||||||
|
const s = schema as Record<string, unknown>;
|
||||||
|
const props = s.properties;
|
||||||
|
if (!props || typeof props !== 'object') return null;
|
||||||
|
const fields: ElicitationField[] = [];
|
||||||
|
for (const [key, val] of Object.entries(props as Record<string, unknown>)) {
|
||||||
|
if (!val || typeof val !== 'object') continue;
|
||||||
|
const p = val as Record<string, unknown>;
|
||||||
|
fields.push({
|
||||||
|
key,
|
||||||
|
title: typeof p.title === 'string' ? p.title : key,
|
||||||
|
description: typeof p.description === 'string' ? p.description : undefined,
|
||||||
|
type: typeof p.type === 'string' ? p.type : 'string',
|
||||||
|
enumValues: Array.isArray(p.enum) ? p.enum.filter((e): e is string => typeof e === 'string') : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (fields.length === 0) return null;
|
||||||
|
return { message: typeof input.message === 'string' ? input.message : '', fields };
|
||||||
|
}
|
||||||
|
|
||||||
export function PermissionCard({ prompt, onRespond, busy }: Props) {
|
export function PermissionCard({ prompt, onRespond, busy }: Props) {
|
||||||
|
const isQuestion = prompt.kind === 'question';
|
||||||
|
const isElicitation = prompt.kind === 'elicitation';
|
||||||
|
|
||||||
|
if (isQuestion) {
|
||||||
|
const questions = parseQuestions(prompt.input);
|
||||||
|
if (questions) {
|
||||||
|
return <QuestionView questions={questions} prompt={prompt} onRespond={onRespond} busy={busy} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isElicitation) {
|
||||||
|
const elicitation = parseElicitation(prompt.input);
|
||||||
|
if (elicitation) {
|
||||||
|
return <ElicitationView elicitation={elicitation} prompt={prompt} onRespond={onRespond} busy={busy} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard tool permission — approve/deny buttons
|
||||||
return (
|
return (
|
||||||
<div className="mx-3 my-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm">
|
<div className="mx-3 my-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
@@ -47,3 +138,286 @@ export function PermissionCard({ prompt, onRespond, busy }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// QuestionView — renders Claude's AskUserQuestion as interactive radio/checkbox
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function QuestionView({
|
||||||
|
questions,
|
||||||
|
prompt,
|
||||||
|
onRespond,
|
||||||
|
busy,
|
||||||
|
}: {
|
||||||
|
questions: Question[];
|
||||||
|
prompt: PermissionPrompt;
|
||||||
|
onRespond: Props['onRespond'];
|
||||||
|
busy?: boolean;
|
||||||
|
}) {
|
||||||
|
const [selections, setSelections] = useState<string[][]>(() => questions.map(() => []));
|
||||||
|
const [freeTexts, setFreeTexts] = useState<string[]>(() => questions.map(() => ''));
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const disabled = busy || submitting;
|
||||||
|
|
||||||
|
const allComplete = questions.every((_, i) =>
|
||||||
|
selections[i]!.length > 0 || freeTexts[i]!.trim().length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
function buildAnswers(): Record<string, string> {
|
||||||
|
const answers: Record<string, string> = {};
|
||||||
|
for (let i = 0; i < questions.length; i++) {
|
||||||
|
const q = questions[i]!;
|
||||||
|
const key = q.question;
|
||||||
|
const selected = selections[i]!;
|
||||||
|
const free = freeTexts[i]!.trim();
|
||||||
|
if (free) {
|
||||||
|
answers[key] = free;
|
||||||
|
} else if (selected.length > 0) {
|
||||||
|
answers[key] = selected.join(', ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return answers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (!allComplete || submitting) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
const answers = buildAnswers();
|
||||||
|
const firstAllow = prompt.options.find((o) =>
|
||||||
|
o.label.toLowerCase().includes('allow') || o.label.toLowerCase().includes('yes'),
|
||||||
|
);
|
||||||
|
onRespond(firstAllow?.optionId ?? prompt.options[0]?.optionId ?? null, {
|
||||||
|
...prompt.input,
|
||||||
|
answers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickSingle(qIdx: number, option: string) {
|
||||||
|
setSelections((prev) => prev.map((arr, i) => (i === qIdx ? [option] : arr)));
|
||||||
|
if (questions.length === 1 && !freeTexts[0]!.trim()) {
|
||||||
|
setSubmitting(true);
|
||||||
|
const firstAllow = prompt.options.find((o) =>
|
||||||
|
o.label.toLowerCase().includes('allow') || o.label.toLowerCase().includes('yes'),
|
||||||
|
);
|
||||||
|
onRespond(firstAllow?.optionId ?? prompt.options[0]?.optionId ?? null, {
|
||||||
|
...prompt.input,
|
||||||
|
answers: { [questions[0]!.question]: option },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMulti(qIdx: number, option: string) {
|
||||||
|
setSelections((prev) =>
|
||||||
|
prev.map((arr, i) => {
|
||||||
|
if (i !== qIdx) return arr;
|
||||||
|
return arr.includes(option) ? arr.filter((o) => o !== option) : [...arr, option];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-3 my-2 rounded-lg border bg-muted/20 text-sm">
|
||||||
|
<div className="px-4 py-3 space-y-4">
|
||||||
|
{questions.map((q, i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
{questions.length > 1 && (
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
||||||
|
{q.header ?? `Question ${i + 1}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="font-medium leading-snug">{q.question}</div>
|
||||||
|
{q.options.length > 0 && !q.multiSelect && (
|
||||||
|
<RadioGroup
|
||||||
|
value={selections[i]![0] ?? ''}
|
||||||
|
onValueChange={(v) => pickSingle(i, v)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
{q.options.map((opt, j) => {
|
||||||
|
const id = `q${i}-opt${j}`;
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={j}
|
||||||
|
htmlFor={id}
|
||||||
|
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
|
||||||
|
>
|
||||||
|
<RadioGroupItem id={id} value={opt} className="mt-0.5" />
|
||||||
|
<span>{opt}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</RadioGroup>
|
||||||
|
)}
|
||||||
|
{q.options.length > 0 && q.multiSelect && (
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{q.options.map((opt, j) => {
|
||||||
|
const id = `q${i}-opt${j}`;
|
||||||
|
const checked = selections[i]!.includes(opt);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={j}
|
||||||
|
htmlFor={id}
|
||||||
|
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={() => toggleMulti(i, opt)}
|
||||||
|
className="mt-1 size-3.5 rounded border-input accent-primary"
|
||||||
|
/>
|
||||||
|
<span>{opt}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="pt-1 space-y-1">
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
||||||
|
Or type a custom answer
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={freeTexts[i]}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="Free text…"
|
||||||
|
onChange={(e) =>
|
||||||
|
setFreeTexts((prev) => prev.map((t, idx) => (idx === i ? e.target.value : t)))
|
||||||
|
}
|
||||||
|
className="w-full rounded border border-input bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:opacity-60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{(questions.length > 1 || freeTexts.some((t) => t.trim())) && (
|
||||||
|
<div className="flex justify-between items-center border-t px-4 py-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onRespond(null)}
|
||||||
|
className="text-xs text-destructive hover:underline disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={!allComplete || disabled}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
{submitting ? 'Submitting…' : 'Submit'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ElicitationView — renders ACP elicitation forms (JSON Schema-driven)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ElicitationView({
|
||||||
|
elicitation,
|
||||||
|
prompt,
|
||||||
|
onRespond,
|
||||||
|
busy,
|
||||||
|
}: {
|
||||||
|
elicitation: { message: string; fields: ElicitationField[] };
|
||||||
|
prompt: PermissionPrompt;
|
||||||
|
onRespond: Props['onRespond'];
|
||||||
|
busy?: boolean;
|
||||||
|
}) {
|
||||||
|
const [values, setValues] = useState<Record<string, string>>(() => {
|
||||||
|
const init: Record<string, string> = {};
|
||||||
|
for (const f of elicitation.fields) init[f.key] = '';
|
||||||
|
return init;
|
||||||
|
});
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const disabled = busy || submitting;
|
||||||
|
|
||||||
|
const allFilled = elicitation.fields.every((f) => (values[f.key] ?? '').trim().length > 0);
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (!allFilled || submitting) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
const content: Record<string, unknown> = {};
|
||||||
|
for (const f of elicitation.fields) {
|
||||||
|
const raw = values[f.key]!.trim();
|
||||||
|
if (f.type === 'number' || f.type === 'integer') {
|
||||||
|
content[f.key] = Number(raw);
|
||||||
|
} else if (f.type === 'boolean') {
|
||||||
|
content[f.key] = raw === 'true' || raw === 'yes' || raw === '1';
|
||||||
|
} else {
|
||||||
|
content[f.key] = raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const firstAllow = prompt.options[0];
|
||||||
|
onRespond(firstAllow?.optionId ?? null, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-3 my-2 rounded-lg border bg-muted/20 text-sm">
|
||||||
|
<div className="px-4 py-3 space-y-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<MessageCircleQuestion className="size-4 text-blue-500 shrink-0 mt-0.5" />
|
||||||
|
<p className="font-medium leading-snug">{elicitation.message}</p>
|
||||||
|
</div>
|
||||||
|
{elicitation.fields.map((f) => (
|
||||||
|
<div key={f.key} className="space-y-1">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">{f.title}</label>
|
||||||
|
{f.description && (
|
||||||
|
<p className="text-[11px] text-muted-foreground/70">{f.description}</p>
|
||||||
|
)}
|
||||||
|
{f.enumValues ? (
|
||||||
|
<RadioGroup
|
||||||
|
value={values[f.key] ?? ''}
|
||||||
|
onValueChange={(v) => setValues((prev) => ({ ...prev, [f.key]: v }))}
|
||||||
|
disabled={disabled}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
{f.enumValues.map((opt, j) => {
|
||||||
|
const id = `e-${f.key}-${j}`;
|
||||||
|
return (
|
||||||
|
<label key={j} htmlFor={id} className="flex items-start gap-2 text-sm cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40">
|
||||||
|
<RadioGroupItem id={id} value={opt} className="mt-0.5" />
|
||||||
|
<span>{opt}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</RadioGroup>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={f.type === 'number' || f.type === 'integer' ? 'number' : 'text'}
|
||||||
|
value={values[f.key] ?? ''}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => setValues((prev) => ({ ...prev, [f.key]: e.target.value }))}
|
||||||
|
className="w-full rounded border border-input bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:opacity-60"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center border-t px-4 py-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onRespond(null)}
|
||||||
|
className="text-xs text-destructive hover:underline disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={!allFilled || disabled}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
{submitting ? 'Submitting…' : 'Submit'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ export function CoderMessageList({ messages, chatId, footer }: Props) {
|
|||||||
toolCall={item.run.call}
|
toolCall={item.run.call}
|
||||||
toolResult={item.run.result}
|
toolResult={item.run.result}
|
||||||
chatId={chatId}
|
chatId={chatId}
|
||||||
|
apiPrefix="/api/coder"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -290,7 +290,9 @@ function useCoderMessages(sessionId: string, chatId: string | undefined, handler
|
|||||||
} else if (frame.type === 'permission_requested') {
|
} else if (frame.type === 'permission_requested') {
|
||||||
handlersRef.current.onPermissionRequested?.({
|
handlersRef.current.onPermissionRequested?.({
|
||||||
taskId: frame.task_id,
|
taskId: frame.task_id,
|
||||||
|
kind: frame.kind,
|
||||||
toolTitle: frame.tool_title,
|
toolTitle: frame.tool_title,
|
||||||
|
...(frame.input ? { input: frame.input as Record<string, unknown> } : {}),
|
||||||
options: (frame.options ?? []).map((o: { option_id: string; label: string }) => ({
|
options: (frame.options ?? []).map((o: { option_id: string; label: string }) => ({
|
||||||
optionId: o.option_id,
|
optionId: o.option_id,
|
||||||
label: o.label,
|
label: o.label,
|
||||||
@@ -565,11 +567,11 @@ export function CoderPane({
|
|||||||
setProviderCommands(commands);
|
setProviderCommands(commands);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handlePermissionRespond = useCallback(async (optionId: string | null) => {
|
const handlePermissionRespond = useCallback(async (optionId: string | null, updatedInput?: Record<string, unknown>) => {
|
||||||
if (!permissionPrompt) return;
|
if (!permissionPrompt) return;
|
||||||
setPermissionBusy(true);
|
setPermissionBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.coder.respondTaskPermission(permissionPrompt.taskId, optionId);
|
await api.coder.respondTaskPermission(permissionPrompt.taskId, optionId, updatedInput);
|
||||||
setPermissionPrompt(null);
|
setPermissionPrompt(null);
|
||||||
} finally {
|
} finally {
|
||||||
setPermissionBusy(false);
|
setPermissionBusy(false);
|
||||||
@@ -716,7 +718,7 @@ export function CoderPane({
|
|||||||
{permissionPrompt && (
|
{permissionPrompt && (
|
||||||
<PermissionCard
|
<PermissionCard
|
||||||
prompt={permissionPrompt}
|
prompt={permissionPrompt}
|
||||||
onRespond={(id) => void handlePermissionRespond(id)}
|
onRespond={(id, input) => void handlePermissionRespond(id, input)}
|
||||||
busy={permissionBusy}
|
busy={permissionBusy}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ top_p: 0.95
|
|||||||
top_k: 20
|
top_k: 20
|
||||||
min_p: 0.0
|
min_p: 0.0
|
||||||
presence_penalty: 0.0
|
presence_penalty: 0.0
|
||||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
|
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||||
description: Reviews code for bugs, security issues, and maintainability. Read-only.
|
description: Reviews code for bugs, security issues, and maintainability. Read-only.
|
||||||
---
|
---
|
||||||
You review code. Find real problems, not style nits.
|
You review code. Find real problems, not style nits.
|
||||||
@@ -46,7 +46,7 @@ top_p: 0.95
|
|||||||
top_k: 20
|
top_k: 20
|
||||||
min_p: 0.0
|
min_p: 0.0
|
||||||
presence_penalty: 0.0
|
presence_penalty: 0.0
|
||||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
|
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||||
description: Diagnoses bugs from error messages, logs, or described symptoms.
|
description: Diagnoses bugs from error messages, logs, or described symptoms.
|
||||||
---
|
---
|
||||||
You diagnose bugs. Form a hypothesis, prove it with evidence from the code.
|
You diagnose bugs. Form a hypothesis, prove it with evidence from the code.
|
||||||
@@ -72,7 +72,7 @@ top_k: 20
|
|||||||
min_p: 0.0
|
min_p: 0.0
|
||||||
presence_penalty: 0.0
|
presence_penalty: 0.0
|
||||||
steps: 5
|
steps: 5
|
||||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
|
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||||
description: Proposes refactors for clarity, deduplication, or decoupling. Read-only — outputs plans, not edits.
|
description: Proposes refactors for clarity, deduplication, or decoupling. Read-only — outputs plans, not edits.
|
||||||
---
|
---
|
||||||
You propose refactors. You do not apply them. The user applies via OpenCode or Claude Code.
|
You propose refactors. You do not apply them. The user applies via OpenCode or Claude Code.
|
||||||
@@ -115,7 +115,7 @@ top_k: 20
|
|||||||
min_p: 0.0
|
min_p: 0.0
|
||||||
presence_penalty: 1.5
|
presence_penalty: 1.5
|
||||||
steps: 20
|
steps: 20
|
||||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
|
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||||
description: Designs new features, modules, or architectural changes. Outputs a build plan.
|
description: Designs new features, modules, or architectural changes. Outputs a build plan.
|
||||||
---
|
---
|
||||||
You design. You produce build plans, not code.
|
You design. You produce build plans, not code.
|
||||||
@@ -157,7 +157,7 @@ top_p: 0.95
|
|||||||
top_k: 20
|
top_k: 20
|
||||||
min_p: 0.0
|
min_p: 0.0
|
||||||
presence_penalty: 0.0
|
presence_penalty: 0.0
|
||||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
|
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||||
description: Audits code for security vulnerabilities. Read-only.
|
description: Audits code for security vulnerabilities. Read-only.
|
||||||
---
|
---
|
||||||
You audit for security issues. Concrete findings only, no generic warnings.
|
You audit for security issues. Concrete findings only, no generic warnings.
|
||||||
@@ -240,7 +240,7 @@ top_p: 0.95
|
|||||||
top_k: 20
|
top_k: 20
|
||||||
min_p: 0.0
|
min_p: 0.0
|
||||||
presence_penalty: 0.0
|
presence_penalty: 0.0
|
||||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
|
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||||
description: Discovers and maps unfamiliar codebases. Reads architecture, traces data flow, identifies key symbols.
|
description: Discovers and maps unfamiliar codebases. Reads architecture, traces data flow, identifies key symbols.
|
||||||
---
|
---
|
||||||
You map codebases. Start broad, then drill into specifics.
|
You map codebases. Start broad, then drill into specifics.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ services:
|
|||||||
CONTAINER_GUIDANCE_FILE: /app/BOOCHAT.md
|
CONTAINER_GUIDANCE_FILE: /app/BOOCHAT.md
|
||||||
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boochat
|
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boochat
|
||||||
BOOCODER_URL: http://100.114.205.53:9502
|
BOOCODER_URL: http://100.114.205.53:9502
|
||||||
|
LLAMA_SIDECAR_URL: http://100.101.41.16:8402
|
||||||
volumes:
|
volumes:
|
||||||
- /opt:/opt
|
- /opt:/opt
|
||||||
- /opt/projects:/opt/projects:rw
|
- /opt/projects:/opt/projects:rw
|
||||||
|
|||||||
@@ -10,5 +10,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.5.0"
|
"typescript": "^5.5.0"
|
||||||
}
|
},
|
||||||
|
"license": "AGPL-3.0-only"
|
||||||
}
|
}
|
||||||
|
|||||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@@ -155,6 +155,9 @@ importers:
|
|||||||
fastify:
|
fastify:
|
||||||
specifier: ^4.28.1
|
specifier: ^4.28.1
|
||||||
version: 4.29.1
|
version: 4.29.1
|
||||||
|
parse5:
|
||||||
|
specifier: ^8.0.1
|
||||||
|
version: 8.0.1
|
||||||
postgres:
|
postgres:
|
||||||
specifier: ^3.4.4
|
specifier: ^3.4.4
|
||||||
version: 3.4.9
|
version: 3.4.9
|
||||||
@@ -2382,6 +2385,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==}
|
resolution: {integrity: sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
|
entities@8.0.0:
|
||||||
|
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
|
||||||
|
engines: {node: '>=20.19.0'}
|
||||||
|
|
||||||
env-paths@2.2.1:
|
env-paths@2.2.1:
|
||||||
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -3274,6 +3281,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
|
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
parse5@8.0.1:
|
||||||
|
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
|
||||||
|
|
||||||
parseurl@1.3.3:
|
parseurl@1.3.3:
|
||||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -6110,6 +6120,8 @@ snapshots:
|
|||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
tapable: 2.3.3
|
tapable: 2.3.3
|
||||||
|
|
||||||
|
entities@8.0.0: {}
|
||||||
|
|
||||||
env-paths@2.2.1: {}
|
env-paths@2.2.1: {}
|
||||||
|
|
||||||
error-ex@1.3.4:
|
error-ex@1.3.4:
|
||||||
@@ -7267,6 +7279,10 @@ snapshots:
|
|||||||
|
|
||||||
parse-ms@4.0.0: {}
|
parse-ms@4.0.0: {}
|
||||||
|
|
||||||
|
parse5@8.0.1:
|
||||||
|
dependencies:
|
||||||
|
entities: 8.0.0
|
||||||
|
|
||||||
parseurl@1.3.3: {}
|
parseurl@1.3.3: {}
|
||||||
|
|
||||||
path-browserify@1.0.1: {}
|
path-browserify@1.0.1: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user