hermes: preserve exact routes and accept Telegram images
This commit is contained in:
parent
89b9f1a4c7
commit
8c73906e6e
@ -449,6 +449,49 @@ if delegate.count(delegate_before) != 1:
|
|||||||
f"found {delegate.count(delegate_before)}"
|
f"found {delegate.count(delegate_before)}"
|
||||||
)
|
)
|
||||||
delegate_path.write_text(delegate.replace(delegate_before, delegate_after, 1))
|
delegate_path.write_text(delegate.replace(delegate_before, delegate_after, 1))
|
||||||
|
|
||||||
|
delegate = delegate_path.read_text()
|
||||||
|
background_before = ''' is_subagent = getattr(parent_agent, "_delegate_depth", 0) > 0
|
||||||
|
return not is_subagent
|
||||||
|
'''
|
||||||
|
background_after = ''' is_subagent = getattr(parent_agent, "_delegate_depth", 0) > 0
|
||||||
|
# A one-shot process has no durable event loop to receive a detached
|
||||||
|
# child's result after its parent exits. Keep delegation synchronous so
|
||||||
|
# the child verdict is returned to—and persisted by—the parent turn.
|
||||||
|
if bool(getattr(parent_agent, "_hermes_oneshot", False)):
|
||||||
|
return False
|
||||||
|
return not is_subagent
|
||||||
|
'''
|
||||||
|
if delegate.count(background_before) != 1:
|
||||||
|
raise SystemExit(
|
||||||
|
"Hermes oneshot delegation context changed: expected 1, "
|
||||||
|
f"found {delegate.count(background_before)}"
|
||||||
|
)
|
||||||
|
delegate_path.write_text(delegate.replace(background_before, background_after, 1))
|
||||||
|
|
||||||
|
oneshot_path = Path("/opt/hermes/hermes_cli/oneshot.py")
|
||||||
|
oneshot = oneshot_path.read_text()
|
||||||
|
oneshot_before = ''' # Belt-and-braces: make sure AIAgent doesn't invoke any streaming
|
||||||
|
# display callbacks that would bypass our stdout capture.
|
||||||
|
agent.suppress_status_output = True
|
||||||
|
'''
|
||||||
|
oneshot_after = ''' # Preserve a caller's explicit --model route across every plugin routing
|
||||||
|
# boundary. Config/env defaults remain automatic policy inputs.
|
||||||
|
agent._hermes_explicit_model_pick = bool((model or "").strip())
|
||||||
|
# Detached delegation cannot deliver back into a process that exits after
|
||||||
|
# this turn; delegate_tool uses this marker to keep children synchronous.
|
||||||
|
agent._hermes_oneshot = True
|
||||||
|
|
||||||
|
# Belt-and-braces: make sure AIAgent doesn't invoke any streaming
|
||||||
|
# display callbacks that would bypass our stdout capture.
|
||||||
|
agent.suppress_status_output = True
|
||||||
|
'''
|
||||||
|
if oneshot.count(oneshot_before) != 1:
|
||||||
|
raise SystemExit(
|
||||||
|
"Hermes oneshot explicit-model context changed: expected 1, "
|
||||||
|
f"found {oneshot.count(oneshot_before)}"
|
||||||
|
)
|
||||||
|
oneshot_path.write_text(oneshot.replace(oneshot_before, oneshot_after, 1))
|
||||||
PY
|
PY
|
||||||
|
|
||||||
# Hermes WebUI sends its model/provider/reasoning selection on /v1/runs.
|
# Hermes WebUI sends its model/provider/reasoning selection on /v1/runs.
|
||||||
@ -1236,6 +1279,7 @@ RUN cd /opt/hermes/web \
|
|||||||
/opt/hermes/gateway/platforms/api_server.py \
|
/opt/hermes/gateway/platforms/api_server.py \
|
||||||
/opt/hermes/agent/turn_context.py \
|
/opt/hermes/agent/turn_context.py \
|
||||||
/opt/hermes/agent/conversation_loop.py \
|
/opt/hermes/agent/conversation_loop.py \
|
||||||
|
/opt/hermes/hermes_cli/oneshot.py \
|
||||||
/opt/hermes/tools/delegate_tool.py \
|
/opt/hermes/tools/delegate_tool.py \
|
||||||
/opt/hermes/tools/web_tools.py \
|
/opt/hermes/tools/web_tools.py \
|
||||||
/opt/hermes/tools/python_sandbox_tool.py \
|
/opt/hermes/tools/python_sandbox_tool.py \
|
||||||
|
|||||||
@ -51,6 +51,14 @@ MANUAL_ROUTES = frozenset(
|
|||||||
)
|
)
|
||||||
ALL_ROUTES = AUTO_ROUTES | MANUAL_ROUTES
|
ALL_ROUTES = AUTO_ROUTES | MANUAL_ROUTES
|
||||||
EFFORTS = frozenset({"none", "minimal", "low", "medium", "high", "xhigh"})
|
EFFORTS = frozenset({"none", "minimal", "low", "medium", "high", "xhigh"})
|
||||||
|
ROUTABLE_EFFORTS = frozenset({"low", "medium", "high", "xhigh"})
|
||||||
|
EXACT_MANUAL_ROUTES = frozenset(
|
||||||
|
f"{route}/{effort}"
|
||||||
|
for route in MANUAL_ROUTES
|
||||||
|
if not route.endswith("/local/qwen-14b")
|
||||||
|
for effort in ROUTABLE_EFFORTS
|
||||||
|
)
|
||||||
|
ALL_REQUEST_ROUTES = ALL_ROUTES | EXACT_MANUAL_ROUTES
|
||||||
PROVIDER_DEFAULT = {
|
PROVIDER_DEFAULT = {
|
||||||
"codex": "atlas/manual/codex/terra",
|
"codex": "atlas/manual/codex/terra",
|
||||||
"claude": "atlas/manual/claude/sonnet",
|
"claude": "atlas/manual/claude/sonnet",
|
||||||
@ -136,11 +144,11 @@ def _normalise_manual_route(provider: str, model: str = "") -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _explicit_ui_route(agent: Any) -> str:
|
def _explicit_ui_route(agent: Any) -> str:
|
||||||
"""Return a route selected in the WebUI model picker for this request."""
|
"""Return an exact route selected by the WebUI or command-line caller."""
|
||||||
if not bool(getattr(agent, "_hermes_explicit_model_pick", False)):
|
if not bool(getattr(agent, "_hermes_explicit_model_pick", False)):
|
||||||
return ""
|
return ""
|
||||||
route = str(getattr(agent, "model", "") or "").strip()
|
route = str(getattr(agent, "model", "") or "").strip()
|
||||||
return route if route in ALL_ROUTES else ""
|
return route if route in ALL_REQUEST_ROUTES else ""
|
||||||
|
|
||||||
|
|
||||||
def _explicit_ui_effort(agent: Any) -> str:
|
def _explicit_ui_effort(agent: Any) -> str:
|
||||||
@ -158,6 +166,9 @@ def _boundary_selection(agent: Any) -> tuple[str, str, str]:
|
|||||||
ui_effort = _explicit_ui_effort(agent)
|
ui_effort = _explicit_ui_effort(agent)
|
||||||
if ui_route:
|
if ui_route:
|
||||||
mode = "auto" if ui_route in AUTO_ROUTES else "manual"
|
mode = "auto" if ui_route in AUTO_ROUTES else "manual"
|
||||||
|
if ui_route in EXACT_MANUAL_ROUTES:
|
||||||
|
effort = ui_route.rsplit("/", 1)[-1]
|
||||||
|
return ui_route, effort, f"ui-{mode}"
|
||||||
return ui_route, ui_effort, f"ui-{mode}"
|
return ui_route, ui_effort, f"ui-{mode}"
|
||||||
|
|
||||||
if policy["mode"] == "manual":
|
if policy["mode"] == "manual":
|
||||||
@ -179,9 +190,11 @@ def _boundary_selection(agent: Any) -> tuple[str, str, str]:
|
|||||||
|
|
||||||
def _resolved_route(route: str, effort: str) -> str:
|
def _resolved_route(route: str, effort: str) -> str:
|
||||||
"""Bind a manual family and UI effort to an exact Switchyard route."""
|
"""Bind a manual family and UI effort to an exact Switchyard route."""
|
||||||
|
if route in EXACT_MANUAL_ROUTES:
|
||||||
|
return route
|
||||||
if route not in MANUAL_ROUTES or route.endswith("/local/qwen-14b"):
|
if route not in MANUAL_ROUTES or route.endswith("/local/qwen-14b"):
|
||||||
return route
|
return route
|
||||||
provider_effort = effort if effort in {"low", "medium", "high", "xhigh"} else "low"
|
provider_effort = effort if effort in ROUTABLE_EFFORTS else "low"
|
||||||
return f"{route}/{provider_effort}"
|
return f"{route}/{provider_effort}"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -27,6 +27,7 @@ type telegramBot struct {
|
|||||||
config telegramConfig
|
config telegramConfig
|
||||||
router *tenantRouter
|
router *tenantRouter
|
||||||
apiBase string
|
apiBase string
|
||||||
|
fileBase string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
mediaClient *http.Client
|
mediaClient *http.Client
|
||||||
agentClient *http.Client
|
agentClient *http.Client
|
||||||
@ -45,10 +46,19 @@ type telegramUpdate struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type telegramMessage struct {
|
type telegramMessage struct {
|
||||||
MessageID int64 `json:"message_id"`
|
MessageID int64 `json:"message_id"`
|
||||||
From *telegramUser `json:"from"`
|
From *telegramUser `json:"from"`
|
||||||
Chat telegramChat `json:"chat"`
|
Chat telegramChat `json:"chat"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
|
Caption string `json:"caption"`
|
||||||
|
Photo []telegramPhotoSize `json:"photo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type telegramPhotoSize struct {
|
||||||
|
FileID string `json:"file_id"`
|
||||||
|
FileSize int64 `json:"file_size"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type telegramUser struct {
|
type telegramUser struct {
|
||||||
@ -89,6 +99,7 @@ func newTelegramBot(config telegramConfig, router *tenantRouter) *telegramBot {
|
|||||||
config: config,
|
config: config,
|
||||||
router: router,
|
router: router,
|
||||||
apiBase: "https://api.telegram.org/bot" + config.BotToken,
|
apiBase: "https://api.telegram.org/bot" + config.BotToken,
|
||||||
|
fileBase: "https://api.telegram.org/file/bot" + config.BotToken,
|
||||||
client: &http.Client{Timeout: 70 * time.Second},
|
client: &http.Client{Timeout: 70 * time.Second},
|
||||||
mediaClient: newTenantMediaClient(),
|
mediaClient: newTenantMediaClient(),
|
||||||
agentClient: &http.Client{Timeout: 15 * time.Minute},
|
agentClient: &http.Client{Timeout: 15 * time.Minute},
|
||||||
@ -265,7 +276,7 @@ func (bot *telegramBot) handleUpdate(update telegramUpdate) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if command == "help" {
|
if command == "help" {
|
||||||
_ = bot.sendText(message.Chat.ID, "Send any text to chat with Hermes. Use /unlink to disconnect this Telegram account. Model and intensity controls are available in Hermes WebUI.")
|
_ = bot.sendText(message.Chat.ID, "Send text or a photo to chat with Hermes. Use /unlink to disconnect this Telegram account. Model and intensity controls are available in Hermes WebUI.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
slot, linked := bot.router.telegramSlot(userID)
|
slot, linked := bot.router.telegramSlot(userID)
|
||||||
@ -274,8 +285,15 @@ func (bot *telegramBot) handleUpdate(update telegramUpdate) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
text := strings.TrimSpace(message.Text)
|
text := strings.TrimSpace(message.Text)
|
||||||
|
hasPhoto := len(message.Photo) > 0
|
||||||
|
if hasPhoto {
|
||||||
|
text = strings.TrimSpace(message.Caption)
|
||||||
|
if text == "" {
|
||||||
|
text = "Describe this image and answer any question it implies."
|
||||||
|
}
|
||||||
|
}
|
||||||
if text == "" {
|
if text == "" {
|
||||||
_ = bot.sendText(message.Chat.ID, "Text messages are supported now; attachment support will be added separately.")
|
_ = bot.sendText(message.Chat.ID, "Hermes currently accepts text and image attachments. Please send other file types through Hermes WebUI.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if utf8.RuneCountInString(text) > 12000 {
|
if utf8.RuneCountInString(text) > 12000 {
|
||||||
@ -286,7 +304,19 @@ func (bot *telegramBot) handleUpdate(update telegramUpdate) {
|
|||||||
lock.Lock()
|
lock.Lock()
|
||||||
defer lock.Unlock()
|
defer lock.Unlock()
|
||||||
_ = bot.sendAction(message.Chat.ID, "typing")
|
_ = bot.sendAction(message.Chat.ID, "typing")
|
||||||
reply, err := bot.askTenant(slot, text, update.UpdateID)
|
var reply string
|
||||||
|
var err error
|
||||||
|
if hasPhoto {
|
||||||
|
media, fetchErr := bot.fetchTelegramPhoto(message.Photo)
|
||||||
|
if fetchErr != nil {
|
||||||
|
_ = bot.sendText(message.Chat.ID, "Hermes could not safely read that image. Please try a smaller JPEG, PNG, or WebP image.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.Remove(media.Path)
|
||||||
|
reply, err = bot.askTenantImage(slot, text, media, update.UpdateID)
|
||||||
|
} else {
|
||||||
|
reply, err = bot.askTenant(slot, text, update.UpdateID)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = bot.sendText(message.Chat.ID, "Hermes could not answer right now. Please try again shortly.")
|
_ = bot.sendText(message.Chat.ID, "Hermes could not answer right now. Please try again shortly.")
|
||||||
return
|
return
|
||||||
@ -303,13 +333,20 @@ func (bot *telegramBot) askTenant(slot int, text string, updateID int64) (string
|
|||||||
"conversation": "telegram",
|
"conversation": "telegram",
|
||||||
"store": true,
|
"store": true,
|
||||||
})
|
})
|
||||||
|
return bot.askTenantRequest(slot, bytes.NewReader(payload), updateID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bot *telegramBot) askTenantRequest(slot int, bodyReader io.Reader, updateID int64) (string, error) {
|
||||||
|
if bot.router.backendAPIURL == nil {
|
||||||
|
return "", errors.New("tenant API unavailable")
|
||||||
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 14*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 14*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
request, err := http.NewRequestWithContext(
|
request, err := http.NewRequestWithContext(
|
||||||
ctx,
|
ctx,
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
bot.router.backendAPIURL(slot)+"/v1/responses",
|
bot.router.backendAPIURL(slot)+"/v1/responses",
|
||||||
bytes.NewReader(payload),
|
bodyReader,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
195
services/hermes/router/telegram_input.go
Normal file
195
services/hermes/router/telegram_input.go
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const telegramInputImageLimit = 20 << 20
|
||||||
|
|
||||||
|
type telegramInputMedia struct {
|
||||||
|
MIME string
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func largestTelegramPhoto(photos []telegramPhotoSize) (telegramPhotoSize, error) {
|
||||||
|
var selected telegramPhotoSize
|
||||||
|
for _, photo := range photos {
|
||||||
|
if strings.TrimSpace(photo.FileID) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if selected.FileID == "" || int64(photo.Width)*int64(photo.Height) > int64(selected.Width)*int64(selected.Height) {
|
||||||
|
selected = photo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if selected.FileID == "" {
|
||||||
|
return telegramPhotoSize{}, errors.New("Telegram photo has no file identifier")
|
||||||
|
}
|
||||||
|
if selected.FileSize > telegramInputImageLimit {
|
||||||
|
return telegramPhotoSize{}, errors.New("Telegram photo exceeds input limit")
|
||||||
|
}
|
||||||
|
return selected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeTelegramFilePath(raw string) (string, error) {
|
||||||
|
value := strings.TrimSpace(raw)
|
||||||
|
if value == "" || strings.Contains(value, `\`) || strings.ContainsRune(value, '\x00') {
|
||||||
|
return "", errors.New("invalid Telegram file path")
|
||||||
|
}
|
||||||
|
cleaned := path.Clean(value)
|
||||||
|
if cleaned == "." || path.IsAbs(cleaned) || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
|
||||||
|
return "", errors.New("invalid Telegram file path")
|
||||||
|
}
|
||||||
|
return cleaned, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapedTelegramFilePath(value string) string {
|
||||||
|
components := strings.Split(value, "/")
|
||||||
|
for index := range components {
|
||||||
|
components[index] = url.PathEscape(components[index])
|
||||||
|
}
|
||||||
|
return strings.Join(components, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func supportedTelegramImageType(header []byte) (string, error) {
|
||||||
|
detected := http.DetectContentType(header)
|
||||||
|
switch detected {
|
||||||
|
case "image/jpeg", "image/png", "image/webp", "image/gif":
|
||||||
|
return detected, nil
|
||||||
|
default:
|
||||||
|
return "", errors.New("unsupported Telegram image type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bot *telegramBot) fetchTelegramPhoto(photos []telegramPhotoSize) (telegramInputMedia, error) {
|
||||||
|
selected, err := largestTelegramPhoto(photos)
|
||||||
|
if err != nil {
|
||||||
|
return telegramInputMedia{}, err
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 65*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
var remote struct {
|
||||||
|
FilePath string `json:"file_path"`
|
||||||
|
FileSize int64 `json:"file_size"`
|
||||||
|
}
|
||||||
|
if err := bot.call(ctx, "getFile", url.Values{"file_id": {selected.FileID}}, &remote); err != nil {
|
||||||
|
return telegramInputMedia{}, errors.New("Telegram file lookup failed")
|
||||||
|
}
|
||||||
|
if remote.FileSize > telegramInputImageLimit {
|
||||||
|
return telegramInputMedia{}, errors.New("Telegram photo exceeds input limit")
|
||||||
|
}
|
||||||
|
remotePath, err := safeTelegramFilePath(remote.FilePath)
|
||||||
|
if err != nil {
|
||||||
|
return telegramInputMedia{}, err
|
||||||
|
}
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(bot.fileBase, "/")+"/"+escapedTelegramFilePath(remotePath), nil)
|
||||||
|
if err != nil {
|
||||||
|
return telegramInputMedia{}, errors.New("create Telegram file request")
|
||||||
|
}
|
||||||
|
response, err := bot.client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return telegramInputMedia{}, errors.New("Telegram file download failed")
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return telegramInputMedia{}, errors.New("Telegram file download rejected")
|
||||||
|
}
|
||||||
|
if response.ContentLength > telegramInputImageLimit {
|
||||||
|
return telegramInputMedia{}, errors.New("Telegram photo exceeds input limit")
|
||||||
|
}
|
||||||
|
temporary, err := os.CreateTemp("", "hermes-telegram-input-*")
|
||||||
|
if err != nil {
|
||||||
|
return telegramInputMedia{}, errors.New("stage Telegram image")
|
||||||
|
}
|
||||||
|
temporaryPath := temporary.Name()
|
||||||
|
keep := false
|
||||||
|
defer func() {
|
||||||
|
_ = temporary.Close()
|
||||||
|
if !keep {
|
||||||
|
_ = os.Remove(temporaryPath)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
written, err := io.Copy(temporary, io.LimitReader(response.Body, telegramInputImageLimit+1))
|
||||||
|
if err != nil {
|
||||||
|
return telegramInputMedia{}, errors.New("read Telegram image")
|
||||||
|
}
|
||||||
|
if written > telegramInputImageLimit {
|
||||||
|
return telegramInputMedia{}, errors.New("Telegram photo exceeds input limit")
|
||||||
|
}
|
||||||
|
if _, err := temporary.Seek(0, io.SeekStart); err != nil {
|
||||||
|
return telegramInputMedia{}, errors.New("inspect Telegram image")
|
||||||
|
}
|
||||||
|
header := make([]byte, 512)
|
||||||
|
read, readErr := temporary.Read(header)
|
||||||
|
if readErr != nil && readErr != io.EOF {
|
||||||
|
return telegramInputMedia{}, errors.New("inspect Telegram image")
|
||||||
|
}
|
||||||
|
mimeType, err := supportedTelegramImageType(header[:read])
|
||||||
|
if err != nil {
|
||||||
|
return telegramInputMedia{}, err
|
||||||
|
}
|
||||||
|
if err := temporary.Close(); err != nil {
|
||||||
|
return telegramInputMedia{}, errors.New("stage Telegram image")
|
||||||
|
}
|
||||||
|
keep = true
|
||||||
|
return telegramInputMedia{MIME: mimeType, Path: temporaryPath}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func streamTelegramImageRequest(writer io.Writer, prompt string, media telegramInputMedia) error {
|
||||||
|
promptJSON, err := json.Marshal(prompt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(writer, `{"input":[{"role":"user","content":[{"type":"input_text","text":%s},{"type":"input_image","image_url":"data:%s;base64,`, promptJSON, media.MIME); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
file, err := os.Open(media.Path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
encoder := base64.NewEncoder(base64.StdEncoding, writer)
|
||||||
|
_, copyErr := io.Copy(encoder, file)
|
||||||
|
closeEncoderErr := encoder.Close()
|
||||||
|
closeFileErr := file.Close()
|
||||||
|
if copyErr != nil {
|
||||||
|
return copyErr
|
||||||
|
}
|
||||||
|
if closeEncoderErr != nil {
|
||||||
|
return closeEncoderErr
|
||||||
|
}
|
||||||
|
if closeFileErr != nil {
|
||||||
|
return closeFileErr
|
||||||
|
}
|
||||||
|
_, err = io.WriteString(writer, `"}]}],"conversation":"telegram","store":true}`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bot *telegramBot) askTenantImage(slot int, prompt string, media telegramInputMedia, updateID int64) (string, error) {
|
||||||
|
reader, writer := io.Pipe()
|
||||||
|
writeDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
err := streamTelegramImageRequest(writer, prompt, media)
|
||||||
|
_ = writer.CloseWithError(err)
|
||||||
|
writeDone <- err
|
||||||
|
}()
|
||||||
|
answer, requestErr := bot.askTenantRequest(slot, reader, updateID)
|
||||||
|
_ = reader.Close()
|
||||||
|
writeErr := <-writeDone
|
||||||
|
if requestErr != nil {
|
||||||
|
return "", requestErr
|
||||||
|
}
|
||||||
|
if writeErr != nil {
|
||||||
|
return "", errors.New("encode Telegram image")
|
||||||
|
}
|
||||||
|
return answer, nil
|
||||||
|
}
|
||||||
@ -1,7 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@ -91,3 +93,146 @@ func TestTelegramCallIncludesAPIDescription(t *testing.T) {
|
|||||||
t.Fatalf("expected Telegram API description, got %v", err)
|
t.Fatalf("expected Telegram API description, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func jpegInputFixture() []byte {
|
||||||
|
return append(
|
||||||
|
[]byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F', 0x00},
|
||||||
|
[]byte("telegram-input-image")...,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchTelegramPhotoStagesLargestImageWithoutTrustingPath(t *testing.T) {
|
||||||
|
image := jpegInputFixture()
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
switch request.URL.Path {
|
||||||
|
case "/getFile":
|
||||||
|
if err := request.ParseForm(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if request.Form.Get("file_id") != "large" {
|
||||||
|
t.Fatalf("unexpected file id %q", request.Form.Get("file_id"))
|
||||||
|
}
|
||||||
|
writer.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(writer).Encode(map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"result": map[string]any{
|
||||||
|
"file_path": "photos/input.jpg",
|
||||||
|
"file_size": len(image),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
case "/file/photos/input.jpg":
|
||||||
|
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
_, _ = writer.Write(image)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected Telegram path %s", request.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
bot := &telegramBot{
|
||||||
|
apiBase: server.URL,
|
||||||
|
fileBase: server.URL + "/file",
|
||||||
|
client: &http.Client{Timeout: time.Second},
|
||||||
|
}
|
||||||
|
media, err := bot.fetchTelegramPhoto([]telegramPhotoSize{
|
||||||
|
{FileID: "small", Width: 100, Height: 100, FileSize: 50},
|
||||||
|
{FileID: "large", Width: 800, Height: 600, FileSize: int64(len(image))},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.Remove(media.Path)
|
||||||
|
actual, err := os.ReadFile(media.Path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if media.MIME != "image/jpeg" || string(actual) != string(image) {
|
||||||
|
t.Fatalf("unexpected staged image: mime=%q bytes=%x", media.MIME, actual)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(media.Path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0600 {
|
||||||
|
t.Fatalf("staged image mode is %o", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTelegramImageRequestStreamsMultimodalInput(t *testing.T) {
|
||||||
|
image := jpegInputFixture()
|
||||||
|
imagePath := filepath.Join(t.TempDir(), "input.jpg")
|
||||||
|
if err := os.WriteFile(imagePath, image, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if request.Header.Get("Authorization") != "Bearer relay-secret" {
|
||||||
|
t.Fatal("relay authentication was not set")
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(request.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Input []struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
ImageURL string `json:"image_url"`
|
||||||
|
} `json:"content"`
|
||||||
|
} `json:"input"`
|
||||||
|
Conversation string `json:"conversation"`
|
||||||
|
Store bool `json:"store"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
t.Fatalf("decode streamed request: %v\n%s", err, body)
|
||||||
|
}
|
||||||
|
if payload.Conversation != "telegram" || !payload.Store || len(payload.Input) != 1 || len(payload.Input[0].Content) != 2 {
|
||||||
|
t.Fatalf("unexpected image payload: %#v", payload)
|
||||||
|
}
|
||||||
|
if payload.Input[0].Role != "user" || payload.Input[0].Content[0].Text != "What is this?" || payload.Input[0].Content[1].Type != "input_image" {
|
||||||
|
t.Fatalf("unexpected image content: %#v", payload.Input[0])
|
||||||
|
}
|
||||||
|
encoded := strings.TrimPrefix(payload.Input[0].Content[1].ImageURL, "data:image/jpeg;base64,")
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||||
|
if err != nil || string(decoded) != string(image) {
|
||||||
|
t.Fatalf("unexpected encoded image: err=%v bytes=%x", err, decoded)
|
||||||
|
}
|
||||||
|
writer.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = writer.Write([]byte(`{"output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"It is a test image."}]}]}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(int) string { return "" })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
router.backendAPIURL = func(int) string { return server.URL }
|
||||||
|
bot := newTelegramBot(telegramConfig{RelayKey: "relay-secret"}, router)
|
||||||
|
answer, err := bot.askTenantImage(0, "What is this?", telegramInputMedia{
|
||||||
|
MIME: "image/jpeg",
|
||||||
|
Path: imagePath,
|
||||||
|
}, 99)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if answer != "It is a test image." {
|
||||||
|
t.Fatalf("unexpected answer %q", answer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTelegramInputRejectsUnsafePathsAndNonImages(t *testing.T) {
|
||||||
|
for _, value := range []string{"", "../secret", "/absolute/image.jpg", `photos\image.jpg`, "photos/../../secret"} {
|
||||||
|
if _, err := safeTelegramFilePath(value); err == nil {
|
||||||
|
t.Errorf("unsafe path accepted: %q", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := supportedTelegramImageType([]byte("not an image")); err == nil {
|
||||||
|
t.Fatal("non-image input was accepted")
|
||||||
|
}
|
||||||
|
if _, err := largestTelegramPhoto([]telegramPhotoSize{{
|
||||||
|
FileID: "too-large", Width: 1, Height: 1, FileSize: telegramInputImageLimit + 1,
|
||||||
|
}}); err == nil {
|
||||||
|
t.Fatal("oversized Telegram image was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -24,6 +24,7 @@ const telegramPage = `<!doctype html>
|
|||||||
<a class="hermes-back" href="/">← Back to Hermes</a>
|
<a class="hermes-back" href="/">← Back to Hermes</a>
|
||||||
<h1>Hermes on Telegram</h1>
|
<h1>Hermes on Telegram</h1>
|
||||||
<p>The operator configures one shared Hermes bot. Link your own Telegram account once so direct messages use this Keycloak account's isolated Hermes tenant.</p>
|
<p>The operator configures one shared Hermes bot. Link your own Telegram account once so direct messages use this Keycloak account's isolated Hermes tenant.</p>
|
||||||
|
<p>After linking, you can send text or photos for analysis. Images Hermes creates or revises are returned directly in the same private chat.</p>
|
||||||
<p><strong>Account-link commands go only to the private chat with the Hermes bot—not to Hermes WebUI and not to BotFather.</strong></p>
|
<p><strong>Account-link commands go only to the private chat with the Hermes bot—not to Hermes WebUI and not to BotFather.</strong></p>
|
||||||
<p id="telegram-status">Checking Telegram…</p>
|
<p id="telegram-status">Checking Telegram…</p>
|
||||||
<div class="hermes-link-actions">
|
<div class="hermes-link-actions">
|
||||||
|
|||||||
@ -107,6 +107,25 @@ def test_ui_manual_model_and_effort_are_forwarded_as_constraints(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_exact_manual_route_remains_exact_across_boundaries(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(router, "POLICY_PATH", tmp_path / "route-policy.json")
|
||||||
|
agent = _agent(
|
||||||
|
model="atlas/manual/claude/opus/xhigh",
|
||||||
|
_hermes_explicit_model_pick=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
route, effort, source = router._boundary_selection(agent)
|
||||||
|
|
||||||
|
assert (route, effort, source) == (
|
||||||
|
"atlas/manual/claude/opus/xhigh",
|
||||||
|
"xhigh",
|
||||||
|
"ui-manual",
|
||||||
|
)
|
||||||
|
assert router._resolved_route(route, effort) == route
|
||||||
|
|
||||||
|
|
||||||
def test_manual_family_and_effort_resolve_to_one_exact_switchyard_route():
|
def test_manual_family_and_effort_resolve_to_one_exact_switchyard_route():
|
||||||
assert router._resolved_route("atlas/manual/codex/terra", "xhigh") == (
|
assert router._resolved_route("atlas/manual/codex/terra", "xhigh") == (
|
||||||
"atlas/manual/codex/terra/xhigh"
|
"atlas/manual/codex/terra/xhigh"
|
||||||
|
|||||||
@ -158,6 +158,9 @@ def test_gateway_image_honors_ui_model_and_caps_reasoning():
|
|||||||
assert "pre_internal_route hook failed" in dockerfile
|
assert "pre_internal_route hook failed" in dockerfile
|
||||||
assert '"pre_subagent_route"' in dockerfile
|
assert '"pre_subagent_route"' in dockerfile
|
||||||
assert "pre_subagent_route hook failed" in dockerfile
|
assert "pre_subagent_route hook failed" in dockerfile
|
||||||
|
assert "agent._hermes_explicit_model_pick = bool" in dockerfile
|
||||||
|
assert "agent._hermes_oneshot = True" in dockerfile
|
||||||
|
assert 'getattr(parent_agent, "_hermes_oneshot", False)' in dockerfile
|
||||||
assert 'var != "CLAUDE_CODE_OAUTH_TOKEN"' in dockerfile
|
assert 'var != "CLAUDE_CODE_OAUTH_TOKEN"' in dockerfile
|
||||||
assert '"source": "managed_claude_code_subscription"' in dockerfile
|
assert '"source": "managed_claude_code_subscription"' in dockerfile
|
||||||
assert '"name": "Claude Code subscription (Switchyard)"' in dockerfile
|
assert '"name": "Claude Code subscription (Switchyard)"' in dockerfile
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user