agentnova 0.3.4__tar.gz → 0.3.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agentnova-0.3.4 → agentnova-0.3.5}/PKG-INFO +4 -3
- {agentnova-0.3.4 → agentnova-0.3.5}/README.md +3 -2
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/__init__.py +1 -1
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/acp_plugin.py +86 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/agent.py +138 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/backends/ollama.py +149 -37
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/cli.py +1 -1
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/core/model_family_config.py +43 -1
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/core/openresponses.py +248 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/core/tool_parse.py +16 -13
- agentnova-0.3.5/agentnova/skills/loader.py +734 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/soul/loader.py +38 -3
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/soul/types.py +26 -3
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/souls/nova-helper/SOUL.md +42 -5
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova.egg-info/PKG-INFO +4 -3
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova.egg-info/SOURCES.txt +2 -3
- {agentnova-0.3.4 → agentnova-0.3.5}/pyproject.toml +1 -1
- {agentnova-0.3.4 → agentnova-0.3.5}/tests/test_agent.py +19 -9
- agentnova-0.3.5/tests/test_spec_compliance.py +383 -0
- agentnova-0.3.4/agentnova/skills/loader.py +0 -1893
- agentnova-0.3.4/tests/test_acp_integration.py +0 -223
- agentnova-0.3.4/tests/test_acp_subagents.py +0 -296
- {agentnova-0.3.4 → agentnova-0.3.5}/LICENSE +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/__main__.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/agent_mode.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/backends/__init__.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/backends/base.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/backends/bitnet.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/config.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/core/__init__.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/core/args_normal.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/core/error_recovery.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/core/helpers.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/core/math_prompts.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/core/memory.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/core/model_config.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/core/models.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/core/prompts.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/core/types.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/examples/00_basic_agent.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/examples/01_quick_diagnostic.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/examples/02_tool_test.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/examples/03_reasoning_test.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/examples/04_gsm8k_benchmark.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/examples/05_common_sense.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/examples/06_causal_reasoning.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/examples/07_logical_deduction.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/examples/08_reading_comprehension.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/examples/09_general_knowledge.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/examples/10_implicit_reasoning.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/examples/11_analogical_reasoning.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/examples/__init__.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/model_discovery.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/orchestrator.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/orchestrator_enhanced.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/py.typed +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/shared_args.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/__init__.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/acp/SKILL.md +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/datetime/SKILL.md +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/SKILL.md +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/scripts/__init__.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/scripts/aggregate_benchmark.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/scripts/generate_report.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/scripts/improve_description.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/scripts/init_skill.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/scripts/package_skill.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/scripts/quick_validate.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/scripts/run_eval.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/scripts/run_loop.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/scripts/security_scan.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/scripts/test_package_skill.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/scripts/test_quick_validate.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/scripts/utils.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/scripts/validate.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/web-search/SKILL.md +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/soul/__init__.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/souls/nova-helper/IDENTITY.md +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/souls/nova-helper/STYLE.md +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/souls/nova-helper/soul.json +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/tools/__init__.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/tools/builtins.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/tools/registry.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/tools/sandboxed_repl.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova.egg-info/dependency_links.txt +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova.egg-info/entry_points.txt +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova.egg-info/requires.txt +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/agentnova.egg-info/top_level.txt +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/localclaw/__init__.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/localclaw-redirect/localclaw/__init__.py +0 -0
- {agentnova-0.3.4 → agentnova-0.3.5}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentnova
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.5
|
|
4
4
|
Summary: ⚛️ AgentNova - A minimal, hackable agentic framework for local LLM inference
|
|
5
5
|
Author-email: VTSTech <veritas@vts-tech.org>
|
|
6
6
|
Maintainer-email: VTSTech <veritas@vts-tech.org>
|
|
@@ -32,7 +32,7 @@ Requires-Dist: black>=23.0; extra == "dev"
|
|
|
32
32
|
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
33
33
|
Dynamic: license-file
|
|
34
34
|
|
|
35
|
-
# ⚛️ AgentNova R03.
|
|
35
|
+
# ⚛️ AgentNova R03.5
|
|
36
36
|
|
|
37
37
|
**Status: Alpha**
|
|
38
38
|
|
|
@@ -49,7 +49,7 @@ Inspired by the architecture of OpenClaw, rebuilt from scratch for local-first o
|
|
|
49
49
|
|
|
50
50
|
[](#license) [](https://python.org)
|
|
51
51
|
|
|
52
|
-
<img width="
|
|
52
|
+
<img width="1378" height="996" alt="image" src="https://github.com/user-attachments/assets/0fe69695-73d9-4ce1-999f-08443f879971" />
|
|
53
53
|
|
|
54
54
|
## 📚 Documentation
|
|
55
55
|
|
|
@@ -71,6 +71,7 @@ Inspired by the architecture of OpenClaw, rebuilt from scratch for local-first o
|
|
|
71
71
|
- **Soul Spec v0.5** — Persona packages with progressive disclosure
|
|
72
72
|
- **ACP v1.0.5 integration** — Agent Control Panel for monitoring and control
|
|
73
73
|
- **AgentSkills spec** — Skill loading with SPDX license validation
|
|
74
|
+
- **Thinking models support** — Automatic handling of qwen3, deepseek-r1 thinking mode
|
|
74
75
|
|
|
75
76
|
## Installation
|
|
76
77
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# ⚛️ AgentNova R03.
|
|
1
|
+
# ⚛️ AgentNova R03.5
|
|
2
2
|
|
|
3
3
|
**Status: Alpha**
|
|
4
4
|
|
|
@@ -15,7 +15,7 @@ Inspired by the architecture of OpenClaw, rebuilt from scratch for local-first o
|
|
|
15
15
|
|
|
16
16
|
[](#license) [](https://python.org)
|
|
17
17
|
|
|
18
|
-
<img width="
|
|
18
|
+
<img width="1378" height="996" alt="image" src="https://github.com/user-attachments/assets/0fe69695-73d9-4ce1-999f-08443f879971" />
|
|
19
19
|
|
|
20
20
|
## 📚 Documentation
|
|
21
21
|
|
|
@@ -37,6 +37,7 @@ Inspired by the architecture of OpenClaw, rebuilt from scratch for local-first o
|
|
|
37
37
|
- **Soul Spec v0.5** — Persona packages with progressive disclosure
|
|
38
38
|
- **ACP v1.0.5 integration** — Agent Control Panel for monitoring and control
|
|
39
39
|
- **AgentSkills spec** — Skill loading with SPDX license validation
|
|
40
|
+
- **Thinking models support** — Automatic handling of qwen3, deepseek-r1 thinking mode
|
|
40
41
|
|
|
41
42
|
## Installation
|
|
42
43
|
|
|
@@ -67,6 +67,7 @@ from __future__ import annotations
|
|
|
67
67
|
|
|
68
68
|
import base64
|
|
69
69
|
import json
|
|
70
|
+
import random
|
|
70
71
|
import time
|
|
71
72
|
import urllib.request
|
|
72
73
|
import urllib.error
|
|
@@ -74,6 +75,14 @@ from contextlib import contextmanager
|
|
|
74
75
|
from dataclasses import dataclass, field
|
|
75
76
|
from typing import Any, Callable, Generator
|
|
76
77
|
|
|
78
|
+
# Default retry configuration
|
|
79
|
+
ACP_RETRY_CONFIG = {
|
|
80
|
+
"max_retries": 3,
|
|
81
|
+
"base_delay": 0.5,
|
|
82
|
+
"max_delay": 8.0,
|
|
83
|
+
"jitter": True, # Add randomness to avoid thundering herd
|
|
84
|
+
}
|
|
85
|
+
|
|
77
86
|
# Import StepResult for type hints (optional - works without it)
|
|
78
87
|
try:
|
|
79
88
|
from .core.models import StepResult
|
|
@@ -369,6 +378,83 @@ class ACPPlugin:
|
|
|
369
378
|
self._csrf_token = token if token else ""
|
|
370
379
|
self._csrf_expiry = now
|
|
371
380
|
|
|
381
|
+
def _request_with_retry(
|
|
382
|
+
self,
|
|
383
|
+
endpoint: str,
|
|
384
|
+
method: str = "GET",
|
|
385
|
+
data: dict | None = None,
|
|
386
|
+
timeout: float = 5.0,
|
|
387
|
+
max_retries: int = None,
|
|
388
|
+
base_delay: float = None,
|
|
389
|
+
max_delay: float = None,
|
|
390
|
+
jitter: bool = None,
|
|
391
|
+
retryable_errors: tuple = None,
|
|
392
|
+
) -> dict:
|
|
393
|
+
"""
|
|
394
|
+
Make HTTP request with exponential backoff retry logic.
|
|
395
|
+
|
|
396
|
+
This wraps _request() with automatic retry on transient failures,
|
|
397
|
+
implementing exponential backoff to avoid overwhelming the server.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
endpoint: API endpoint path
|
|
401
|
+
method: HTTP method
|
|
402
|
+
data: Request body data
|
|
403
|
+
timeout: Request timeout
|
|
404
|
+
max_retries: Maximum number of retry attempts (default: from ACP_RETRY_CONFIG)
|
|
405
|
+
base_delay: Initial delay in seconds (default: from ACP_RETRY_CONFIG)
|
|
406
|
+
max_delay: Maximum delay cap in seconds (default: from ACP_RETRY_CONFIG)
|
|
407
|
+
jitter: Add randomness to avoid thundering herd (default: from ACP_RETRY_CONFIG)
|
|
408
|
+
retryable_errors: Tuple of error types that should trigger retry
|
|
409
|
+
Default: (502, 503, 504, connection errors)
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
API response dict
|
|
413
|
+
"""
|
|
414
|
+
# Use config defaults if not specified
|
|
415
|
+
if max_retries is None:
|
|
416
|
+
max_retries = ACP_RETRY_CONFIG["max_retries"]
|
|
417
|
+
if base_delay is None:
|
|
418
|
+
base_delay = ACP_RETRY_CONFIG["base_delay"]
|
|
419
|
+
if max_delay is None:
|
|
420
|
+
max_delay = ACP_RETRY_CONFIG["max_delay"]
|
|
421
|
+
if jitter is None:
|
|
422
|
+
jitter = ACP_RETRY_CONFIG["jitter"]
|
|
423
|
+
|
|
424
|
+
if retryable_errors is None:
|
|
425
|
+
# Default retryable: server errors and connection issues
|
|
426
|
+
retryable_errors = (502, 503, 504, "connection", "timeout")
|
|
427
|
+
|
|
428
|
+
last_error = None
|
|
429
|
+
|
|
430
|
+
for attempt in range(max_retries + 1):
|
|
431
|
+
result = self._request(endpoint, method, data, timeout)
|
|
432
|
+
|
|
433
|
+
# Check for success
|
|
434
|
+
if result.get("success") != False or "error" not in result:
|
|
435
|
+
return result
|
|
436
|
+
|
|
437
|
+
# Check if error is retryable
|
|
438
|
+
error_str = str(result.get("error", "")).lower()
|
|
439
|
+
is_retryable = any(
|
|
440
|
+
str(code) in error_str or str(code) in error_str.lower()
|
|
441
|
+
for code in retryable_errors
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
if not is_retryable or attempt == max_retries:
|
|
445
|
+
return result
|
|
446
|
+
|
|
447
|
+
# Calculate exponential backoff delay with optional jitter
|
|
448
|
+
delay = min(base_delay * (2 ** attempt), max_delay)
|
|
449
|
+
if jitter:
|
|
450
|
+
# Add up to 25% random jitter
|
|
451
|
+
delay = delay * (0.75 + random.random() * 0.5)
|
|
452
|
+
self._log(f"Retry {attempt + 1}/{max_retries} after {delay:.2f}s: {error_str}")
|
|
453
|
+
time.sleep(delay)
|
|
454
|
+
last_error = error_str
|
|
455
|
+
|
|
456
|
+
return result
|
|
457
|
+
|
|
372
458
|
# ------------------------------------------------------------------ #
|
|
373
459
|
# JSON-RPC 2.0 Support (1.0.4 - A2A Compliance) #
|
|
374
460
|
# ------------------------------------------------------------------ #
|
|
@@ -42,6 +42,7 @@ from .core.openresponses import (
|
|
|
42
42
|
RequestConfig, Error,
|
|
43
43
|
create_message_item, create_function_call_item, create_function_call_output,
|
|
44
44
|
create_function_call_output_item,
|
|
45
|
+
stream_response_events,
|
|
45
46
|
)
|
|
46
47
|
from .tools import ToolRegistry, make_builtin_registry
|
|
47
48
|
from .backends import BaseBackend, get_default_backend
|
|
@@ -852,6 +853,143 @@ Final Answer: <the answer>
|
|
|
852
853
|
success=bool(final_answer),
|
|
853
854
|
)
|
|
854
855
|
|
|
856
|
+
def run_stream(self, prompt: str) -> Generator[str, None, None]:
|
|
857
|
+
"""
|
|
858
|
+
Run the agent on a prompt with streaming OpenResponses SSE events.
|
|
859
|
+
|
|
860
|
+
This method implements the agentic loop with streaming output following
|
|
861
|
+
OpenResponses specification. It yields Server-Sent Events (SSE) that
|
|
862
|
+
describe the response lifecycle and content deltas.
|
|
863
|
+
|
|
864
|
+
SSE Event Sequence (per OpenResponses spec):
|
|
865
|
+
1. response.queued - Response is queued
|
|
866
|
+
2. response.in_progress - Response started
|
|
867
|
+
3. response.output_item.added - New output item added
|
|
868
|
+
4. response.content_part.added - New content part added
|
|
869
|
+
5. response.output_text.delta - Text deltas (multiple)
|
|
870
|
+
6. response.output_text.done - Text completed
|
|
871
|
+
7. response.content_part.done - Content part completed
|
|
872
|
+
8. response.output_item.done - Output item completed
|
|
873
|
+
9. response.completed - Response finished
|
|
874
|
+
|
|
875
|
+
Args:
|
|
876
|
+
prompt: User prompt
|
|
877
|
+
|
|
878
|
+
Yields:
|
|
879
|
+
SSE-formatted strings (event: ...\\ndata: ...\\n\\n)
|
|
880
|
+
|
|
881
|
+
Example:
|
|
882
|
+
agent = Agent(model="qwen2.5:0.5b")
|
|
883
|
+
for sse_event in agent.run_stream("Hello!"):
|
|
884
|
+
print(sse_event) # SSE formatted event
|
|
885
|
+
"""
|
|
886
|
+
# Create OpenResponses Response object
|
|
887
|
+
response = Response(
|
|
888
|
+
model=self.model,
|
|
889
|
+
status=ResponseStatus.QUEUED,
|
|
890
|
+
tool_choice=self.tool_choice,
|
|
891
|
+
allowed_tools=self._allowed_tools or [],
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
if self.debug and not self._is_comp_mode:
|
|
895
|
+
print(f"\n[OpenResponses] Response created: id={response.id}")
|
|
896
|
+
print(f"[OpenResponses] Response status: {response.status.value}")
|
|
897
|
+
|
|
898
|
+
# Add user prompt to memory
|
|
899
|
+
self.memory.add("user", prompt)
|
|
900
|
+
|
|
901
|
+
# Add input item
|
|
902
|
+
user_item = create_message_item("user", prompt)
|
|
903
|
+
response.input.append(user_item)
|
|
904
|
+
|
|
905
|
+
if self.debug and not self._is_comp_mode:
|
|
906
|
+
print(f"[OpenResponses] Input item added: id={user_item.id}, type={user_item.type}, role={user_item.role}")
|
|
907
|
+
|
|
908
|
+
if self.debug:
|
|
909
|
+
print(f"\n[AgentNova] Model: {self.model}")
|
|
910
|
+
print(f"[AgentNova] Backend: {self.backend.base_url}")
|
|
911
|
+
print(f"[AgentNova] tool_choice: {self.tool_choice.type.value}")
|
|
912
|
+
print(f"[AgentNova] Tools: {self.tools.names()}")
|
|
913
|
+
print(f"[AgentNova] Prompt (streaming): {prompt}\n")
|
|
914
|
+
|
|
915
|
+
# Get text chunks from backend streaming
|
|
916
|
+
text_chunks = self._generate_stream_chunks(prompt)
|
|
917
|
+
|
|
918
|
+
# Wrap with OpenResponses SSE events using stream_response_events()
|
|
919
|
+
for sse_event in stream_response_events(response, text_chunks, debug=self.debug):
|
|
920
|
+
yield sse_event
|
|
921
|
+
|
|
922
|
+
def _generate_stream_chunks(self, prompt: str) -> Generator[str, None, None]:
|
|
923
|
+
"""
|
|
924
|
+
Generate streaming text chunks from the backend.
|
|
925
|
+
|
|
926
|
+
This is a helper method that wraps the backend's streaming functionality
|
|
927
|
+
and yields raw text chunks for the OpenResponses event generator.
|
|
928
|
+
|
|
929
|
+
Args:
|
|
930
|
+
prompt: User prompt (unused, memory already has the prompt)
|
|
931
|
+
|
|
932
|
+
Yields:
|
|
933
|
+
Text chunks from the model
|
|
934
|
+
"""
|
|
935
|
+
messages = self.memory.get_messages()
|
|
936
|
+
|
|
937
|
+
if self.debug:
|
|
938
|
+
print(f" [DEBUG] Streaming {len(messages)} messages")
|
|
939
|
+
|
|
940
|
+
# Check if model needs thinking disabled (qwen3, deepseek-r1, etc.)
|
|
941
|
+
think = None
|
|
942
|
+
if self.model_family:
|
|
943
|
+
from .core.model_family_config import needs_no_think_directive
|
|
944
|
+
if needs_no_think_directive(self.model_family):
|
|
945
|
+
think = False
|
|
946
|
+
|
|
947
|
+
# Build kwargs for backend
|
|
948
|
+
backend_kwargs = {"think": think}
|
|
949
|
+
if self.num_ctx is not None:
|
|
950
|
+
backend_kwargs["num_ctx"] = self.num_ctx
|
|
951
|
+
|
|
952
|
+
# Check if backend has streaming support
|
|
953
|
+
if hasattr(self.backend, 'generate_stream'):
|
|
954
|
+
# Use native Ollama streaming
|
|
955
|
+
for chunk in self.backend.generate_stream(
|
|
956
|
+
model=self.model,
|
|
957
|
+
messages=messages,
|
|
958
|
+
tools=self.tools.all() if self.tools and len(self.tools) > 0 else None,
|
|
959
|
+
temperature=self.model_config.default_temperature,
|
|
960
|
+
max_tokens=self.model_config.default_max_tokens,
|
|
961
|
+
**backend_kwargs,
|
|
962
|
+
):
|
|
963
|
+
yield chunk
|
|
964
|
+
elif hasattr(self.backend, 'generate_completions_stream'):
|
|
965
|
+
# Use OpenAI-compatible streaming
|
|
966
|
+
for chunk_dict in self.backend.generate_completions_stream(
|
|
967
|
+
model=self.model,
|
|
968
|
+
messages=messages,
|
|
969
|
+
tools=self.tools.all() if self.tools and len(self.tools) > 0 else None,
|
|
970
|
+
temperature=self.model_config.default_temperature,
|
|
971
|
+
max_tokens=self.model_config.default_max_tokens,
|
|
972
|
+
**backend_kwargs,
|
|
973
|
+
):
|
|
974
|
+
delta = chunk_dict.get("delta", "")
|
|
975
|
+
if delta:
|
|
976
|
+
yield delta
|
|
977
|
+
else:
|
|
978
|
+
# Fallback: non-streaming with simulated streaming
|
|
979
|
+
result = self.backend.generate(
|
|
980
|
+
model=self.model,
|
|
981
|
+
messages=messages,
|
|
982
|
+
tools=self.tools.all() if self.tools and len(self.tools) > 0 else None,
|
|
983
|
+
temperature=self.model_config.default_temperature,
|
|
984
|
+
max_tokens=self.model_config.default_max_tokens,
|
|
985
|
+
**backend_kwargs,
|
|
986
|
+
)
|
|
987
|
+
content = result.get("content", "")
|
|
988
|
+
# Yield content in chunks for consistent behavior
|
|
989
|
+
chunk_size = 20
|
|
990
|
+
for i in range(0, len(content), chunk_size):
|
|
991
|
+
yield content[i:i + chunk_size]
|
|
992
|
+
|
|
855
993
|
def create_response(
|
|
856
994
|
self,
|
|
857
995
|
input_items: list = None,
|
|
@@ -296,6 +296,13 @@ class OllamaBackend(BaseBackend):
|
|
|
296
296
|
frequency_penalty: float | None = None,
|
|
297
297
|
response_format: dict | None = None,
|
|
298
298
|
top_p: float | None = None,
|
|
299
|
+
# OpenAI Chat Completions API spec completeness (v1.0)
|
|
300
|
+
logprobs: bool | None = None,
|
|
301
|
+
top_logprobs: int | None = None,
|
|
302
|
+
n: int | None = None,
|
|
303
|
+
user: str | None = None,
|
|
304
|
+
# Tool choice control (OpenAI API spec)
|
|
305
|
+
tool_choice: str | dict | None = None,
|
|
299
306
|
**kwargs,
|
|
300
307
|
) -> dict:
|
|
301
308
|
"""Generate a response using OpenAI Chat-Completions compatible API.
|
|
@@ -315,6 +322,10 @@ class OllamaBackend(BaseBackend):
|
|
|
315
322
|
frequency_penalty: Frequency penalty (-2.0 to 2.0)
|
|
316
323
|
response_format: Response format (e.g., {"type": "json_object"})
|
|
317
324
|
top_p: Top-p sampling (0.0 to 1.0)
|
|
325
|
+
logprobs: Whether to return log probabilities of output tokens
|
|
326
|
+
top_logprobs: Number of most likely tokens to return at each position (requires logprobs=True)
|
|
327
|
+
n: Number of completions to generate (default: 1)
|
|
328
|
+
user: Unique identifier representing the end-user for abuse monitoring
|
|
318
329
|
**kwargs: Additional options passed to Ollama
|
|
319
330
|
"""
|
|
320
331
|
import urllib.request
|
|
@@ -343,20 +354,34 @@ class OllamaBackend(BaseBackend):
|
|
|
343
354
|
body["response_format"] = response_format
|
|
344
355
|
if top_p is not None:
|
|
345
356
|
body["top_p"] = top_p
|
|
357
|
+
|
|
358
|
+
# Add OpenAI API spec completeness parameters (v1.0)
|
|
359
|
+
if logprobs is not None:
|
|
360
|
+
body["logprobs"] = logprobs
|
|
361
|
+
if top_logprobs is not None:
|
|
362
|
+
body["top_logprobs"] = top_logprobs
|
|
363
|
+
if n is not None:
|
|
364
|
+
body["n"] = n
|
|
365
|
+
if user is not None:
|
|
366
|
+
body["user"] = user
|
|
346
367
|
|
|
347
368
|
# Add tools in OpenAI format
|
|
348
369
|
if tools:
|
|
349
370
|
body["tools"] = [t.to_openai_schema() for t in tools]
|
|
371
|
+
|
|
372
|
+
# Add tool_choice parameter (OpenAI API spec)
|
|
373
|
+
# Supports: "auto", "none", "required", or {"type": "function", "function": {"name": "..."}}
|
|
374
|
+
if tool_choice is not None:
|
|
375
|
+
body["tool_choice"] = tool_choice
|
|
350
376
|
|
|
351
|
-
# Add think parameter for thinking models
|
|
377
|
+
# Add think parameter for thinking models (qwen3, deepseek-r1)
|
|
378
|
+
# Ollama's OpenAI-compatible endpoint supports this in the request body
|
|
352
379
|
if think is not None:
|
|
353
|
-
|
|
354
|
-
# This is Ollama-specific, might need to be in extra body
|
|
355
|
-
pass
|
|
380
|
+
body["think"] = think
|
|
356
381
|
|
|
357
382
|
# Debug output for request
|
|
358
383
|
if os.environ.get("AGENTNOVA_DEBUG"):
|
|
359
|
-
print(f" [OpenAI-Comp] Request: tools={len(tools) if tools else 0}")
|
|
384
|
+
print(f" [OpenAI-Comp] Request: tools={len(tools) if tools else 0}, think={think}")
|
|
360
385
|
|
|
361
386
|
# Make request
|
|
362
387
|
start_time = time.time()
|
|
@@ -414,48 +439,86 @@ class OllamaBackend(BaseBackend):
|
|
|
414
439
|
"raw": result,
|
|
415
440
|
}
|
|
416
441
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
442
|
+
# Helper function to parse a single choice
|
|
443
|
+
def _parse_choice(choice: dict) -> dict:
|
|
444
|
+
"""Parse a single choice from OpenAI format response."""
|
|
445
|
+
message = choice.get("message", {})
|
|
446
|
+
content = message.get("content", "")
|
|
447
|
+
tool_calls = message.get("tool_calls", [])
|
|
448
|
+
finish_reason = choice.get("finish_reason")
|
|
449
|
+
|
|
450
|
+
# Parse tool calls from OpenAI format
|
|
451
|
+
# OpenAI format: {"id": "...", "type": "function", "function": {"name": "...", "arguments": "{...}"}}
|
|
452
|
+
parsed_tool_calls = []
|
|
453
|
+
for tc in tool_calls:
|
|
454
|
+
func = tc.get("function", {})
|
|
455
|
+
args = func.get("arguments", "{}")
|
|
456
|
+
# OpenAI returns arguments as JSON string, parse it
|
|
457
|
+
if isinstance(args, str):
|
|
458
|
+
try:
|
|
459
|
+
args = json.loads(args)
|
|
460
|
+
except json.JSONDecodeError:
|
|
461
|
+
args = {}
|
|
462
|
+
parsed_tool_calls.append({
|
|
463
|
+
"id": tc.get("id", ""),
|
|
464
|
+
"name": func.get("name", ""),
|
|
465
|
+
"arguments": args,
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
# Extract logprobs if present
|
|
469
|
+
choice_logprobs = None
|
|
470
|
+
if logprobs:
|
|
471
|
+
choice_logprobs = choice.get("logprobs")
|
|
426
472
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
# OpenAI returns arguments as JSON string, parse it
|
|
434
|
-
if isinstance(args, str):
|
|
435
|
-
try:
|
|
436
|
-
args = json.loads(args)
|
|
437
|
-
except json.JSONDecodeError:
|
|
438
|
-
args = {}
|
|
439
|
-
parsed_tool_calls.append({
|
|
440
|
-
"id": tc.get("id", ""),
|
|
441
|
-
"name": func.get("name", ""),
|
|
442
|
-
"arguments": args,
|
|
443
|
-
})
|
|
473
|
+
return {
|
|
474
|
+
"content": content,
|
|
475
|
+
"tool_calls": parsed_tool_calls,
|
|
476
|
+
"finish_reason": finish_reason,
|
|
477
|
+
"logprobs": choice_logprobs,
|
|
478
|
+
}
|
|
444
479
|
|
|
445
480
|
usage = result.get("usage", {})
|
|
446
481
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
482
|
+
# OpenAI Chat Completions API v1.0: Handle multiple completions (n > 1)
|
|
483
|
+
# When n > 1, process ALL choices and return them under "choices" (plural) key
|
|
484
|
+
# Maintain backward compatibility by also including first choice under existing keys
|
|
485
|
+
num_choices = len(choices)
|
|
486
|
+
parsed_choices = [_parse_choice(choice) for choice in choices]
|
|
487
|
+
|
|
488
|
+
# First choice for backward-compatible keys
|
|
489
|
+
first_choice = parsed_choices[0]
|
|
490
|
+
content = first_choice["content"]
|
|
491
|
+
parsed_tool_calls = first_choice["tool_calls"]
|
|
492
|
+
finish_reason = first_choice["finish_reason"]
|
|
493
|
+
logprobs_result = first_choice["logprobs"]
|
|
494
|
+
|
|
495
|
+
# Debug output
|
|
496
|
+
if os.environ.get("AGENTNOVA_DEBUG"):
|
|
497
|
+
print(f" [OpenAI-Comp] Choices: {num_choices}")
|
|
498
|
+
print(f" [OpenAI-Comp] Content[0]: {content[:1024] if content else '(empty)'}")
|
|
499
|
+
print(f" [OpenAI-Comp] Tool calls[0]: {parsed_tool_calls}")
|
|
500
|
+
|
|
501
|
+
# Build response dict
|
|
502
|
+
response = {
|
|
503
|
+
"content": content, # Backward compatible: first choice content
|
|
504
|
+
"tool_calls": parsed_tool_calls, # Backward compatible: first choice tool calls
|
|
505
|
+
"finish_reason": finish_reason, # OpenAI API spec: "stop", "length", "tool_calls", "content_filter"
|
|
450
506
|
"usage": {
|
|
451
507
|
"prompt_tokens": usage.get("prompt_tokens", 0),
|
|
452
508
|
"completion_tokens": usage.get("completion_tokens", 0),
|
|
453
509
|
"total_tokens": usage.get("total_tokens", 0),
|
|
454
510
|
},
|
|
455
511
|
"latency_ms": latency_ms,
|
|
512
|
+
"logprobs": logprobs_result,
|
|
456
513
|
"raw": result,
|
|
457
514
|
}
|
|
458
515
|
|
|
516
|
+
# When n > 1, include all choices under "choices" key (OpenAI API spec compliance)
|
|
517
|
+
if num_choices > 1:
|
|
518
|
+
response["choices"] = parsed_choices
|
|
519
|
+
|
|
520
|
+
return response
|
|
521
|
+
|
|
459
522
|
def generate_completions_stream(
|
|
460
523
|
self,
|
|
461
524
|
model: str,
|
|
@@ -463,11 +526,17 @@ class OllamaBackend(BaseBackend):
|
|
|
463
526
|
tools: list[Tool] | None = None,
|
|
464
527
|
temperature: float = 0.7,
|
|
465
528
|
max_tokens: int = 2048,
|
|
529
|
+
think: bool | None = None,
|
|
466
530
|
stop: str | list[str] | None = None,
|
|
467
531
|
presence_penalty: float | None = None,
|
|
468
532
|
frequency_penalty: float | None = None,
|
|
469
533
|
response_format: dict | None = None,
|
|
470
534
|
top_p: float | None = None,
|
|
535
|
+
# OpenAI Chat Completions API spec completeness (v1.0)
|
|
536
|
+
logprobs: bool | None = None,
|
|
537
|
+
top_logprobs: int | None = None,
|
|
538
|
+
n: int | None = None,
|
|
539
|
+
user: str | None = None,
|
|
471
540
|
**kwargs,
|
|
472
541
|
) -> Generator[dict, None, None]:
|
|
473
542
|
"""Stream generated text using OpenAI Chat-Completions compatible API with SSE.
|
|
@@ -479,6 +548,7 @@ class OllamaBackend(BaseBackend):
|
|
|
479
548
|
- "delta": text delta from the model
|
|
480
549
|
- "finish_reason": None or "stop" when complete
|
|
481
550
|
- "tool_calls": incremental tool call data (if any)
|
|
551
|
+
- "logprobs": log probability data (when logprobs=True)
|
|
482
552
|
|
|
483
553
|
Args:
|
|
484
554
|
model: Model name
|
|
@@ -486,15 +556,20 @@ class OllamaBackend(BaseBackend):
|
|
|
486
556
|
tools: List of Tool objects for native tool calling
|
|
487
557
|
temperature: Sampling temperature
|
|
488
558
|
max_tokens: Maximum tokens to generate
|
|
559
|
+
think: For thinking models (qwen3, deepseek-r1): None=auto, False=disable thinking
|
|
489
560
|
stop: Stop sequences (string or list of strings)
|
|
490
561
|
presence_penalty: Presence penalty (-2.0 to 2.0)
|
|
491
562
|
frequency_penalty: Frequency penalty (-2.0 to 2.0)
|
|
492
563
|
response_format: Response format (e.g., {"type": "json_object"})
|
|
493
564
|
top_p: Top-p sampling (0.0 to 1.0)
|
|
565
|
+
logprobs: Whether to return log probabilities of output tokens
|
|
566
|
+
top_logprobs: Number of most likely tokens to return at each position
|
|
567
|
+
n: Number of completions to generate (default: 1)
|
|
568
|
+
user: Unique identifier representing the end-user for abuse monitoring
|
|
494
569
|
**kwargs: Additional options
|
|
495
570
|
|
|
496
571
|
Yields:
|
|
497
|
-
Dict with "delta", "finish_reason", and optionally "tool_calls"
|
|
572
|
+
Dict with "delta", "finish_reason", and optionally "tool_calls" and "logprobs"
|
|
498
573
|
"""
|
|
499
574
|
import urllib.request
|
|
500
575
|
import urllib.error
|
|
@@ -521,12 +596,26 @@ class OllamaBackend(BaseBackend):
|
|
|
521
596
|
body["response_format"] = response_format
|
|
522
597
|
if top_p is not None:
|
|
523
598
|
body["top_p"] = top_p
|
|
599
|
+
|
|
600
|
+
# Add OpenAI API spec completeness parameters (v1.0)
|
|
601
|
+
if logprobs is not None:
|
|
602
|
+
body["logprobs"] = logprobs
|
|
603
|
+
if top_logprobs is not None:
|
|
604
|
+
body["top_logprobs"] = top_logprobs
|
|
605
|
+
if n is not None:
|
|
606
|
+
body["n"] = n
|
|
607
|
+
if user is not None:
|
|
608
|
+
body["user"] = user
|
|
524
609
|
|
|
525
610
|
if tools:
|
|
526
611
|
body["tools"] = [t.to_openai_schema() for t in tools]
|
|
527
612
|
|
|
613
|
+
# Add think parameter for thinking models (qwen3, deepseek-r1)
|
|
614
|
+
if think is not None:
|
|
615
|
+
body["think"] = think
|
|
616
|
+
|
|
528
617
|
if os.environ.get("AGENTNOVA_DEBUG"):
|
|
529
|
-
print(f" [OpenAI-Comp-Stream] Request: tools={len(tools) if tools else 0}")
|
|
618
|
+
print(f" [OpenAI-Comp-Stream] Request: tools={len(tools) if tools else 0}, think={think}")
|
|
530
619
|
|
|
531
620
|
try:
|
|
532
621
|
req = urllib.request.Request(
|
|
@@ -538,6 +627,7 @@ class OllamaBackend(BaseBackend):
|
|
|
538
627
|
|
|
539
628
|
with urllib.request.urlopen(req, timeout=self.config.timeout) as response:
|
|
540
629
|
buffer = ""
|
|
630
|
+
last_finish_reason = None
|
|
541
631
|
for line in response:
|
|
542
632
|
line = line.decode("utf-8")
|
|
543
633
|
|
|
@@ -547,6 +637,13 @@ class OllamaBackend(BaseBackend):
|
|
|
547
637
|
|
|
548
638
|
# Check for end of stream
|
|
549
639
|
if data_str == "[DONE]":
|
|
640
|
+
# Yield final chunk with finish_reason if not already sent
|
|
641
|
+
if last_finish_reason is None:
|
|
642
|
+
yield {
|
|
643
|
+
"delta": "",
|
|
644
|
+
"finish_reason": "stop", # Default to "stop" when [DONE] received
|
|
645
|
+
"tool_calls": None,
|
|
646
|
+
}
|
|
550
647
|
break
|
|
551
648
|
|
|
552
649
|
try:
|
|
@@ -561,18 +658,33 @@ class OllamaBackend(BaseBackend):
|
|
|
561
658
|
delta = choices[0].get("delta", {})
|
|
562
659
|
finish_reason = choices[0].get("finish_reason")
|
|
563
660
|
|
|
661
|
+
# Track last finish_reason for final chunk
|
|
662
|
+
if finish_reason:
|
|
663
|
+
last_finish_reason = finish_reason
|
|
664
|
+
|
|
564
665
|
# Extract text content from delta
|
|
565
666
|
text_delta = delta.get("content", "")
|
|
566
667
|
|
|
567
668
|
# Extract tool calls if present
|
|
568
669
|
tool_calls_delta = delta.get("tool_calls", None)
|
|
569
670
|
|
|
570
|
-
|
|
671
|
+
# Extract logprobs if present (OpenAI API spec completeness)
|
|
672
|
+
# In streaming mode, logprobs are returned per-chunk in choices[0].logprobs
|
|
673
|
+
logprobs_data = choices[0].get("logprobs") if logprobs else None
|
|
674
|
+
|
|
675
|
+
# Build yield dict with required fields
|
|
676
|
+
yield_chunk = {
|
|
571
677
|
"delta": text_delta,
|
|
572
678
|
"finish_reason": finish_reason,
|
|
573
679
|
"tool_calls": tool_calls_delta,
|
|
574
680
|
}
|
|
575
681
|
|
|
682
|
+
# Include logprobs in yield when available
|
|
683
|
+
if logprobs_data is not None:
|
|
684
|
+
yield_chunk["logprobs"] = logprobs_data
|
|
685
|
+
|
|
686
|
+
yield yield_chunk
|
|
687
|
+
|
|
576
688
|
if finish_reason:
|
|
577
689
|
break
|
|
578
690
|
|
|
@@ -238,7 +238,7 @@ def print_banner() -> None:
|
|
|
238
238
|
# Convert 0.3.3 to R03.3 format for display
|
|
239
239
|
parts = __version__.split('.')
|
|
240
240
|
display_version = f"R{int(parts[1]):02d}.{parts[2]}" if len(parts) >= 2 else __version__
|
|
241
|
-
version_str = f"
|
|
241
|
+
version_str = f"{display_version} [{__status__}]"
|
|
242
242
|
if _COLOR_ENABLED:
|
|
243
243
|
# Replace ANSI-colored "Status: Alpha" with version
|
|
244
244
|
banner = BANNER_ATOM_BRAILLE.replace("\x1b[2mStatus:\x1b[0m \x1b[33mAlpha\x1b[0m", f"\x1b[2m{version_str}\x1b[0m")
|