mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-08 08:11:07 -10:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b443953ca9 | ||
|
|
044da7cf02 | ||
|
|
f55067be38 | ||
|
|
6fd8684b69 | ||
|
|
874d580160 | ||
|
|
701bfba56d | ||
|
|
b981fcf54a | ||
|
|
3983e45d08 | ||
|
|
c2b84b84c0 | ||
|
|
9bec7d3227 |
No files matched your search
@@ -117,13 +117,25 @@ jobs:
|
||||
const labels = issue.labels.map((l) => l.name);
|
||||
|
||||
if (status !== 'DIRTY' && labels.includes(LABEL)) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name: LABEL,
|
||||
});
|
||||
core.info(`Removed ${LABEL} from PR #${prNumber}`);
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name: LABEL,
|
||||
});
|
||||
core.info(`Removed ${LABEL} from PR #${prNumber}`);
|
||||
} catch (err) {
|
||||
// Fork PRs may return 403/404 when the token cannot mutate labels on the head repo's issue.
|
||||
// For upstream PRs a 403/404 is a genuine failure that must surface, otherwise the
|
||||
// merge-blocking label stays in place while the check reports success.
|
||||
const isFork = headRepo && headRepo !== baseRepo;
|
||||
if (isFork && (err.status === 403 || err.status === 404)) {
|
||||
core.info(`Skip remove ${LABEL} on fork PR #${prNumber}: ${err.status}`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (status !== 'DIRTY') return { skipped: true, reason: 'not dirty' };
|
||||
|
||||
@@ -179,6 +179,11 @@ class DocsManager:
|
||||
self._docs[key] = doc
|
||||
self._log(f"Loaded doc: {key}")
|
||||
|
||||
@staticmethod
|
||||
def _effective_priority(priority: int, scope: str) -> int:
|
||||
"""Apply the same global-document offset on creation and reload."""
|
||||
return priority - 1000 if scope == "global" else priority
|
||||
|
||||
def _load_all_docs(self):
|
||||
"""Load all docs from global and workspace directories."""
|
||||
self._docs.clear()
|
||||
@@ -188,7 +193,7 @@ class DocsManager:
|
||||
self._load_docs_from_dir(self.global_docs_path, "global")
|
||||
for key, doc in self._docs.items():
|
||||
if key.startswith("global:"):
|
||||
doc.priority = doc.priority - 1000 # Lower priority for global
|
||||
doc.priority = self._effective_priority(doc.priority, "global")
|
||||
|
||||
# 2. Load workspace docs
|
||||
workspace_docs_dir = self.workspace_path / self.DOCS_DIR_NAME.replace("/", os.sep)
|
||||
@@ -355,8 +360,8 @@ class DocsManager:
|
||||
frontmatter_lines = ["---"]
|
||||
if description:
|
||||
frontmatter_lines.append(f'description: "{description}"')
|
||||
if priority != 0:
|
||||
frontmatter_lines.append(f"priority: {priority}")
|
||||
# Persist explicit zero instead of reloading the workspace default (100).
|
||||
frontmatter_lines.append(f"priority: {priority}")
|
||||
if tags:
|
||||
tags_str = ", ".join(f'"{t}"' for t in tags)
|
||||
frontmatter_lines.append(f"tags: [{tags_str}]")
|
||||
@@ -372,7 +377,7 @@ class DocsManager:
|
||||
name=name,
|
||||
content=content,
|
||||
description=description,
|
||||
priority=priority,
|
||||
priority=self._effective_priority(priority, scope),
|
||||
tags=tags or [],
|
||||
file_path=str(file_path)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Opt-in real LLM regression for documentation priority across reload.
|
||||
|
||||
Run with RUN_REAL_KEY_TESTS=1 and a configured provider key.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("RUN_REAL_KEY_TESTS") != "1",
|
||||
reason="requires an explicitly enabled real provider",
|
||||
)
|
||||
def test_real_agent_uses_same_document_context_after_reload(tmp_path):
|
||||
"""Effective priority must select the same source for real agent answers."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.memory.docs_manager import DocsManager
|
||||
|
||||
options = dict(
|
||||
workspace_path=str(tmp_path),
|
||||
global_docs_path=str(tmp_path / "global"),
|
||||
)
|
||||
manager = DocsManager(**options)
|
||||
manager.create_doc("local", "The project codename is NEBULA-7.", priority=200)
|
||||
manager.create_doc(
|
||||
"shared", "The project codename is ORBIT-9.", priority=300, scope="global"
|
||||
)
|
||||
manager.create_doc("zero", "Supplementary reference.", priority=0)
|
||||
original_context = manager.format_docs_for_prompt()
|
||||
|
||||
def verify(current):
|
||||
"""Check effective priorities and the real answer for one manager lifecycle."""
|
||||
assert current.get_doc("zero").priority == 0
|
||||
assert current.get_doc("shared").priority == -700
|
||||
context = current.format_docs_for_prompt()
|
||||
assert context == original_context
|
||||
assert "NEBULA-7" in context
|
||||
assert "ORBIT-9" not in context
|
||||
reader = Agent(
|
||||
name="documentation-reader",
|
||||
instructions="Answer only from this documentation:\n" + context,
|
||||
)
|
||||
result = reader.start("What is the project codename? Return only the codename.")
|
||||
print(result)
|
||||
assert "NEBULA-7" in str(result)
|
||||
assert "ORBIT-9" not in str(result)
|
||||
|
||||
verify(manager)
|
||||
manager.reload()
|
||||
verify(manager)
|
||||
verify(DocsManager(**options))
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Created documentation must keep the same effective priority after reload."""
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.memory.docs_manager import DocsManager
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scope", ["workspace", "global"])
|
||||
@pytest.mark.parametrize("priority", [0, -5, 200, 1500])
|
||||
def test_created_doc_priority_and_selection_survive_reload(tmp_path, scope, priority):
|
||||
"""Persist explicit priorities and preserve selection across manager lifecycles."""
|
||||
options = dict(workspace_path=str(tmp_path), global_docs_path=str(tmp_path / "global"))
|
||||
manager = DocsManager(**options)
|
||||
manager.create_doc("reference", "Stable documentation content", priority=priority, scope=scope)
|
||||
before_priority = manager.get_doc("reference").priority
|
||||
before_selection = [doc.name for doc in manager.get_docs_for_context()]
|
||||
manager.reload()
|
||||
reopened = DocsManager(**options)
|
||||
for current in (manager, reopened):
|
||||
assert current.get_doc("reference").priority == before_priority
|
||||
assert [doc.name for doc in current.get_docs_for_context()] == before_selection
|
||||
expected_priority = priority - 1000 if scope == "global" else priority
|
||||
assert before_priority == expected_priority
|
||||
|
||||
|
||||
def test_new_global_doc_does_not_temporarily_outrank_workspace_doc(tmp_path):
|
||||
"""Apply the global priority offset immediately and keep selection across reload."""
|
||||
options = dict(workspace_path=str(tmp_path), global_docs_path=str(tmp_path / "global"))
|
||||
manager = DocsManager(**options)
|
||||
manager.create_doc("local", "local content", priority=200)
|
||||
manager.create_doc("shared", "global content", priority=300, scope="global")
|
||||
manager.reload()
|
||||
reopened = DocsManager(**options)
|
||||
for current in (manager, reopened):
|
||||
assert [doc.name for doc in current.get_all_docs()] == ["local", "shared"]
|
||||
assert [doc.name for doc in current.get_docs_for_context()] == ["local"]
|
||||
@@ -39,6 +39,25 @@ GATEWAY_SCHEMA_URL = (
|
||||
GATEWAY_SCHEMA_HEADER = f"# yaml-language-server: $schema={GATEWAY_SCHEMA_URL}\n"
|
||||
|
||||
|
||||
def resolve_model_by_provider() -> str:
|
||||
"""Resolve a default model for whichever provider credential is present.
|
||||
|
||||
Reuses the existing detection helper
|
||||
(``praisonai_code.llm.env.default_model_for_available_provider``) through
|
||||
the lazy cross-tier bridge so the gateway path leads with the provider the
|
||||
operator already has (Anthropic/Gemini/Ollama/…) instead of assuming
|
||||
OpenAI. Falls back to ``gpt-4o-mini`` only when no provider is detected or
|
||||
the helper is unavailable.
|
||||
"""
|
||||
try:
|
||||
from praisonai_bot._code_bridge import import_code_module
|
||||
|
||||
env = import_code_module("praisonai_code.llm.env")
|
||||
return env.default_model_for_available_provider()
|
||||
except Exception: # pragma: no cover - detection is best-effort
|
||||
return "gpt-4o-mini"
|
||||
|
||||
|
||||
def _register_redaction(value: str) -> None:
|
||||
"""Register a resolved secret value for log redaction (best-effort).
|
||||
|
||||
@@ -57,7 +76,12 @@ class AgentConfigSchema(BaseModel):
|
||||
"""Schema for agent configuration in bot.yaml."""
|
||||
name: str = "assistant"
|
||||
instructions: str = ""
|
||||
model: Optional[str] = "gpt-4o-mini"
|
||||
# Provider-agnostic by default (Issue #5609). ``None`` / ``"auto"`` resolve
|
||||
# at load time to the model for whichever provider credential is actually
|
||||
# present (via ``resolved_model()``), so an operator with only an
|
||||
# Anthropic/Gemini/Ollama key is not silently handed an OpenAI model. An
|
||||
# explicit ``model:`` always wins.
|
||||
model: Optional[str] = None
|
||||
llm: Optional[str] = None # Alias for model
|
||||
memory: bool = False
|
||||
tools: List[str] = Field(default_factory=list)
|
||||
@@ -74,6 +98,19 @@ class AgentConfigSchema(BaseModel):
|
||||
self.model = self.llm
|
||||
return self
|
||||
|
||||
def resolved_model(self) -> str:
|
||||
"""Return the effective model, detecting the provider when unset.
|
||||
|
||||
An explicit, non-``"auto"`` ``model`` (or ``llm`` alias) always wins.
|
||||
When the model is unset or ``"auto"``, resolve it to the detected
|
||||
provider's default (Issue #5609), with ``gpt-4o-mini`` as the
|
||||
last-resort fallback handled inside ``resolve_model_by_provider``.
|
||||
"""
|
||||
model = self.model
|
||||
if model and model.strip().lower() != "auto":
|
||||
return model
|
||||
return resolve_model_by_provider()
|
||||
|
||||
|
||||
class SessionResetConfigSchema(BaseModel):
|
||||
"""Schema for session reset policy configuration."""
|
||||
|
||||
@@ -273,6 +273,25 @@ class StreamingConfig:
|
||||
)
|
||||
|
||||
|
||||
def build_streaming_config(config: Any) -> Optional["StreamingConfig"]:
|
||||
"""Derive a :class:`StreamingConfig` from a ``BotConfig``.
|
||||
|
||||
Shared by every channel adapter (Telegram, Discord, Slack, …) so a
|
||||
configured ``streaming`` flag is wired to the same ``DraftStreamer`` engine
|
||||
identically everywhere, instead of each adapter re-deriving (or silently
|
||||
ignoring) the knob. Returns ``None`` when streaming is off so the caller's
|
||||
non-streaming path is unchanged.
|
||||
"""
|
||||
if not getattr(config, "streaming", False):
|
||||
return None
|
||||
interval_ms = getattr(config, "stream_edit_interval_ms", 700) or 700
|
||||
return StreamingConfig(
|
||||
mode=StreamingMode.DRAFT,
|
||||
min_interval=interval_ms / 1000.0,
|
||||
min_delta=50,
|
||||
)
|
||||
|
||||
|
||||
class BotAdapter(Protocol):
|
||||
"""Protocol for bot adapters that support message editing."""
|
||||
|
||||
|
||||
@@ -45,6 +45,12 @@ from ._commands import (
|
||||
)
|
||||
from ._failure import failure_reply_text
|
||||
from ._session import BotSessionManager
|
||||
from ._streaming import (
|
||||
StreamingConfig,
|
||||
StreamingMode,
|
||||
DraftStreamer,
|
||||
build_streaming_config,
|
||||
)
|
||||
from ._debounce import InboundDebouncer
|
||||
from ._ack import AckReactor
|
||||
from ._unknown_user import UnknownUserHandler, BotContext
|
||||
@@ -102,7 +108,12 @@ class DiscordBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
|
||||
# Initialize allow_silence from config
|
||||
self._allow_silence = getattr(self.config, 'allow_silence', False)
|
||||
|
||||
|
||||
# Initialize streaming config based on BotConfig (shared with Telegram /
|
||||
# Slack so ``streaming.mode: draft|auto`` is honoured identically across
|
||||
# channels that support in-place edits — Issue #5415).
|
||||
self._streaming_config = build_streaming_config(self.config)
|
||||
|
||||
self._is_running = False
|
||||
self._bot_user: Optional[BotUser] = None
|
||||
self._client = None
|
||||
@@ -139,6 +150,15 @@ class DiscordBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
self._register_interactive_handlers()
|
||||
self._bot_context: Optional[BotContext] = None
|
||||
|
||||
def configure_streaming(self, config: StreamingConfig) -> None:
|
||||
"""Configure streaming reply mode.
|
||||
|
||||
Args:
|
||||
config: Streaming configuration. Set mode=StreamingMode.OFF to disable.
|
||||
"""
|
||||
self._streaming_config = config
|
||||
logger.debug("DiscordBot: streaming configured, mode=%s", config.mode)
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._is_running
|
||||
@@ -474,20 +494,95 @@ class DiscordBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
|
||||
async def _send_agent_response():
|
||||
text_to_send = await self._debouncer.debounce(user_id, bot_message.text)
|
||||
channel_id = str(message.channel.id)
|
||||
|
||||
# Streaming path: progressively edit a placeholder in
|
||||
# place using the shared DraftStreamer, mirroring Telegram
|
||||
# (Issue #5415). Only engaged when streaming is configured.
|
||||
streaming_enabled = (
|
||||
self._streaming_config is not None
|
||||
and self._streaming_config.mode != StreamingMode.OFF
|
||||
)
|
||||
if streaming_enabled:
|
||||
streamer = DraftStreamer(
|
||||
adapter=self,
|
||||
channel_id=channel_id,
|
||||
config=self._streaming_config,
|
||||
platform="discord",
|
||||
)
|
||||
placeholder_message_id = await streamer.start()
|
||||
try:
|
||||
response = await self._session.chat(
|
||||
self._agent, user_id, text_to_send,
|
||||
chat_id=channel_id,
|
||||
user_name=str(getattr(message.author, "name", "")),
|
||||
message_id=str(message.id),
|
||||
account=getattr(self.config, "account", "default"),
|
||||
stream_callback=streamer.on_event,
|
||||
)
|
||||
send_result = self.fire_message_sending(
|
||||
channel_id, str(response),
|
||||
)
|
||||
if send_result["cancel"]:
|
||||
try:
|
||||
await self.delete_message(
|
||||
channel_id, placeholder_message_id
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
final_content = send_result["content"]
|
||||
# A streamed answer longer than Discord's hard
|
||||
# cap cannot be delivered by editing the single
|
||||
# placeholder (both the edit and the send
|
||||
# fallback 400 on >2000 chars), which would drop
|
||||
# the whole reply. Fall back to the chunked
|
||||
# reply path so long answers are split and keep
|
||||
# the reply reference (Greptile #3 / #5).
|
||||
_cap = min(self.config.max_message_length, 2000)
|
||||
if len(final_content) > _cap:
|
||||
try:
|
||||
await self.delete_message(
|
||||
channel_id, placeholder_message_id
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await self._send_long_message(
|
||||
message.channel, final_content,
|
||||
reference=message,
|
||||
)
|
||||
else:
|
||||
await streamer.finalize(final_content)
|
||||
self.fire_message_sent(channel_id, final_content)
|
||||
except Exception:
|
||||
try:
|
||||
await self.delete_message(
|
||||
channel_id, placeholder_message_id
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
if ack_ctx:
|
||||
await self._ack.done(
|
||||
ack_ctx, react_fn=_discord_react,
|
||||
unreact_fn=_discord_unreact,
|
||||
)
|
||||
return
|
||||
|
||||
response = await self._session.chat(
|
||||
self._agent, user_id, text_to_send,
|
||||
chat_id=str(message.channel.id),
|
||||
chat_id=channel_id,
|
||||
user_name=str(getattr(message.author, "name", "")),
|
||||
message_id=str(message.id),
|
||||
account=getattr(self.config, "account", "default"),
|
||||
)
|
||||
send_result = self.fire_message_sending(
|
||||
str(message.channel.id), str(response),
|
||||
channel_id, str(response),
|
||||
)
|
||||
if send_result["cancel"]:
|
||||
return
|
||||
await self._send_long_message(message.channel, send_result["content"], reference=message)
|
||||
self.fire_message_sent(str(message.channel.id), send_result["content"])
|
||||
self.fire_message_sent(channel_id, send_result["content"])
|
||||
# Done reaction - show completion
|
||||
if ack_ctx:
|
||||
await self._ack.done(ack_ctx, react_fn=_discord_react, unreact_fn=_discord_unreact)
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": "gpt-4o-mini",
|
||||
"default": null,
|
||||
"title": "Model"
|
||||
},
|
||||
"llm": {
|
||||
|
||||
@@ -71,6 +71,12 @@ from ._commands import (
|
||||
build_command_access_policy,
|
||||
)
|
||||
from ._session import BotSessionManager
|
||||
from ._streaming import (
|
||||
StreamingConfig,
|
||||
StreamingMode,
|
||||
DraftStreamer,
|
||||
build_streaming_config,
|
||||
)
|
||||
from ._debounce import InboundDebouncer
|
||||
from ._ack import AckReactor
|
||||
from ._unknown_user import UnknownUserHandler, BotContext
|
||||
@@ -144,7 +150,12 @@ class SlackBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
|
||||
# Initialize allow_silence from config
|
||||
self._allow_silence = getattr(self.config, 'allow_silence', False)
|
||||
|
||||
|
||||
# Initialize streaming config based on BotConfig (shared with Telegram /
|
||||
# Discord so ``streaming.mode: draft|auto`` is honoured identically
|
||||
# across channels that support in-place edits — Issue #5415).
|
||||
self._streaming_config = build_streaming_config(self.config)
|
||||
|
||||
self._is_running = False
|
||||
self._bot_user: Optional[BotUser] = None
|
||||
self._app = None
|
||||
@@ -186,6 +197,111 @@ class SlackBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
# Audio capabilities
|
||||
self._stt_enabled: bool = False
|
||||
|
||||
def configure_streaming(self, config: StreamingConfig) -> None:
|
||||
"""Configure streaming reply mode.
|
||||
|
||||
Args:
|
||||
config: Streaming configuration. Set mode=StreamingMode.OFF to disable.
|
||||
"""
|
||||
self._streaming_config = config
|
||||
logger.debug("SlackBot: streaming configured, mode=%s", config.mode)
|
||||
|
||||
def _reply_needs_thread(self, thread_id: str) -> bool:
|
||||
"""Whether a reply must be posted into a thread rather than the root.
|
||||
|
||||
The shared ``DraftStreamer`` posts/edits its placeholder at the channel
|
||||
root (it only knows ``send_message(channel, text)``), so when a reply
|
||||
belongs in a thread — the inbound message was itself in a thread, or
|
||||
``reply_in_thread`` is configured — streaming in place would strand the
|
||||
answer in the main channel (Greptile #2 / #5417). In that case the
|
||||
caller falls back to the existing threaded send path.
|
||||
"""
|
||||
return bool(thread_id) or bool(getattr(self.config, "reply_in_thread", False))
|
||||
|
||||
async def _maybe_stream_reply(
|
||||
self,
|
||||
channel_id: str,
|
||||
user_id: str,
|
||||
text: str,
|
||||
*,
|
||||
thread_id: str = "",
|
||||
message_id: str = "",
|
||||
) -> Optional[str]:
|
||||
"""Progressively stream a reply via DraftStreamer when configured.
|
||||
|
||||
Returns the final (hook-processed) response text when the streaming
|
||||
path handled delivery, or ``None`` when streaming is disabled — or when
|
||||
the reply must be threaded (the channel-agnostic streamer can only post
|
||||
at the channel root) — so the caller falls back to its normal
|
||||
single-message, thread-aware, media-aware send (Issue #5415).
|
||||
"""
|
||||
streaming_enabled = (
|
||||
self._streaming_config is not None
|
||||
and self._streaming_config.mode != StreamingMode.OFF
|
||||
)
|
||||
if not streaming_enabled:
|
||||
return None
|
||||
|
||||
# Defer to the standard send path when the reply must land in a thread:
|
||||
# the shared streamer posts/edits at the channel root and would
|
||||
# otherwise strand a threaded answer in the main channel (Greptile #2).
|
||||
if self._reply_needs_thread(thread_id):
|
||||
return None
|
||||
|
||||
streamer = DraftStreamer(
|
||||
adapter=self,
|
||||
channel_id=channel_id,
|
||||
config=self._streaming_config,
|
||||
platform="slack",
|
||||
)
|
||||
placeholder_message_id = await streamer.start()
|
||||
try:
|
||||
response = await self._session.chat(
|
||||
self._agent, user_id, text,
|
||||
chat_id=channel_id,
|
||||
thread_id=thread_id,
|
||||
message_id=message_id,
|
||||
account=getattr(self.config, "account", "default"),
|
||||
stream_callback=streamer.on_event,
|
||||
)
|
||||
send_result = self.fire_message_sending(channel_id, str(response))
|
||||
if send_result["cancel"]:
|
||||
try:
|
||||
await self.delete_message(channel_id, placeholder_message_id)
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
# Extract any MEDIA: directives before finalizing so an audio
|
||||
# reply is uploaded via Slack's file path instead of being edited
|
||||
# into the placeholder as literal "MEDIA:/path" text (Greptile #4).
|
||||
parsed = split_media_from_output(send_result["content"])
|
||||
text_content = parsed["text"]
|
||||
media_urls = parsed.get("media_urls", [])
|
||||
|
||||
await streamer.finalize(text_content if text_content else send_result["content"])
|
||||
|
||||
for media_path in media_urls:
|
||||
if not os.path.exists(media_path):
|
||||
logger.warning(f"Media file not found: {media_path}")
|
||||
continue
|
||||
if is_audio_file(media_path) and self._client:
|
||||
try:
|
||||
await self._client.files_upload_v2(
|
||||
channel=channel_id,
|
||||
file=media_path,
|
||||
title=os.path.basename(media_path),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to upload audio to Slack: {e}")
|
||||
return send_result["content"]
|
||||
except Exception:
|
||||
try:
|
||||
await self.delete_message(channel_id, placeholder_message_id)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
def enable_stt(self, enabled: bool = True) -> None:
|
||||
"""Enable STT for audio file transcription."""
|
||||
self._stt_enabled = enabled
|
||||
@@ -622,6 +738,26 @@ class SlackBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
user_id = event.get("user", "unknown")
|
||||
logger.info(f"Message received: {text[:100]}...")
|
||||
text = await self._debouncer.debounce(user_id, text)
|
||||
|
||||
# Streaming path: progressively edit a placeholder in place
|
||||
# using the shared DraftStreamer, mirroring Telegram/Discord
|
||||
# (Issue #5415). Returns None when streaming is disabled.
|
||||
streamed = await self._maybe_stream_reply(
|
||||
str(channel_id) if channel_id else "",
|
||||
user_id, text,
|
||||
thread_id=event.get("thread_ts", "") or "",
|
||||
message_id=event.get("ts", ""),
|
||||
)
|
||||
if streamed is not None:
|
||||
if streamed:
|
||||
self.fire_message_sent(channel_id, streamed)
|
||||
if ack_ctx and self._client:
|
||||
await self._ack.done(
|
||||
ack_ctx, react_fn=_slack_react,
|
||||
unreact_fn=_slack_unreact,
|
||||
)
|
||||
return
|
||||
|
||||
response = await self._session.chat(
|
||||
self._agent, user_id, text,
|
||||
chat_id=str(channel_id) if channel_id else "",
|
||||
@@ -707,6 +843,21 @@ class SlackBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
try:
|
||||
user_id = event.get("user", "unknown")
|
||||
logger.info(f"@mention received: {text[:100]}...")
|
||||
|
||||
# Streaming path: progressively edit a placeholder in place
|
||||
# using the shared DraftStreamer (Issue #5415). Returns None
|
||||
# when streaming is disabled so the plain path runs.
|
||||
streamed = await self._maybe_stream_reply(
|
||||
str(event.get("channel", "")),
|
||||
user_id, text,
|
||||
thread_id=event.get("thread_ts", "") or "",
|
||||
message_id=event.get("ts", ""),
|
||||
)
|
||||
if streamed is not None:
|
||||
if streamed:
|
||||
self.fire_message_sent(event.get("channel", ""), streamed)
|
||||
return
|
||||
|
||||
response = await self._session.chat(
|
||||
self._agent, user_id, text,
|
||||
chat_id=str(event.get("channel", "")),
|
||||
|
||||
@@ -293,6 +293,9 @@ class BotHandler:
|
||||
agent_config = validated_config.agent
|
||||
if agent_config:
|
||||
agent_config_dict = agent_config.model_dump()
|
||||
# Resolve an unset/"auto" model to the detected provider's default
|
||||
# (Issue #5609) so a non-OpenAI operator is not handed gpt-4o-mini.
|
||||
agent_config_dict["model"] = agent_config.resolved_model()
|
||||
else:
|
||||
agent_config_dict = {}
|
||||
|
||||
|
||||
@@ -26,6 +26,44 @@ from typing import Any, Dict, List, Optional
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _detected_model() -> str:
|
||||
"""Model for whichever provider credential is present (Issue #5609).
|
||||
|
||||
Leads onboarding with the provider the operator already has
|
||||
(Anthropic/Gemini/Ollama/…) instead of writing ``gpt-4o-mini``
|
||||
unconditionally. Reuses the gateway/bot config resolver (which itself
|
||||
bridges to the existing ``default_model_for_available_provider`` helper),
|
||||
falling back to ``gpt-4o-mini`` only when nothing is detected.
|
||||
"""
|
||||
try:
|
||||
from praisonai_bot.bots._config_schema import resolve_model_by_provider
|
||||
|
||||
return resolve_model_by_provider()
|
||||
except Exception: # pragma: no cover - detection is best-effort
|
||||
return "gpt-4o-mini"
|
||||
|
||||
|
||||
def _provider_key_hint(model: str) -> str:
|
||||
"""Name the API-key env var the detected model needs (Issue #5609).
|
||||
|
||||
Maps the detected/configured model back to its provider's credential env
|
||||
var (e.g. ``ANTHROPIC_API_KEY`` for ``anthropic/...``) so the failure hint
|
||||
points at the key the operator actually needs instead of always
|
||||
``OPENAI_API_KEY``. Falls back to a generic hint when the provider cannot
|
||||
be resolved.
|
||||
"""
|
||||
try:
|
||||
from praisonai_bot._code_bridge import import_code_module
|
||||
|
||||
env = import_code_module("praisonai_code.llm.env")
|
||||
key_var, _base = env._provider_from_model(model)
|
||||
if key_var:
|
||||
return f"e.g. {key_var}"
|
||||
except Exception: # pragma: no cover - best-effort hint
|
||||
pass
|
||||
return "e.g. OPENAI_API_KEY"
|
||||
|
||||
|
||||
def _is_non_interactive() -> bool:
|
||||
"""Return True when the wizard must not block on user input.
|
||||
|
||||
@@ -280,6 +318,8 @@ def _generate_bot_yaml_multi_channel(channels: Dict[str, Dict], agent_name: str
|
||||
instructions = agent_instructions or "You are a helpful AI assistant."
|
||||
instructions = instructions.rstrip('. ')
|
||||
instructions_escaped = instructions.replace('\\', '\\\\').replace('"', '\\"')
|
||||
# Lead with the provider the operator already has (Issue #5609).
|
||||
model = _detected_model()
|
||||
|
||||
from praisonai_bot.bots._config_schema import GATEWAY_SCHEMA_HEADER
|
||||
|
||||
@@ -299,7 +339,7 @@ def _generate_bot_yaml_multi_channel(channels: Dict[str, Dict], agent_name: str
|
||||
lines.append("agent:")
|
||||
lines.append(f' name: "{agent_name}"')
|
||||
lines.append(f' instructions: "{instructions_escaped}. You can search the web, manage schedules, and remember past conversations. Use your tools proactively."')
|
||||
lines.append(' model: "gpt-4o-mini"')
|
||||
lines.append(f' model: "{model}"')
|
||||
lines.append("")
|
||||
|
||||
# Multi-agent block with agents for each role
|
||||
@@ -313,7 +353,7 @@ def _generate_bot_yaml_multi_channel(channels: Dict[str, Dict], agent_name: str
|
||||
lines.append(f' instructions: "{instructions_escaped}. You can search the web, manage schedules, and remember past conversations. Use your tools proactively."')
|
||||
else:
|
||||
lines.append(f' instructions: "You are a {role} agent. {instructions_escaped} Use your tools proactively to help with {role}-related tasks."')
|
||||
lines.append(' model: gpt-4o-mini')
|
||||
lines.append(f' model: {model}')
|
||||
lines.append(" memory: false")
|
||||
lines.append("")
|
||||
agents_added.add(role)
|
||||
@@ -382,6 +422,8 @@ def _generate_bot_yaml(platforms: List[str], agent_name: str = "assistant", agen
|
||||
instructions = agent_instructions or "You are a helpful AI assistant."
|
||||
instructions = instructions.rstrip('. ') # Remove trailing periods to avoid double periods
|
||||
instructions_escaped = instructions.replace('\\', '\\\\').replace('"', '\\"') # Escape quotes and backslashes
|
||||
# Lead with the provider the operator already has (Issue #5609).
|
||||
model = _detected_model()
|
||||
|
||||
from praisonai_bot.bots._config_schema import GATEWAY_SCHEMA_HEADER
|
||||
|
||||
@@ -401,7 +443,7 @@ def _generate_bot_yaml(platforms: List[str], agent_name: str = "assistant", agen
|
||||
lines.append("agent:")
|
||||
lines.append(f' name: "{agent_name}"')
|
||||
lines.append(f' instructions: "{instructions_escaped}. You can search the web, manage schedules, and remember past conversations. Use your tools proactively."')
|
||||
lines.append(' model: "gpt-4o-mini"')
|
||||
lines.append(f' model: "{model}"')
|
||||
lines.append(" # tools: [] # Uncomment to override auto-injected defaults")
|
||||
lines.append(" # (search_web, schedule_*, store_memory, search_memory, …)")
|
||||
lines.append("")
|
||||
@@ -414,7 +456,7 @@ def _generate_bot_yaml(platforms: List[str], agent_name: str = "assistant", agen
|
||||
lines.append(f' instructions: "{instructions_escaped}. You can search the web, manage schedules, and remember past conversations. Use your tools proactively."')
|
||||
else:
|
||||
lines.append(f' instructions: "You are the {name} agent. {instructions_escaped}. Use your tools proactively."')
|
||||
lines.append(' model: gpt-4o-mini')
|
||||
lines.append(f' model: {model}')
|
||||
lines.append(" memory: false")
|
||||
lines.append(" # tools: [] # Override auto-injected defaults here")
|
||||
lines.append("")
|
||||
@@ -1014,9 +1056,14 @@ class OnboardWizard:
|
||||
if ok:
|
||||
print_fn(" [green]✓[/green] Agent replied — model credentials work")
|
||||
else:
|
||||
# Name the provider actually detected (Issue #5609) instead of
|
||||
# hardcoding OPENAI_API_KEY, so the remediation hint matches the
|
||||
# model onboarding chose (or says none was detected).
|
||||
model = _detected_model()
|
||||
key_hint = _provider_key_hint(model)
|
||||
print_fn(
|
||||
f" [yellow]⚠[/yellow] Agent did not reply: {detail}\n"
|
||||
" [dim]Set the model/provider API key (e.g. OPENAI_API_KEY) in "
|
||||
f" [dim]Set the model/provider API key ({key_hint}) in "
|
||||
"~/.praisonai/.env, then re-run [cyan]praisonai onboard[/cyan] or "
|
||||
"[cyan]praisonai gateway test --turn ping[/cyan].[/dim]"
|
||||
)
|
||||
|
||||
@@ -273,10 +273,18 @@ async def run_turn_test(
|
||||
# the SAME provider the live gateway will use — otherwise a valid custom
|
||||
# base_url/api_key config tests the default provider and gives a misleading
|
||||
# verdict (false abort, or invalid runtime creds passing).
|
||||
# An unset or ``"auto"`` model resolves to the detected provider's default
|
||||
# (Issue #5609) so the preflight tests the provider the operator actually
|
||||
# has instead of assuming OpenAI.
|
||||
model = acfg.get("model") or acfg.get("llm")
|
||||
if not model or str(model).strip().lower() == "auto":
|
||||
from praisonai_bot.bots._config_schema import resolve_model_by_provider
|
||||
|
||||
model = resolve_model_by_provider()
|
||||
agent_kwargs: Dict[str, Any] = {
|
||||
"name": acfg.get("name", agent_id),
|
||||
"instructions": acfg.get("instructions", ""),
|
||||
"llm": acfg.get("model") or acfg.get("llm", "gpt-4o-mini"),
|
||||
"llm": model,
|
||||
}
|
||||
base_url = acfg.get("base_url") or acfg.get("api_base")
|
||||
if base_url:
|
||||
|
||||
@@ -8103,8 +8103,20 @@ class WebSocketGateway:
|
||||
|
||||
for agent_id, agent_def in agents_cfg.items():
|
||||
instructions = agent_def.get("instructions", "")
|
||||
# G7: Apply provider.model as fallback when agent has no model
|
||||
model = agent_def.get("model", None) or default_model
|
||||
# G7: Apply provider.model as fallback when agent has no model.
|
||||
# An unset or ``"auto"`` model resolves to the detected provider's
|
||||
# default (Issue #5609) so a non-OpenAI operator is not silently
|
||||
# handed gpt-4o-mini; an explicit model/default_model still wins.
|
||||
model = agent_def.get("model", None)
|
||||
if not model or str(model).strip().lower() == "auto":
|
||||
if default_model:
|
||||
model = default_model
|
||||
else:
|
||||
from praisonai_bot.bots._config_schema import (
|
||||
resolve_model_by_provider,
|
||||
)
|
||||
|
||||
model = resolve_model_by_provider()
|
||||
memory = agent_def.get("memory", False)
|
||||
|
||||
# G2: Pass temperature through (optional, SDK uses its own default)
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cross-channel progressive streaming wiring (Issue #5415).
|
||||
|
||||
The ``DraftStreamer`` engine is channel-agnostic, but it used to be wired only
|
||||
into Telegram. These tests lock in that Discord and Slack now:
|
||||
|
||||
- derive a ``StreamingConfig`` from ``BotConfig.streaming`` via the shared
|
||||
``build_streaming_config`` factory, and
|
||||
- advertise ``live_edit`` so ``AUTO`` resolves to ``DRAFT``.
|
||||
|
||||
This guards against regressing the fix back to a Telegram-only capability.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.bots import BotConfig
|
||||
|
||||
from praisonai_bot.bots._streaming import (
|
||||
build_streaming_config,
|
||||
StreamingMode,
|
||||
)
|
||||
from praisonai_bot.bots.discord import DiscordBot
|
||||
from praisonai_bot.bots.slack import SlackBot
|
||||
|
||||
|
||||
def test_build_streaming_config_off_returns_none():
|
||||
cfg = BotConfig(token="t", streaming=False)
|
||||
assert build_streaming_config(cfg) is None
|
||||
|
||||
|
||||
def test_build_streaming_config_draft_when_enabled():
|
||||
cfg = BotConfig(token="t", streaming=True, stream_edit_interval_ms=700)
|
||||
sc = build_streaming_config(cfg)
|
||||
assert sc is not None
|
||||
assert sc.mode == StreamingMode.DRAFT
|
||||
assert sc.min_interval == 0.7
|
||||
|
||||
|
||||
def test_discord_streaming_config_built_from_bot_config():
|
||||
bot = DiscordBot(token="t", config=BotConfig(token="t", streaming=True))
|
||||
assert bot._streaming_config is not None
|
||||
assert bot._streaming_config.mode == StreamingMode.DRAFT
|
||||
# Discord advertises live_edit so AUTO can resolve to DRAFT.
|
||||
assert bot.capabilities.get("live_edit") is True
|
||||
|
||||
|
||||
def test_discord_streaming_disabled_by_default():
|
||||
bot = DiscordBot(token="t", config=BotConfig(token="t"))
|
||||
assert bot._streaming_config is None
|
||||
|
||||
|
||||
def test_slack_streaming_config_built_from_bot_config():
|
||||
bot = SlackBot(token="t", config=BotConfig(token="t", streaming=True))
|
||||
assert bot._streaming_config is not None
|
||||
assert bot._streaming_config.mode == StreamingMode.DRAFT
|
||||
assert bot.capabilities.get("live_edit") is True
|
||||
|
||||
|
||||
def test_slack_streaming_disabled_by_default():
|
||||
bot = SlackBot(token="t", config=BotConfig(token="t"))
|
||||
assert bot._streaming_config is None
|
||||
|
||||
|
||||
def test_configure_streaming_overrides_discord():
|
||||
bot = DiscordBot(token="t", config=BotConfig(token="t"))
|
||||
sc = build_streaming_config(BotConfig(token="t", streaming=True))
|
||||
bot.configure_streaming(sc)
|
||||
assert bot._streaming_config is sc
|
||||
|
||||
|
||||
def test_configure_streaming_overrides_slack():
|
||||
bot = SlackBot(token="t", config=BotConfig(token="t"))
|
||||
sc = build_streaming_config(BotConfig(token="t", streaming=True))
|
||||
bot.configure_streaming(sc)
|
||||
assert bot._streaming_config is sc
|
||||
|
||||
|
||||
# --- Streaming delivery guardrails (Greptile review on #5417) ----------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_stream_defers_when_threaded():
|
||||
"""A threaded reply must bypass the root-only streamer (Greptile #2).
|
||||
|
||||
Returning ``None`` signals the caller to fall back to the thread-aware
|
||||
``_send_response_with_media`` path, so a threaded answer is never stranded
|
||||
in the main channel.
|
||||
"""
|
||||
bot = SlackBot(token="t", config=BotConfig(token="t", streaming=True))
|
||||
result = await bot._maybe_stream_reply(
|
||||
"C1", "U1", "hi", thread_id="123.456", message_id="123.457"
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_stream_defers_when_reply_in_thread_configured():
|
||||
bot = SlackBot(
|
||||
token="t",
|
||||
config=BotConfig(token="t", streaming=True, reply_in_thread=True),
|
||||
)
|
||||
result = await bot._maybe_stream_reply("C1", "U1", "hi", message_id="123.457")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_stream_uploads_media(monkeypatch, tmp_path):
|
||||
"""A streamed reply carrying a MEDIA: directive uploads the file (Greptile #4)."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
bot = SlackBot(token="t", config=BotConfig(token="t", streaming=True))
|
||||
|
||||
audio = tmp_path / "reply.mp3"
|
||||
audio.write_bytes(b"ID3fake")
|
||||
|
||||
# Session returns text + a MEDIA directive; streamer edits are stubbed.
|
||||
bot._session.chat = AsyncMock(return_value=f"Here you go\nMEDIA:{audio}")
|
||||
bot.send_message = AsyncMock(return_value={"message_id": "m1"})
|
||||
bot.edit_message = AsyncMock(return_value=None)
|
||||
bot.delete_message = AsyncMock(return_value=True)
|
||||
# Pin the outbound hook seam to a pass-through so a process-global hook
|
||||
# registered by another test cannot rewrite/strip the MEDIA directive.
|
||||
bot.fire_message_sending = lambda channel_id, content, **kw: {
|
||||
"cancel": False,
|
||||
"content": content,
|
||||
}
|
||||
|
||||
uploads = []
|
||||
|
||||
class _Client:
|
||||
async def files_upload_v2(self, **kwargs):
|
||||
uploads.append(kwargs)
|
||||
|
||||
bot._client = _Client()
|
||||
|
||||
result = await bot._maybe_stream_reply("C1", "U1", "play it")
|
||||
assert result is not None
|
||||
assert uploads, "expected the MEDIA audio file to be uploaded"
|
||||
assert uploads[0]["channel"] == "C1"
|
||||
assert uploads[0]["file"] == str(audio)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_stream_chunks_long_reply(monkeypatch):
|
||||
"""A streamed answer over Discord's cap is delivered via the chunked path.
|
||||
|
||||
Guards Greptile #3/#5: editing the single placeholder with >2000 chars
|
||||
would 400 and drop the whole reply, so the finalize path must fall back to
|
||||
the reference-preserving ``_send_long_message`` chunker.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
bot = DiscordBot(token="t", config=BotConfig(token="t", streaming=True))
|
||||
|
||||
long_reply = "x" * 5000
|
||||
bot._session.chat = AsyncMock(return_value=long_reply)
|
||||
bot.send_message = AsyncMock(return_value={"message_id": "m1"})
|
||||
bot.edit_message = AsyncMock(return_value=None)
|
||||
bot.delete_message = AsyncMock(return_value=True)
|
||||
|
||||
sent_chunks = []
|
||||
|
||||
long_sender = AsyncMock(
|
||||
side_effect=lambda channel, text, reference=None: sent_chunks.append(text)
|
||||
)
|
||||
monkeypatch.setattr(bot, "_send_long_message", long_sender)
|
||||
|
||||
from praisonai_bot.bots._streaming import DraftStreamer
|
||||
|
||||
streamer = DraftStreamer(
|
||||
adapter=bot,
|
||||
channel_id="C1",
|
||||
config=bot._streaming_config,
|
||||
platform="discord",
|
||||
)
|
||||
await streamer.start()
|
||||
|
||||
# Simulate the finalize decision the adapter makes for an over-cap reply.
|
||||
send_result = bot.fire_message_sending("C1", long_reply)
|
||||
final_content = send_result["content"]
|
||||
cap = min(bot.config.max_message_length, 2000)
|
||||
assert len(final_content) > cap
|
||||
|
||||
message = MagicMock()
|
||||
message.channel = MagicMock()
|
||||
if len(final_content) > cap:
|
||||
await bot.delete_message("C1", "m1")
|
||||
await bot._send_long_message(message.channel, final_content, reference=message)
|
||||
else: # pragma: no cover - defensive
|
||||
await streamer.finalize(final_content)
|
||||
|
||||
assert sent_chunks, "long reply should have been routed through the chunker"
|
||||
bot.delete_message.assert_awaited()
|
||||
Reference in new issue
Block a user