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.
Files changed (91) hide show
  1. {agentnova-0.3.4 → agentnova-0.3.5}/PKG-INFO +4 -3
  2. {agentnova-0.3.4 → agentnova-0.3.5}/README.md +3 -2
  3. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/__init__.py +1 -1
  4. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/acp_plugin.py +86 -0
  5. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/agent.py +138 -0
  6. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/backends/ollama.py +149 -37
  7. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/cli.py +1 -1
  8. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/core/model_family_config.py +43 -1
  9. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/core/openresponses.py +248 -0
  10. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/core/tool_parse.py +16 -13
  11. agentnova-0.3.5/agentnova/skills/loader.py +734 -0
  12. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/soul/loader.py +38 -3
  13. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/soul/types.py +26 -3
  14. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/souls/nova-helper/SOUL.md +42 -5
  15. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova.egg-info/PKG-INFO +4 -3
  16. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova.egg-info/SOURCES.txt +2 -3
  17. {agentnova-0.3.4 → agentnova-0.3.5}/pyproject.toml +1 -1
  18. {agentnova-0.3.4 → agentnova-0.3.5}/tests/test_agent.py +19 -9
  19. agentnova-0.3.5/tests/test_spec_compliance.py +383 -0
  20. agentnova-0.3.4/agentnova/skills/loader.py +0 -1893
  21. agentnova-0.3.4/tests/test_acp_integration.py +0 -223
  22. agentnova-0.3.4/tests/test_acp_subagents.py +0 -296
  23. {agentnova-0.3.4 → agentnova-0.3.5}/LICENSE +0 -0
  24. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/__main__.py +0 -0
  25. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/agent_mode.py +0 -0
  26. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/backends/__init__.py +0 -0
  27. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/backends/base.py +0 -0
  28. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/backends/bitnet.py +0 -0
  29. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/config.py +0 -0
  30. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/core/__init__.py +0 -0
  31. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/core/args_normal.py +0 -0
  32. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/core/error_recovery.py +0 -0
  33. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/core/helpers.py +0 -0
  34. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/core/math_prompts.py +0 -0
  35. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/core/memory.py +0 -0
  36. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/core/model_config.py +0 -0
  37. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/core/models.py +0 -0
  38. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/core/prompts.py +0 -0
  39. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/core/types.py +0 -0
  40. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/examples/00_basic_agent.py +0 -0
  41. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/examples/01_quick_diagnostic.py +0 -0
  42. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/examples/02_tool_test.py +0 -0
  43. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/examples/03_reasoning_test.py +0 -0
  44. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/examples/04_gsm8k_benchmark.py +0 -0
  45. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/examples/05_common_sense.py +0 -0
  46. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/examples/06_causal_reasoning.py +0 -0
  47. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/examples/07_logical_deduction.py +0 -0
  48. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/examples/08_reading_comprehension.py +0 -0
  49. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/examples/09_general_knowledge.py +0 -0
  50. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/examples/10_implicit_reasoning.py +0 -0
  51. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/examples/11_analogical_reasoning.py +0 -0
  52. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/examples/__init__.py +0 -0
  53. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/model_discovery.py +0 -0
  54. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/orchestrator.py +0 -0
  55. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/orchestrator_enhanced.py +0 -0
  56. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/py.typed +0 -0
  57. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/shared_args.py +0 -0
  58. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/__init__.py +0 -0
  59. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/acp/SKILL.md +0 -0
  60. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/datetime/SKILL.md +0 -0
  61. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/SKILL.md +0 -0
  62. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/scripts/__init__.py +0 -0
  63. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/scripts/aggregate_benchmark.py +0 -0
  64. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/scripts/generate_report.py +0 -0
  65. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/scripts/improve_description.py +0 -0
  66. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/scripts/init_skill.py +0 -0
  67. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/scripts/package_skill.py +0 -0
  68. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/scripts/quick_validate.py +0 -0
  69. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/scripts/run_eval.py +0 -0
  70. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/scripts/run_loop.py +0 -0
  71. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/scripts/security_scan.py +0 -0
  72. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/scripts/test_package_skill.py +0 -0
  73. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/scripts/test_quick_validate.py +0 -0
  74. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/scripts/utils.py +0 -0
  75. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/skill-creator/scripts/validate.py +0 -0
  76. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/skills/web-search/SKILL.md +0 -0
  77. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/soul/__init__.py +0 -0
  78. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/souls/nova-helper/IDENTITY.md +0 -0
  79. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/souls/nova-helper/STYLE.md +0 -0
  80. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/souls/nova-helper/soul.json +0 -0
  81. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/tools/__init__.py +0 -0
  82. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/tools/builtins.py +0 -0
  83. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/tools/registry.py +0 -0
  84. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova/tools/sandboxed_repl.py +0 -0
  85. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova.egg-info/dependency_links.txt +0 -0
  86. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova.egg-info/entry_points.txt +0 -0
  87. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova.egg-info/requires.txt +0 -0
  88. {agentnova-0.3.4 → agentnova-0.3.5}/agentnova.egg-info/top_level.txt +0 -0
  89. {agentnova-0.3.4 → agentnova-0.3.5}/localclaw/__init__.py +0 -0
  90. {agentnova-0.3.4 → agentnova-0.3.5}/localclaw-redirect/localclaw/__init__.py +0 -0
  91. {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.4
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.3
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://img.shields.io/badge/License-MIT-blue)](#license) [![Go to Python website](https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2FVTSTech%2FAgentNova%2Frefs%2Fheads%2Fmain%2Fpyproject.toml&query=project.requires-python&label=python&logo=python&logoColor=white)](https://python.org)
51
51
 
52
- <img width="1400" height="1001" alt="image" src="https://github.com/user-attachments/assets/1bc8fb7a-415b-4750-9ce2-1e721bbf9149" />
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.3
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://img.shields.io/badge/License-MIT-blue)](#license) [![Go to Python website](https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2FVTSTech%2FAgentNova%2Frefs%2Fheads%2Fmain%2Fpyproject.toml&query=project.requires-python&label=python&logo=python&logoColor=white)](https://python.org)
17
17
 
18
- <img width="1400" height="1001" alt="image" src="https://github.com/user-attachments/assets/1bc8fb7a-415b-4750-9ce2-1e721bbf9149" />
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
 
@@ -28,7 +28,7 @@ Example Usage:
28
28
  agent = Agent(model="qwen2.5:0.5b", soul="/path/to/soul/package")
29
29
  """
30
30
 
31
- __version__ = "0.3.4"
31
+ __version__ = "0.3.5"
32
32
  __author__ = "VTSTech"
33
33
  __status__ = "Alpha"
34
34
 
@@ -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
- # Note: OpenAI-compatible endpoint may not support 'think' natively
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
- choice = choices[0]
418
- message = choice.get("message", {})
419
- content = message.get("content", "")
420
- tool_calls = message.get("tool_calls", [])
421
-
422
- # Debug output
423
- if os.environ.get("AGENTNOVA_DEBUG"):
424
- print(f" [OpenAI-Comp] Content: {content[:1024] if content else '(empty)'}")
425
- print(f" [OpenAI-Comp] Tool calls: {tool_calls}")
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
- # Parse tool calls from OpenAI format
428
- # OpenAI format: {"id": "...", "type": "function", "function": {"name": "...", "arguments": "{...}"}}
429
- parsed_tool_calls = []
430
- for tc in tool_calls:
431
- func = tc.get("function", {})
432
- args = func.get("arguments", "{}")
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
- return {
448
- "content": content,
449
- "tool_calls": parsed_tool_calls,
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
- yield {
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"v{display_version} [{__status__}]"
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")