versionhq 1.2.4.1__py3-none-any.whl → 1.2.4.3__py3-none-any.whl

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.
versionhq/task/model.py CHANGED
@@ -4,7 +4,6 @@ import datetime
4
4
  import uuid
5
5
  import inspect
6
6
  import enum
7
- from textwrap import dedent
8
7
  from concurrent.futures import Future
9
8
  from hashlib import md5
10
9
  from typing import Any, Dict, List, Set, Optional, Callable, Type
@@ -16,7 +15,7 @@ from pydantic_core import PydanticCustomError
16
15
  import versionhq as vhq
17
16
  from versionhq.task.evaluation import Evaluation, EvaluationItem
18
17
  from versionhq.tool.model import Tool, ToolSet
19
- from versionhq._utils import process_config, Logger, is_valid_url
18
+ from versionhq._utils import process_config, Logger, UsageMetrics, ErrorType
20
19
 
21
20
 
22
21
  class TaskExecutionType(enum.Enum):
@@ -175,7 +174,6 @@ class TaskOutput(BaseModel):
175
174
  """
176
175
  A class to store the final output of the given task in raw (string), json_dict, and pydantic class formats.
177
176
  """
178
- _tokens: int = PrivateAttr(default=0)
179
177
 
180
178
  task_id: UUID4 = Field(default_factory=uuid.uuid4, frozen=True, description="store Task ID")
181
179
  raw: str = Field(default="", description="Raw output of the task")
@@ -183,23 +181,38 @@ class TaskOutput(BaseModel):
183
181
  pydantic: Optional[Any] = Field(default=None)
184
182
  tool_output: Optional[Any] = Field(default=None, description="stores tool result when the task takes tool output as its final output")
185
183
  callback_output: Optional[Any] = Field(default=None, description="stores task or agent callback outcome")
186
- latency: float = Field(default=None, description="job latency in ms")
187
184
  evaluation: Optional[InstanceOf[Evaluation]] = Field(default=None, description="stores overall evaluation of the task output. stored in ltm")
188
185
 
189
186
 
187
+ def _fetch_value_of(self, key: str = None) -> Any:
188
+ """Returns a value to the given key."""
189
+
190
+ if not key:
191
+ return None
192
+
193
+ if self.pydantic and hasattr(self.pydantic, key):
194
+ return getattr(self.pydantic, key)
195
+
196
+ elif self.json_dict and key in self.json_dict:
197
+ return self.json_dict[key]
198
+
199
+ else:
200
+ return None
201
+
202
+
190
203
  def _to_context_prompt(self) -> str:
191
- """
192
- Returns response in string as a prompt context.
193
- """
204
+ """Formats prompt context in text formats from the final response."""
205
+
194
206
  context = ""
195
- try:
196
- context = json.dumps(self.json_dict)
197
- except:
198
- try:
199
- if self.pydantic:
200
- context = self.pydantic.model_dump()
201
- except:
202
- context = self.raw
207
+ match self.final:
208
+ case dict() | self.pydantic:
209
+ try:
210
+ context = json.dumps(self.final)
211
+ except:
212
+ context = str(self.final)
213
+ case _:
214
+ context = str(self.final)
215
+
203
216
  return context
204
217
 
205
218
 
@@ -225,7 +238,6 @@ class TaskOutput(BaseModel):
225
238
 
226
239
  task_eval = Task(description=description, pydantic_output=EvaluationItem)
227
240
  res = task_eval.execute(agent=self.evaluation.eval_by)
228
- self._tokens += task_eval._tokens
229
241
 
230
242
  if res.pydantic:
231
243
  item = EvaluationItem(
@@ -252,6 +264,24 @@ class TaskOutput(BaseModel):
252
264
  return self.evaluation
253
265
 
254
266
 
267
+ @property
268
+ def final(self) -> Any:
269
+ """Returns final output from the task."""
270
+
271
+ output = None
272
+
273
+ if self.callback_output:
274
+ output = self.callback_output
275
+
276
+ elif self.tool_output and str(self.tool_output) == self.raw: # tool_output_as_final
277
+ output = self.tool_output
278
+
279
+ else:
280
+ output = self.pydantic if self.pydantic else self.json_dict if self.json_dict else self.raw
281
+
282
+ return output
283
+
284
+
255
285
  @property
256
286
  def aggregate_score(self) -> float | int:
257
287
  return self.evaluation.aggregate_score if self.evaluation is not None else 0
@@ -280,6 +310,7 @@ class Task(BaseModel):
280
310
  description: str = Field(description="Description of the actual task")
281
311
 
282
312
  # response format
313
+ response_schema: Optional[Type[BaseModel] | List[ResponseField]] = Field(default=None)
283
314
  pydantic_output: Optional[Type[BaseModel]] = Field(default=None, description="store Pydantic class as structured response format")
284
315
  response_fields: Optional[List[ResponseField]] = Field(default_factory=list, description="store list of ResponseField as structured response format")
285
316
 
@@ -292,6 +323,11 @@ class Task(BaseModel):
292
323
  file: Optional[str] = Field(default=None, description="absolute file path or url in string")
293
324
  audio: Optional[str] = Field(default=None, description="absolute file path or url in string")
294
325
 
326
+ # test run
327
+ should_test_run: bool = Field(default=False)
328
+ human: bool = Field(default=False)
329
+ _pfg: Any = None
330
+
295
331
  # executing
296
332
  execution_type: TaskExecutionType = Field(default=TaskExecutionType.SYNC)
297
333
  allow_delegation: bool = Field(default=False, description="whether to delegate the task to another agent")
@@ -304,9 +340,7 @@ class Task(BaseModel):
304
340
  fsls: Optional[list[str]] = Field(default=None, description="stores ideal/weak responses")
305
341
 
306
342
  # recording
307
- _tokens: int = 0
308
- _tool_errors: int = 0
309
- _format_errors: int = 0
343
+ _usage: UsageMetrics = PrivateAttr(default=None)
310
344
  _delegations: int = 0
311
345
  processed_agents: Set[str] = Field(default_factory=set, description="store keys of the agents that executed the task")
312
346
  output: Optional[TaskOutput] = Field(default=None, description="store the final TaskOutput object")
@@ -331,6 +365,8 @@ class Task(BaseModel):
331
365
  for field in required_fields:
332
366
  if getattr(self, field) is None:
333
367
  raise ValueError( f"{field} must be provided either directly or through config")
368
+
369
+ self._usage = UsageMetrics(id=self.id)
334
370
  return self
335
371
 
336
372
 
@@ -351,121 +387,6 @@ class Task(BaseModel):
351
387
  return self
352
388
 
353
389
 
354
- def _draft_output_prompt(self, model_provider: str = None) -> str:
355
- output_prompt = ""
356
-
357
- if self.pydantic_output:
358
- output_prompt, output_formats_to_follow = "", dict()
359
- response_format = str(self._structure_response_format(model_provider=model_provider))
360
- for k, v in self.pydantic_output.model_fields.items():
361
- output_formats_to_follow[k] = f"<Return your answer in {v.annotation}>"
362
-
363
- output_prompt = f"""Your response MUST be a valid JSON string that strictly follows the response format. Use double quotes for all keys and string values. Do not use single quotes, trailing commas, or any other non-standard JSON syntax.
364
- Response format: {response_format}
365
- Ref. Output image: {output_formats_to_follow}
366
- """
367
- elif self.response_fields:
368
- output_prompt, output_formats_to_follow = "", dict()
369
- response_format = str(self._structure_response_format(model_provider=model_provider))
370
- for item in self.response_fields:
371
- if item:
372
- output_formats_to_follow[item.title] = f"<Return your answer in {item.data_type.__name__}>"
373
-
374
- output_prompt = f"""Your response MUST be a valid JSON string that strictly follows the response format. Use double quotes for all keys and string values. Do not use single quotes, trailing commas, or any other non-standard JSON syntax.
375
- Response format: {response_format}
376
- Ref. Output image: {output_formats_to_follow}
377
- """
378
- # elif not self.tools or self.can_use_agent_tools == False:
379
- else:
380
- output_prompt = "You MUST return your response as a valid JSON serializable string, enclosed in double quotes. Use double quotes for all keys and string values. Do NOT use single quotes, trailing commas, or other non-standard JSON syntax."
381
-
382
- # else:
383
- # output_prompt = "You will return a response in a concise manner."
384
-
385
- return dedent(output_prompt)
386
-
387
-
388
- def _draft_context_prompt(self, context: Any) -> str:
389
- """
390
- Create a context prompt from the given context in any format: a task object, task output object, list, dict.
391
- """
392
-
393
- context_to_add = None
394
- if not context:
395
- # Logger().log(level="error", color="red", message="Missing a context to add to the prompt. We'll return ''.")
396
- return context_to_add
397
-
398
- match context:
399
- case str():
400
- context_to_add = context
401
-
402
- case Task():
403
- if not context.output:
404
- res = context.execute()
405
- context_to_add = res._to_context_prompt()
406
-
407
- else:
408
- context_to_add = context.output.raw
409
-
410
- case TaskOutput():
411
- context_to_add = context._to_context_prompt()
412
-
413
-
414
- case dict():
415
- context_to_add = str(context)
416
-
417
- case list():
418
- res = ", ".join([self._draft_context_prompt(context=item) for item in context])
419
- context_to_add = res
420
-
421
- case _:
422
- pass
423
-
424
- return dedent(context_to_add)
425
-
426
-
427
- def _user_prompt(self, model_provider: str = None, context: Optional[Any] = None) -> str:
428
- """
429
- Format the task prompt and cascade it to the agent.
430
- """
431
- output_prompt = self._draft_output_prompt(model_provider=model_provider)
432
- task_slices = [self.description, output_prompt, ]
433
-
434
- if context:
435
- context_prompt = self._draft_context_prompt(context=context)
436
- task_slices.insert(len(task_slices), f"Consider the following context when responding: {context_prompt}")
437
-
438
- return "\n".join(task_slices)
439
-
440
-
441
- def _format_content_prompt(self) -> Dict[str, str]:
442
- """Formats content (file, image, audio) prompts that added to the messages sent to the LLM."""
443
-
444
- from pathlib import Path
445
- import base64
446
-
447
- content_messages = {}
448
-
449
- if self.image:
450
- with open(self.image, "rb") as file:
451
- content = file.read()
452
- if content:
453
- encoded_file = base64.b64encode(content).decode("utf-8")
454
- img_url = f"data:image/jpeg;base64,{encoded_file}"
455
- content_messages.update({ "type": "image_url", "image_url": { "url": img_url }})
456
-
457
- if self.file:
458
- if is_valid_url(self.file):
459
- content_messages.update({ "type": "image_url", "image_url": self.file })
460
-
461
- if self.audio:
462
- audio_bytes = Path(self.audio).read_bytes()
463
- encoded_data = base64.b64encode(audio_bytes).decode("utf-8")
464
- content_messages.update({ "type": "image_url", "image_url": "data:audio/mp3;base64,{}".format(encoded_data)})
465
-
466
- return content_messages
467
-
468
-
469
390
  def _structure_response_format(self, data_type: str = "object", model_provider: str = "gemini") -> Dict[str, Any] | None:
470
391
  """Structures `response_fields` or `pydantic_output` to a LLM response format."""
471
392
 
@@ -495,7 +416,6 @@ Ref. Output image: {output_formats_to_follow}
495
416
  "json_schema": { "name": "outcome", "schema": response_schema }
496
417
  }
497
418
 
498
-
499
419
  elif self.pydantic_output:
500
420
  response_format = StructuredOutput(response_format=self.pydantic_output, provider=model_provider)._format()
501
421
 
@@ -525,14 +445,15 @@ Ref. Output image: {output_formats_to_follow}
525
445
  output = json.loads(j)
526
446
 
527
447
  if isinstance(output, dict):
528
- return output
448
+ return output["json_schema"] if "json_schema" in output else output
529
449
  else:
530
450
  try:
531
451
  output = ast.literal_eval(j)
532
452
  except:
533
453
  output = ast.literal_eval(r)
534
454
 
535
- return output if isinstance(output, dict) else { "output": str(r) }
455
+
456
+ return output["json_schema"] if isinstance(output, dict) and "json_schema" in output else output if isinstance(output, dict) else { "output": str(r) }
536
457
 
537
458
 
538
459
  def _create_json_output(self, raw: str) -> Dict[str, Any]:
@@ -548,12 +469,13 @@ Ref. Output image: {output_formats_to_follow}
548
469
  try:
549
470
  output = json.loads(raw)
550
471
  if isinstance(output, dict):
551
- return output
472
+ return output["json_schema"] if "json_schema" in output else output
552
473
  else:
553
474
  output = self._sanitize_raw_output(raw=raw)
554
475
  return output
555
476
  except:
556
477
  output = self._sanitize_raw_output(raw=raw)
478
+ self._usage.record_errors(type=ErrorType.FORMAT)
557
479
  return output
558
480
 
559
481
 
@@ -673,23 +595,25 @@ Ref. Output image: {output_formats_to_follow}
673
595
 
674
596
 
675
597
  # task execution
676
- def execute(
677
- self, type: TaskExecutionType = None, agent: Optional["vhq.Agent"] = None, context: Optional[Any] = None
678
- ) -> TaskOutput | Future[TaskOutput]:
679
- """
680
- A main method to handle task execution. Build an agent when the agent is not given.
681
- """
598
+ def execute(self, type: TaskExecutionType = None, agent: "vhq.Agent" = None, context: Any = None) -> TaskOutput | Future[TaskOutput]:
599
+ """A main method to handle task execution."""
600
+
682
601
  type = type if type else self.execution_type if self.execution_type else TaskExecutionType.SYNC
602
+ agent = agent if agent else self._build_agent_from_task(task_description=self.description)
603
+ res = None
683
604
 
684
- if not agent:
685
- agent = self._build_agent_from_task(task_description=self.description)
605
+ if (self.should_test_run or agent.self_learn) and not self._pfg:
606
+ res = self._test_time_computation(agent=agent, context=context)
607
+ return res
686
608
 
687
609
  match type:
688
610
  case TaskExecutionType.SYNC:
689
- return self._execute_sync(agent=agent, context=context)
611
+ res = self._execute_sync(agent=agent, context=context)
690
612
 
691
613
  case TaskExecutionType.ASYNC:
692
- return self._execute_async(agent=agent, context=context)
614
+ res = self._execute_async(agent=agent, context=context)
615
+
616
+ return res
693
617
 
694
618
 
695
619
  def _execute_sync(self, agent, context: Optional[Any] = None) -> TaskOutput:
@@ -710,14 +634,14 @@ Ref. Output image: {output_formats_to_follow}
710
634
 
711
635
 
712
636
  def _execute_core(self, agent, context: Optional[Any]) -> TaskOutput:
713
- """
714
- A core method to execute a task.
715
- """
637
+ """A core method to execute a single task."""
638
+
639
+ start_dt = datetime.datetime.now()
716
640
  task_output: InstanceOf[TaskOutput] = None
717
641
  raw_output: str = None
718
642
  tool_output: str | list = None
719
643
  task_tools: List[List[InstanceOf[Tool]| InstanceOf[ToolSet] | Type[Tool]]] = []
720
- started_at, ended_at = datetime.datetime.now(), datetime.datetime.now()
644
+ user_prompt, dev_prompt = None, None
721
645
 
722
646
  if self.tools:
723
647
  for item in self.tools:
@@ -730,17 +654,14 @@ Ref. Output image: {output_formats_to_follow}
730
654
  self._delegations += 1
731
655
 
732
656
  if self.tool_res_as_final == True:
733
- started_at = datetime.datetime.now()
734
- tool_output = agent.execute_task(task=self, context=context, task_tools=task_tools)
657
+ user_prompt, dev_prompt, tool_output = agent.execute_task(task=self, context=context, task_tools=task_tools)
735
658
  raw_output = str(tool_output) if tool_output else ""
736
- ended_at = datetime.datetime.now()
659
+ if not raw_output:
660
+ self._usage.record_errors(type=ErrorType.TOOL)
737
661
  task_output = TaskOutput(task_id=self.id, tool_output=tool_output, raw=raw_output)
738
662
 
739
663
  else:
740
- started_at = datetime.datetime.now()
741
- raw_output = agent.execute_task(task=self, context=context, task_tools=task_tools)
742
- ended_at = datetime.datetime.now()
743
-
664
+ user_prompt, dev_prompt, raw_output = agent.execute_task(task=self, context=context, task_tools=task_tools)
744
665
  json_dict_output = self._create_json_output(raw=raw_output)
745
666
  if "outcome" in json_dict_output:
746
667
  json_dict_output = self._create_json_output(raw=str(json_dict_output["outcome"]))
@@ -754,8 +675,6 @@ Ref. Output image: {output_formats_to_follow}
754
675
  json_dict=json_dict_output,
755
676
  )
756
677
 
757
- task_output.latency = round((ended_at - started_at).total_seconds() * 1000, 3)
758
- task_output._tokens = self._tokens
759
678
  self.output = task_output
760
679
  self.processed_agents.add(agent.key)
761
680
 
@@ -767,6 +686,11 @@ Ref. Output image: {output_formats_to_follow}
767
686
  # )
768
687
  # self._save_file(content)
769
688
 
689
+ if self._pfg:
690
+ index = self._pfg.index
691
+ self._pfg.user_prompts.update({ index: user_prompt })
692
+ self._pfg.dev_prompts.update({ index: dev_prompt })
693
+
770
694
  if raw_output:
771
695
  if self.should_evaluate:
772
696
  task_output.evaluate(task=self)
@@ -784,9 +708,36 @@ Ref. Output image: {output_formats_to_follow}
784
708
  self.output = task_output
785
709
  self._store_logs()
786
710
 
711
+ end_dt = datetime.datetime.now()
712
+ self._usage.record_latency(start_dt=start_dt, end_dt=end_dt)
787
713
  return task_output
788
714
 
789
715
 
716
+ def _test_time_computation(self, agent, context: Optional[Any]) -> TaskOutput | None:
717
+ """Handles test-time computation."""
718
+
719
+ from versionhq.task_graph.model import ReformTriggerEvent
720
+ from versionhq._prompt.model import Prompt
721
+ from versionhq._prompt.auto_feedback import PromptFeedbackGraph
722
+
723
+ # self._usage = None
724
+ prompt = Prompt(task=self, agent=agent, context=context)
725
+ pfg = PromptFeedbackGraph(prompt=prompt, should_reform=self.human, reform_trigger_event=ReformTriggerEvent.USER_INPUT if self.human else None)
726
+ pfg = pfg.set_up_graph()
727
+ self._pfg = pfg
728
+
729
+ try:
730
+ if self._pfg and self.output is None:
731
+ res, all_outputs = self._pfg.activate()
732
+ if all_outputs: self._usage = self._pfg._usage
733
+ return res
734
+
735
+ except:
736
+ self._usage.record_errors(type=ErrorType.API)
737
+ Logger().log(level="error", message="Failed to execute the task.", color="red")
738
+ return None
739
+
740
+
790
741
  @property
791
742
  def key(self) -> str:
792
743
  output_format = "json" if self.response_fields else "pydantic" if self.pydantic_output is not None else "raw"
@@ -10,7 +10,7 @@ sys.modules['pydantic.main'].ModelMetaclass = ModelMetaclass
10
10
 
11
11
  from versionhq.agent.model import Agent
12
12
  from versionhq.task.model import ResponseField
13
- from versionhq.task_graph.model import TaskGraph, Task, DependencyType, Node
13
+ from versionhq.task_graph.model import TaskGraph, Task, DependencyType, Node, ReformTriggerEvent
14
14
  from versionhq._utils.logger import Logger
15
15
 
16
16
 
@@ -81,7 +81,8 @@ def workflow(final_output: Type[BaseModel], context: Any = None, human: bool = F
81
81
  nodes={node.identifier: node for node in nodes},
82
82
  concl_format=final_output,
83
83
  concl=None,
84
- should_reform=True,
84
+ should_reform=human,
85
+ reform_trigger_event=ReformTriggerEvent.USER_INPUT if human else None,
85
86
  )
86
87
 
87
88
  for res in task_items:
@@ -95,17 +96,6 @@ def workflow(final_output: Type[BaseModel], context: Any = None, human: bool = F
95
96
  task_graph.add_dependency(
96
97
  source=source.identifier, target=target.identifier, dependency_type=dependency_type)
97
98
 
98
- task_graph.visualize()
99
-
100
- if human:
101
- print('Proceed? Y/n:')
102
- x = input()
103
-
104
- if x.lower() == "y":
105
- print("ok. generating agent network")
106
-
107
- else:
108
- request = input("request?")
109
- print('ok. regenerating the graph based on your input: ', request)
99
+ # task_graph.visualize()
110
100
 
111
101
  return task_graph