agentnova 0.3.7__tar.gz → 0.3.9__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 (92) hide show
  1. {agentnova-0.3.7 → agentnova-0.3.9}/PKG-INFO +7 -8
  2. {agentnova-0.3.7 → agentnova-0.3.9}/README.md +7 -8
  3. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/__init__.py +1 -1
  4. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/acp_plugin.py +7 -3
  5. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/agent.py +260 -32
  6. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/backends/ollama.py +77 -40
  7. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/cli.py +107 -84
  8. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/colors.py +203 -203
  9. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/core/__init__.py +7 -7
  10. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/core/helpers.py +76 -23
  11. agentnova-0.3.9/agentnova/core/model_config.py +36 -0
  12. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/core/model_family_config.py +51 -2
  13. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/core/models.py +6 -1
  14. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/core/prompts.py +14 -1
  15. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/core/tool_cache.py +223 -188
  16. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/core/tool_parse.py +46 -43
  17. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/skills/loader.py +5 -2
  18. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/skills/skill-creator/SKILL.md +1 -0
  19. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/soul/loader.py +12 -4
  20. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/souls/nova-helper/SOUL.md +29 -1
  21. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/tools/builtins.py +1 -9
  22. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/tools/registry.py +1 -1
  23. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/tools/sandboxed_repl.py +4 -5
  24. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova.egg-info/PKG-INFO +7 -8
  25. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova.egg-info/SOURCES.txt +2 -0
  26. {agentnova-0.3.7 → agentnova-0.3.9}/pyproject.toml +1 -1
  27. agentnova-0.3.9/tests/test_builtins.py +349 -0
  28. agentnova-0.3.9/tests/test_security.py +406 -0
  29. agentnova-0.3.7/agentnova/core/model_config.py +0 -239
  30. {agentnova-0.3.7 → agentnova-0.3.9}/LICENSE +0 -0
  31. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/__main__.py +0 -0
  32. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/agent_mode.py +0 -0
  33. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/backends/__init__.py +0 -0
  34. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/backends/base.py +0 -0
  35. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/backends/bitnet.py +0 -0
  36. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/config.py +0 -0
  37. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/core/args_normal.py +0 -0
  38. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/core/error_recovery.py +0 -0
  39. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/core/math_prompts.py +0 -0
  40. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/core/memory.py +0 -0
  41. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/core/openresponses.py +0 -0
  42. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/core/types.py +0 -0
  43. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/examples/00_basic_agent.py +0 -0
  44. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/examples/01_quick_diagnostic.py +0 -0
  45. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/examples/02_tool_test.py +0 -0
  46. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/examples/03_reasoning_test.py +0 -0
  47. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/examples/04_gsm8k_benchmark.py +0 -0
  48. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/examples/05_common_sense.py +0 -0
  49. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/examples/06_causal_reasoning.py +0 -0
  50. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/examples/07_logical_deduction.py +0 -0
  51. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/examples/08_reading_comprehension.py +0 -0
  52. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/examples/09_general_knowledge.py +0 -0
  53. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/examples/10_implicit_reasoning.py +0 -0
  54. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/examples/11_analogical_reasoning.py +0 -0
  55. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/examples/__init__.py +0 -0
  56. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/model_discovery.py +0 -0
  57. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/orchestrator.py +0 -0
  58. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/py.typed +0 -0
  59. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/shared_args.py +0 -0
  60. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/skills/__init__.py +0 -0
  61. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/skills/acp/SKILL.md +0 -0
  62. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/skills/datetime/SKILL.md +0 -0
  63. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/skills/skill-creator/scripts/__init__.py +0 -0
  64. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/skills/skill-creator/scripts/aggregate_benchmark.py +0 -0
  65. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/skills/skill-creator/scripts/generate_report.py +0 -0
  66. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/skills/skill-creator/scripts/improve_description.py +0 -0
  67. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/skills/skill-creator/scripts/init_skill.py +0 -0
  68. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/skills/skill-creator/scripts/package_skill.py +0 -0
  69. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/skills/skill-creator/scripts/quick_validate.py +0 -0
  70. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/skills/skill-creator/scripts/run_eval.py +0 -0
  71. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/skills/skill-creator/scripts/run_loop.py +0 -0
  72. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/skills/skill-creator/scripts/security_scan.py +0 -0
  73. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/skills/skill-creator/scripts/test_package_skill.py +0 -0
  74. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/skills/skill-creator/scripts/test_quick_validate.py +0 -0
  75. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/skills/skill-creator/scripts/utils.py +0 -0
  76. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/skills/skill-creator/scripts/validate.py +0 -0
  77. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/skills/web-search/SKILL.md +0 -0
  78. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/soul/__init__.py +0 -0
  79. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/soul/types.py +0 -0
  80. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/souls/nova-helper/IDENTITY.md +0 -0
  81. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/souls/nova-helper/STYLE.md +0 -0
  82. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/souls/nova-helper/soul.json +0 -0
  83. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova/tools/__init__.py +0 -0
  84. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova.egg-info/dependency_links.txt +0 -0
  85. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova.egg-info/entry_points.txt +0 -0
  86. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova.egg-info/requires.txt +0 -0
  87. {agentnova-0.3.7 → agentnova-0.3.9}/agentnova.egg-info/top_level.txt +0 -0
  88. {agentnova-0.3.7 → agentnova-0.3.9}/localclaw/__init__.py +0 -0
  89. {agentnova-0.3.7 → agentnova-0.3.9}/localclaw-redirect/localclaw/__init__.py +0 -0
  90. {agentnova-0.3.7 → agentnova-0.3.9}/setup.cfg +0 -0
  91. {agentnova-0.3.7 → agentnova-0.3.9}/tests/test_agent.py +0 -0
  92. {agentnova-0.3.7 → agentnova-0.3.9}/tests/test_spec_compliance.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentnova
3
- Version: 0.3.7
3
+ Version: 0.3.9
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.7
35
+ # ⚛️ AgentNova R03.9
36
36
 
37
37
  **Status: Alpha**
38
38
 
@@ -49,7 +49,8 @@ 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="1378" height="996" alt="image" src="https://github.com/user-attachments/assets/0fe69695-73d9-4ce1-999f-08443f879971" />
52
+ <img width="1314" height="993" alt="image" src="https://github.com/user-attachments/assets/586b1a44-441e-41ae-85c2-b65b6a0816b9" />
53
+ <img width="1063" height="574" alt="image" src="https://github.com/user-attachments/assets/eab6f4ad-810c-4741-b637-e120f9ccb974" />
53
54
 
54
55
  ## 📚 Documentation
55
56
 
@@ -76,12 +77,10 @@ Inspired by the architecture of OpenClaw, rebuilt from scratch for local-first o
76
77
  ## Installation
77
78
 
78
79
  ```bash
79
- # From source
80
- git clone https://github.com/VTSTech/AgentNova.git
81
- cd AgentNova
82
- pip install -e .
80
+ # Latest Development Release
81
+ pip install git+https://github.com/VTSTech/AgentNova.git --force-reinstall
83
82
 
84
- # Or from PyPI (when published)
83
+ # Last Stable (as stable as Alpha can be) Release
85
84
  pip install agentnova
86
85
  ```
87
86
 
@@ -1,4 +1,4 @@
1
- # ⚛️ AgentNova R03.7
1
+ # ⚛️ AgentNova R03.9
2
2
 
3
3
  **Status: Alpha**
4
4
 
@@ -15,7 +15,8 @@ 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="1378" height="996" alt="image" src="https://github.com/user-attachments/assets/0fe69695-73d9-4ce1-999f-08443f879971" />
18
+ <img width="1314" height="993" alt="image" src="https://github.com/user-attachments/assets/586b1a44-441e-41ae-85c2-b65b6a0816b9" />
19
+ <img width="1063" height="574" alt="image" src="https://github.com/user-attachments/assets/eab6f4ad-810c-4741-b637-e120f9ccb974" />
19
20
 
20
21
  ## 📚 Documentation
21
22
 
@@ -42,12 +43,10 @@ Inspired by the architecture of OpenClaw, rebuilt from scratch for local-first o
42
43
  ## Installation
43
44
 
44
45
  ```bash
45
- # From source
46
- git clone https://github.com/VTSTech/AgentNova.git
47
- cd AgentNova
48
- pip install -e .
46
+ # Latest Development Release
47
+ pip install git+https://github.com/VTSTech/AgentNova.git --force-reinstall
49
48
 
50
- # Or from PyPI (when published)
49
+ # Last Stable (as stable as Alpha can be) Release
51
50
  pip install agentnova
52
51
  ```
53
52
 
@@ -349,4 +348,4 @@ Contributions welcome! Please read the contributing guidelines first.
349
348
 
350
349
  - Built for local inference with [Ollama](https://ollama.ai)
351
350
  - Optimized for small, efficient models
352
- - Inspired by ReAct and other agentic frameworks
351
+ - Inspired by ReAct and other agentic frameworks
@@ -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.7"
31
+ __version__ = "0.3.9"
32
32
  __author__ = "VTSTech"
33
33
  __status__ = "Alpha"
34
34
 
@@ -281,6 +281,10 @@ class ACPPlugin:
281
281
  "get_note": "READ",
282
282
  "list_notes": "READ",
283
283
  "list_directory": "READ",
284
+ # ACP v1.0.4: A2A agent-to-agent communication
285
+ "a2a_send": "A2A",
286
+ "a2a_request": "A2A",
287
+ "a2a_response": "A2A",
284
288
  }
285
289
 
286
290
  # v1.0.4: Auto-derive capabilities from tool action map if not explicitly provided
@@ -1089,11 +1093,11 @@ class ACPPlugin:
1089
1093
  """Format tool arguments as a target string for ACP."""
1090
1094
  # Tool-specific formatting
1091
1095
  if tool_name == "read_file":
1092
- return args.get("path", "unknown")
1096
+ return args.get("file_path", args.get("path", "unknown"))
1093
1097
  elif tool_name == "write_file":
1094
- return args.get("path", "unknown")
1098
+ return args.get("file_path", args.get("path", "unknown"))
1095
1099
  elif tool_name == "edit_file":
1096
- return args.get("path", "unknown")
1100
+ return args.get("file_path", args.get("path", "unknown"))
1097
1101
  elif tool_name == "shell":
1098
1102
  return args.get("command", "")[:100]
1099
1103
  elif tool_name == "calculator":
@@ -40,6 +40,7 @@ from .core.openresponses import (
40
40
  MessageItem, FunctionCallItem, FunctionCallOutputItem, ReasoningItem,
41
41
  OutputText, InputText,
42
42
  RequestConfig, Error,
43
+ EventType, ResponseEvent, OutputItemEvent,
43
44
  create_message_item, create_function_call_item, create_function_call_output,
44
45
  create_function_call_output_item,
45
46
  stream_response_events,
@@ -132,7 +133,7 @@ class Agent:
132
133
  system_prompt: Custom system prompt (overrides soul)
133
134
  soul: Path to Soul Spec package (default: "nova-helper")
134
135
  soul_level: Progressive disclosure level for soul (1-3)
135
- num_ctx: Context window size in tokens (default: 4096)
136
+ num_ctx: Context window size in tokens (default: 8192)
136
137
  temperature: Sampling temperature (default: model-specific)
137
138
  top_p: Nucleus sampling probability (default: model-specific)
138
139
  num_predict: Maximum tokens to generate (default: model-specific)
@@ -143,12 +144,12 @@ class Agent:
143
144
  self.model = model
144
145
  self.max_steps = max_steps
145
146
  self.debug = debug
146
- # Get num_ctx from: explicit param > config/env > default 4096
147
+ # Get num_ctx from: explicit param > config/env > default 8192
147
148
  if num_ctx is not None:
148
149
  self.num_ctx = num_ctx
149
150
  else:
150
151
  config = get_config()
151
- self.num_ctx = config.num_ctx if config.num_ctx else 4096
152
+ self.num_ctx = config.num_ctx if config.num_ctx else 8192
152
153
 
153
154
  # Generation parameters (use model defaults if not specified)
154
155
  self._temperature = temperature
@@ -225,7 +226,7 @@ class Agent:
225
226
  self.memory = Memory(memory_config or MemoryConfig())
226
227
 
227
228
  # Get model configuration (for temperature, max_tokens defaults)
228
- from .core.model_config import get_model_config
229
+ from .core.model_family_config import get_model_config
229
230
  self.model_config = get_model_config(model)
230
231
 
231
232
  # Detect model family (for backend-specific settings like think=False)
@@ -413,8 +414,11 @@ Final Answer: <the answer>
413
414
  # No synthesis or fallback mechanisms are used.
414
415
 
415
416
  # Track when we're expecting a Final Answer (after successful tool use)
417
+ # Only enforced for terminal tools (calculator, get_time, etc.) to avoid
418
+ # breaking multi-step workflows that need multiple tool calls.
416
419
  _expecting_final_answer = False
417
420
  _last_successful_result = None
421
+ _last_tool_name = None
418
422
 
419
423
  # Reset error tracker for new run
420
424
  self._error_tracker.reset()
@@ -441,6 +445,31 @@ Final Answer: <the answer>
441
445
  tokens = gen_response.get("usage", {}).get("total_tokens", 0)
442
446
  total_tokens += tokens
443
447
 
448
+ # OpenResponses: Handle finish_reason from backend
449
+ finish_reason = gen_response.get("_finish_reason", "stop")
450
+ if finish_reason == "length":
451
+ # Token budget exhausted — response is incomplete
452
+ if self.debug:
453
+ print(f" [OpenResponses] finish_reason='length' — marking incomplete")
454
+ steps.append(StepResult(
455
+ type=StepResultType.MAX_STEPS,
456
+ content="Response truncated: token limit reached",
457
+ tokens_used=tokens,
458
+ ))
459
+ response.mark_incomplete()
460
+ break
461
+ elif finish_reason == "content_filter":
462
+ # Content was filtered — response failed
463
+ if self.debug:
464
+ print(f" [OpenResponses] finish_reason='content_filter' — marking failed")
465
+ steps.append(StepResult(
466
+ type=StepResultType.ERROR,
467
+ error="Response blocked by content filter",
468
+ tokens_used=tokens,
469
+ ))
470
+ response.mark_failed({"message": "Content filtered by provider", "type": "content_filter"})
471
+ break
472
+
444
473
  if self.debug:
445
474
  print(f" Content: {content[:200] if content else '(empty)'}...")
446
475
  print(f" Native tool calls: {native_tool_calls}")
@@ -558,9 +587,6 @@ Final Answer: <the answer>
558
587
  self.memory.add("user", f"Observation: Error: {error_msg}")
559
588
  continue
560
589
 
561
- # Fuzzy match tool name
562
- tool_name = self._parser._fuzzy_match_tool(tool_name)
563
-
564
590
  # Create FunctionCallItem
565
591
  fc_item = create_function_call_item(tool_name, tool_args, tool_call_id)
566
592
  fc_item.status = ItemStatus.IN_PROGRESS
@@ -590,6 +616,9 @@ Final Answer: <the answer>
590
616
  fc_item.status = ItemStatus.FAILED
591
617
  response.mark_failed({"message": "Too many tool failures", "type": "error_recovery"})
592
618
  break
619
+ else:
620
+ # Record success to reset consecutive failure counter
621
+ self._error_tracker.record_success(tool_name)
593
622
 
594
623
  # Update FunctionCallItem status
595
624
  fc_item.status = ItemStatus.COMPLETED
@@ -622,15 +651,25 @@ Final Answer: <the answer>
622
651
  )
623
652
 
624
653
  # Update expecting_final_answer flag
654
+ # Only enforce for terminal tools (simple, direct-answer tools)
655
+ # to avoid breaking multi-step workflows
625
656
  if is_error:
626
657
  _expecting_final_answer = False
658
+ _last_tool_name = None
627
659
  else:
628
- _expecting_final_answer = True
629
- _last_successful_result = str(result)
660
+ from .core.error_recovery import _is_simple_result
661
+ if _is_simple_result(str(result), tool_name):
662
+ _expecting_final_answer = True
663
+ _last_successful_result = str(result)
664
+ _last_tool_name = tool_name
665
+ else:
666
+ # Complex/intermediate result — allow more tool calls
667
+ _expecting_final_answer = False
668
+ _last_tool_name = None
630
669
 
631
670
  self.memory.add("user", observation_msg)
632
671
 
633
- if not str(result).startswith("Error"):
672
+ if not is_error:
634
673
  successful_results.append(f"{tool_name}: {result}")
635
674
 
636
675
  steps.append(StepResult(
@@ -873,6 +912,10 @@ Final Answer: <the answer>
873
912
  OpenResponses specification. It yields Server-Sent Events (SSE) that
874
913
  describe the response lifecycle and content deltas.
875
914
 
915
+ IMPORTANT: The agentic loop is fully supported during streaming.
916
+ When the model produces a tool call, it is executed and the loop
917
+ continues, streaming the next model response.
918
+
876
919
  SSE Event Sequence (per OpenResponses spec):
877
920
  1. response.queued - Response is queued
878
921
  2. response.in_progress - Response started
@@ -895,6 +938,8 @@ Final Answer: <the answer>
895
938
  for sse_event in agent.run_stream("Hello!"):
896
939
  print(sse_event) # SSE formatted event
897
940
  """
941
+ start_time = time.time()
942
+
898
943
  # Create OpenResponses Response object
899
944
  response = Response(
900
945
  model=self.model,
@@ -903,9 +948,8 @@ Final Answer: <the answer>
903
948
  allowed_tools=self._allowed_tools or [],
904
949
  )
905
950
 
906
- if self.debug and not self._is_comp_mode:
907
- print(f"\n[OpenResponses] Response created: id={response.id}")
908
- print(f"[OpenResponses] Response status: {response.status.value}")
951
+ if self.debug:
952
+ print(f"\n[OpenResponses stream] Response created: id={response.id}")
909
953
 
910
954
  # Add user prompt to memory
911
955
  self.memory.add("user", prompt)
@@ -914,22 +958,195 @@ Final Answer: <the answer>
914
958
  user_item = create_message_item("user", prompt)
915
959
  response.input.append(user_item)
916
960
 
917
- if self.debug and not self._is_comp_mode:
918
- print(f"[OpenResponses] Input item added: id={user_item.id}, type={user_item.type}, role={user_item.role}")
919
-
920
961
  if self.debug:
921
- print(f"\n[AgentNova] Model: {self.model}")
922
- print(f"[AgentNova] Backend: {self.backend.base_url}")
923
- print(f"[AgentNova] tool_choice: {self.tool_choice.type.value}")
924
- print(f"[AgentNova] Tools: {self.tools.names()}")
925
- print(f"[AgentNova] Prompt (streaming): {prompt}\n")
962
+ print(f"\n[AgentNova stream] Model: {self.model}")
963
+ print(f"[AgentNova stream] Backend: {self.backend.base_url}")
964
+ print(f"[AgentNova stream] tool_choice: {self.tool_choice.type.value}")
965
+ print(f"[AgentNova stream] Tools: {self.tools.names()}")
966
+ print(f"[AgentNova stream] Prompt: {prompt}\n")
967
+
968
+ # OpenResponses: Agentic Loop (streaming variant)
969
+ # Stream model output, check for tool calls, execute them, repeat.
970
+ _expecting_final_answer = False
971
+ _last_successful_result = None
972
+ _last_tool_name = None
973
+ tool_call_count = 0
974
+
975
+ for step_num in range(self.max_steps):
976
+ if self.debug:
977
+ print(f"[Stream Step {step_num + 1}]")
978
+
979
+ # Collect the full streamed response
980
+ full_content = ""
981
+
982
+ # Stream model response, collecting content for tool-call detection
983
+ try:
984
+ for chunk in self._generate_stream_chunks(prompt):
985
+ full_content += chunk
986
+ except Exception as e:
987
+ if self.debug:
988
+ print(f" [Stream] ERROR: {e}")
989
+ # Emit failure event
990
+ response.mark_failed({"message": str(e), "type": "stream_error"})
991
+ fail_event = ResponseEvent(
992
+ type=EventType.RESPONSE_FAILED,
993
+ response=response,
994
+ )
995
+ yield fail_event.to_sse()
996
+ return
997
+
998
+ # Parse for tool calls (ReAct format)
999
+ tool_calls_found = []
1000
+
1001
+ if full_content:
1002
+ parsed_calls = self._parser.parse(full_content)
1003
+ for call in parsed_calls:
1004
+ if hasattr(call, 'thought') and call.thought:
1005
+ reasoning_item = ReasoningItem(
1006
+ content=[OutputText(text=call.thought)]
1007
+ )
1008
+ reasoning_item.status = ItemStatus.COMPLETED
1009
+ response.add_output_item(reasoning_item)
1010
+
1011
+ tool_calls_found.append({
1012
+ "name": call.name,
1013
+ "arguments": call.arguments,
1014
+ "id": "",
1015
+ "final_answer": getattr(call, 'final_answer', None),
1016
+ })
926
1017
 
927
- # Get text chunks from backend streaming
928
- text_chunks = self._generate_stream_chunks(prompt)
1018
+ # ---- Execute tool calls if found ----
1019
+ if tool_calls_found:
1020
+ # Final Answer enforcement (same logic as run())
1021
+ if _expecting_final_answer and _last_successful_result is not None:
1022
+ text_chunks_gen = iter([_last_successful_result])
1023
+ for sse_event in stream_response_events(
1024
+ Response(model=self.model, status=ResponseStatus.IN_PROGRESS,
1025
+ tool_choice=self.tool_choice, allowed_tools=self._allowed_tools or []),
1026
+ text_chunks_gen, debug=self.debug,
1027
+ ):
1028
+ yield sse_event
1029
+ return
1030
+
1031
+ pending_final_answer = None
1032
+ self.memory.add("assistant", full_content)
1033
+
1034
+ for tc in tool_calls_found:
1035
+ tool_name = tc["name"]
1036
+ tool_args = tc["arguments"]
1037
+
1038
+ if tc.get("final_answer"):
1039
+ pending_final_answer = tc["final_answer"]
1040
+
1041
+ # Check allowed_tools
1042
+ if self._allowed_tools and tool_name not in self._allowed_tools:
1043
+ error_msg = f"Tool '{tool_name}' not in allowed_tools: {self._allowed_tools}"
1044
+ self.memory.add("user", f"Observation: Error: {error_msg}")
1045
+ continue
1046
+
1047
+ # Create FunctionCallItem and emit SSE events
1048
+ fc_item = create_function_call_item(tool_name, tool_args)
1049
+ fc_item.status = ItemStatus.IN_PROGRESS
1050
+ response.add_output_item(fc_item)
1051
+ output_index = len(response.output) - 1
1052
+
1053
+ fc_added = OutputItemEvent(
1054
+ type=EventType.OUTPUT_ITEM_ADDED,
1055
+ item=fc_item,
1056
+ output_index=output_index,
1057
+ )
1058
+ yield fc_added.to_sse()
1059
+
1060
+ # Execute the tool
1061
+ result = self._execute_tool(tool_name, tool_args, prompt)
1062
+ tool_call_count += 1
1063
+
1064
+ fc_item.status = ItemStatus.COMPLETED
1065
+
1066
+ fc_done = OutputItemEvent(
1067
+ type=EventType.OUTPUT_ITEM_DONE,
1068
+ item=fc_item,
1069
+ output_index=output_index,
1070
+ )
1071
+ yield fc_done.to_sse()
1072
+
1073
+ # Create function_call_output
1074
+ fco_item = create_function_call_output(fc_item.call_id, str(result))
1075
+ response.add_output_item(fco_item)
1076
+
1077
+ # Build observation and add to memory
1078
+ is_error = is_error_result(str(result))
1079
+ observation_msg = build_enhanced_observation(
1080
+ tool_name=tool_name,
1081
+ result=str(result),
1082
+ tracker=self._error_tracker,
1083
+ available_tools=self.tools.names(),
1084
+ is_error=is_error,
1085
+ )
1086
+
1087
+ if is_error:
1088
+ _expecting_final_answer = False
1089
+ _last_tool_name = None
1090
+ else:
1091
+ from .core.error_recovery import _is_simple_result
1092
+ if _is_simple_result(str(result), tool_name):
1093
+ _expecting_final_answer = True
1094
+ _last_successful_result = str(result)
1095
+ _last_tool_name = tool_name
1096
+ else:
1097
+ _expecting_final_answer = False
1098
+ _last_tool_name = None
1099
+
1100
+ self.memory.add("user", observation_msg)
1101
+
1102
+ # Check for pending final answer
1103
+ if pending_final_answer:
1104
+ text_chunks_gen = iter([pending_final_answer])
1105
+ for sse_event in stream_response_events(
1106
+ Response(model=self.model, status=ResponseStatus.IN_PROGRESS,
1107
+ tool_choice=self.tool_choice, allowed_tools=self._allowed_tools or []),
1108
+ text_chunks_gen, debug=self.debug,
1109
+ ):
1110
+ yield sse_event
1111
+ return
1112
+
1113
+ # Continue the agentic loop (next streaming iteration)
1114
+ continue
1115
+
1116
+ # ---- No tool calls — stream final response ----
1117
+ # Check for Final Answer format
1118
+ if self._parser.is_final_answer(full_content):
1119
+ answer = self._parser.extract_final_answer(full_content)
1120
+ text_chunks_gen = iter([answer])
1121
+ else:
1122
+ text_chunks_gen = iter([full_content])
1123
+
1124
+ # Stream the final response with proper OpenResponses events
1125
+ final_response = Response(
1126
+ model=self.model,
1127
+ status=ResponseStatus.IN_PROGRESS,
1128
+ tool_choice=self.tool_choice,
1129
+ allowed_tools=self._allowed_tools or [],
1130
+ )
1131
+ # Carry over any items from previous loop iterations
1132
+ final_response.output = response.output
1133
+ final_response.input = response.input
1134
+ final_response.usage = response.usage
929
1135
 
930
- # Wrap with OpenResponses SSE events using stream_response_events()
931
- for sse_event in stream_response_events(response, text_chunks, debug=self.debug):
932
- yield sse_event
1136
+ for sse_event in stream_response_events(final_response, text_chunks_gen, debug=self.debug):
1137
+ yield sse_event
1138
+
1139
+ # Only one pass needed when there are no tool calls
1140
+ return
1141
+
1142
+ else:
1143
+ # Max steps reached
1144
+ response.mark_incomplete()
1145
+ incomplete_event = ResponseEvent(
1146
+ type=EventType.RESPONSE_INCOMPLETE,
1147
+ response=response,
1148
+ )
1149
+ yield incomplete_event.to_sse()
933
1150
 
934
1151
  def _generate_stream_chunks(self, prompt: str) -> Generator[str, None, None]:
935
1152
  """
@@ -1076,6 +1293,11 @@ Final Answer: <the answer>
1076
1293
  if self._num_predict is not None:
1077
1294
  backend_kwargs["num_predict"] = self._num_predict
1078
1295
 
1296
+ # OpenResponses: Forward tool_choice to backend API
1297
+ # This allows the backend to enforce tool invocation constraints natively
1298
+ if self.tool_choice and self.tool_choice.type != ToolChoiceType.AUTO:
1299
+ backend_kwargs["tool_choice"] = self.tool_choice.to_dict()
1300
+
1079
1301
  # Pass tools for native tool calling (OpenResponses/ChatCompletions compliant)
1080
1302
  # ReAct parsing remains as fallback for models without native support
1081
1303
  tools_for_backend = self.tools.all() if self.tools and len(self.tools) > 0 else None
@@ -1101,6 +1323,17 @@ Final Answer: <the answer>
1101
1323
  **backend_kwargs,
1102
1324
  )
1103
1325
 
1326
+ # OpenResponses / Chat Completions: Handle finish_reason
1327
+ # Per spec, finish_reason affects response status:
1328
+ # "stop" → normal completion (default)
1329
+ # "length" → incomplete — token budget exhausted
1330
+ # "content_filter" → failed — content was filtered
1331
+ finish_reason = response.get("finish_reason", "stop")
1332
+ if self.debug:
1333
+ print(f" [DEBUG] finish_reason: {finish_reason}")
1334
+ # Store for caller to consume
1335
+ response["_finish_reason"] = finish_reason
1336
+
1104
1337
  if self.debug:
1105
1338
  print(f" [DEBUG] Response keys: {list(response.keys())}")
1106
1339
  print(f" [DEBUG] Content: {response.get('content', '')[:100]}...")
@@ -1115,12 +1348,7 @@ Final Answer: <the answer>
1115
1348
  OpenResponses: Tool execution is straightforward - no synthesis.
1116
1349
  Arguments must come from the model.
1117
1350
  """
1118
- from .core.tool_parse import _fuzzy_match_tool_name
1119
- matched_name = _fuzzy_match_tool_name(name, self.tools.names())
1120
- if matched_name:
1121
- name = matched_name
1122
-
1123
- tool = self.tools.get_fuzzy(name)
1351
+ tool = self.tools.get(name)
1124
1352
 
1125
1353
  if tool is None:
1126
1354
  return f"Error: Unknown tool '{name}'. Available tools: {self.tools.names()}"