telize 0.1.0__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.
- telize/__init__.py +5 -0
- telize/__main__.py +3 -0
- telize/__version__.py +1 -0
- telize/cli.py +115 -0
- telize/config/__init__.py +21 -0
- telize/config/loader.py +45 -0
- telize/config/models/__init__.py +27 -0
- telize/config/models/actions.py +125 -0
- telize/config/models/config.py +31 -0
- telize/config/models/flow.py +22 -0
- telize/config/models/spec.py +48 -0
- telize/console/__init__.py +13 -0
- telize/console/display.py +189 -0
- telize/console/observer.py +88 -0
- telize/exceptions.py +10 -0
- telize/providers/__init__.py +3 -0
- telize/providers/ollama.py +84 -0
- telize/py.typed +0 -0
- telize/runtime/__init__.py +4 -0
- telize/runtime/actions/__init__.py +9 -0
- telize/runtime/actions/base.py +29 -0
- telize/runtime/actions/input.py +43 -0
- telize/runtime/actions/llm.py +65 -0
- telize/runtime/actions/python.py +40 -0
- telize/runtime/actions/registry.py +40 -0
- telize/runtime/actions/shell.py +42 -0
- telize/runtime/actions/yaml.py +23 -0
- telize/runtime/context.py +27 -0
- telize/runtime/observer.py +42 -0
- telize/runtime/paths.py +9 -0
- telize/runtime/planning.py +20 -0
- telize/runtime/runner.py +158 -0
- telize/runtime/state.py +55 -0
- telize/runtime/workflow_input.py +88 -0
- telize/templating/__init__.py +3 -0
- telize/templating/context.py +9 -0
- telize/templating/load.py +40 -0
- telize/templating/renderer.py +45 -0
- telize-0.1.0.dist-info/METADATA +258 -0
- telize-0.1.0.dist-info/RECORD +43 -0
- telize-0.1.0.dist-info/WHEEL +4 -0
- telize-0.1.0.dist-info/entry_points.txt +2 -0
- telize-0.1.0.dist-info/licenses/LICENSE +201 -0
telize/__init__.py
ADDED
telize/__main__.py
ADDED
telize/__version__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
telize/cli.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from telize import __version__
|
|
10
|
+
from telize.config import load_spec
|
|
11
|
+
from telize.console import RichConsoleObserver, print_validation_ok
|
|
12
|
+
from telize.exceptions import ConfigError, ExecutionError, TelizeError
|
|
13
|
+
from telize.runtime import WorkflowRunner
|
|
14
|
+
from telize.runtime.workflow_input import resolve_cli_workflow_input
|
|
15
|
+
|
|
16
|
+
_ERR_CONSOLE = Console(stderr=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
20
|
+
parser = argparse.ArgumentParser(
|
|
21
|
+
prog="telize",
|
|
22
|
+
description="Run agent workflows defined in YAML.",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--version",
|
|
26
|
+
action="version",
|
|
27
|
+
version=f"%(prog)s {__version__}",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"-f",
|
|
31
|
+
"--file",
|
|
32
|
+
type=Path,
|
|
33
|
+
metavar="FILE",
|
|
34
|
+
help="Path to a workflow YAML file.",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--validate-only",
|
|
38
|
+
action="store_true",
|
|
39
|
+
help="Parse and validate the YAML without executing steps.",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--input",
|
|
43
|
+
action="append",
|
|
44
|
+
metavar="KEY=VALUE",
|
|
45
|
+
dest="input_pairs",
|
|
46
|
+
help=(
|
|
47
|
+
"Workflow input as key=value (repeatable). Merged with --input-file and --input-stdin."
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--input-file",
|
|
52
|
+
type=Path,
|
|
53
|
+
metavar="FILE",
|
|
54
|
+
help="YAML or JSON file with workflow input (mapping at root).",
|
|
55
|
+
)
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"--input-stdin",
|
|
58
|
+
action="store_true",
|
|
59
|
+
help="Read workflow input as YAML or JSON from stdin.",
|
|
60
|
+
)
|
|
61
|
+
return parser
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _print_error(message: str) -> None:
|
|
65
|
+
_ERR_CONSOLE.print(f"[bold red]error[/]: {message}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main(argv: list[str] | None = None) -> None:
|
|
69
|
+
parser = build_parser()
|
|
70
|
+
args = parser.parse_args(argv)
|
|
71
|
+
|
|
72
|
+
if args.file is None:
|
|
73
|
+
parser.print_help()
|
|
74
|
+
sys.exit(0 if argv is not None else 1)
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
spec = load_spec(args.file)
|
|
78
|
+
workflow_input = resolve_cli_workflow_input(
|
|
79
|
+
pairs=args.input_pairs,
|
|
80
|
+
input_file=args.input_file,
|
|
81
|
+
input_stdin=args.input_stdin,
|
|
82
|
+
)
|
|
83
|
+
except ConfigError as exc:
|
|
84
|
+
_print_error(str(exc))
|
|
85
|
+
sys.exit(1)
|
|
86
|
+
|
|
87
|
+
entrypoint = spec.config.entrypoint
|
|
88
|
+
flow = spec.flows[entrypoint]
|
|
89
|
+
|
|
90
|
+
if args.validate_only:
|
|
91
|
+
print_validation_ok(
|
|
92
|
+
workflow_file=args.file.resolve(),
|
|
93
|
+
entrypoint=entrypoint,
|
|
94
|
+
step_count=len(flow.steps),
|
|
95
|
+
)
|
|
96
|
+
sys.exit(0)
|
|
97
|
+
|
|
98
|
+
observer = RichConsoleObserver(spec, args.file.resolve())
|
|
99
|
+
try:
|
|
100
|
+
WorkflowRunner(
|
|
101
|
+
spec,
|
|
102
|
+
args.file.resolve(),
|
|
103
|
+
observer=observer,
|
|
104
|
+
workflow_input=workflow_input,
|
|
105
|
+
).run()
|
|
106
|
+
except (ConfigError, ExecutionError) as exc:
|
|
107
|
+
_print_error(str(exc))
|
|
108
|
+
sys.exit(1)
|
|
109
|
+
except TelizeError as exc:
|
|
110
|
+
_print_error(str(exc))
|
|
111
|
+
sys.exit(1)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
main()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from telize.config.loader import load_spec
|
|
2
|
+
from telize.config.models import (
|
|
3
|
+
Flow,
|
|
4
|
+
GlobalConfig,
|
|
5
|
+
InputStep,
|
|
6
|
+
LlmStep,
|
|
7
|
+
ShellStep,
|
|
8
|
+
Step,
|
|
9
|
+
WorkflowSpec,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Flow",
|
|
14
|
+
"GlobalConfig",
|
|
15
|
+
"InputStep",
|
|
16
|
+
"LlmStep",
|
|
17
|
+
"ShellStep",
|
|
18
|
+
"Step",
|
|
19
|
+
"WorkflowSpec",
|
|
20
|
+
"load_spec",
|
|
21
|
+
]
|
telize/config/loader.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
from pydantic import ValidationError
|
|
8
|
+
|
|
9
|
+
from telize.config.models import WorkflowSpec
|
|
10
|
+
from telize.exceptions import ConfigError, ExecutionError
|
|
11
|
+
from telize.templating.load import render_env_templates
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _apply_load_time_templates(raw: dict[str, Any]) -> dict[str, Any]:
|
|
15
|
+
"""Expand `{{ env.* }}` in YAML before validation; leave runtime templates intact."""
|
|
16
|
+
try:
|
|
17
|
+
rendered = render_env_templates(raw)
|
|
18
|
+
except ExecutionError as exc:
|
|
19
|
+
raise ConfigError(str(exc)) from exc
|
|
20
|
+
if not isinstance(rendered, dict):
|
|
21
|
+
raise ConfigError("Expected a YAML mapping at the root after template rendering")
|
|
22
|
+
return rendered
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_spec(path: Path) -> WorkflowSpec:
|
|
26
|
+
"""Load and validate a Telize workflow YAML file."""
|
|
27
|
+
if not path.is_file():
|
|
28
|
+
raise ConfigError(f"File not found: {path}")
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
raw: Any = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
32
|
+
except yaml.YAMLError as exc:
|
|
33
|
+
raise ConfigError(f"Invalid YAML in {path}: {exc}") from exc
|
|
34
|
+
|
|
35
|
+
if raw is None:
|
|
36
|
+
raise ConfigError(f"Empty YAML file: {path}")
|
|
37
|
+
if not isinstance(raw, dict):
|
|
38
|
+
raise ConfigError(f"Expected a YAML mapping at the root of {path}")
|
|
39
|
+
|
|
40
|
+
raw = _apply_load_time_templates(raw)
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
return WorkflowSpec.model_validate(raw)
|
|
44
|
+
except ValidationError as exc:
|
|
45
|
+
raise ConfigError(f"Invalid workflow spec in {path}:\n{exc}") from exc
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from telize.config.models.actions import (
|
|
2
|
+
FlowRefStep,
|
|
3
|
+
InputStep,
|
|
4
|
+
LlmStep,
|
|
5
|
+
LoopConfig,
|
|
6
|
+
PythonStep,
|
|
7
|
+
ShellStep,
|
|
8
|
+
Step,
|
|
9
|
+
YamlStep,
|
|
10
|
+
)
|
|
11
|
+
from telize.config.models.config import GlobalConfig
|
|
12
|
+
from telize.config.models.flow import Flow
|
|
13
|
+
from telize.config.models.spec import WorkflowSpec
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Flow",
|
|
17
|
+
"FlowRefStep",
|
|
18
|
+
"GlobalConfig",
|
|
19
|
+
"InputStep",
|
|
20
|
+
"LlmStep",
|
|
21
|
+
"LoopConfig",
|
|
22
|
+
"PythonStep",
|
|
23
|
+
"ShellStep",
|
|
24
|
+
"Step",
|
|
25
|
+
"WorkflowSpec",
|
|
26
|
+
"YamlStep",
|
|
27
|
+
]
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Any, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LoopConfig(BaseModel):
|
|
9
|
+
"""Iterate an LLM step over items produced from a prior step's output."""
|
|
10
|
+
|
|
11
|
+
model_config = ConfigDict(extra="forbid")
|
|
12
|
+
|
|
13
|
+
items: str = Field(
|
|
14
|
+
description="Jinja template resolving to a delimited list (e.g. `{{ steps.foo.output }}`).",
|
|
15
|
+
)
|
|
16
|
+
split_by: str = Field(
|
|
17
|
+
default=",",
|
|
18
|
+
description="Delimiter used to split `items` into separate loop iterations.",
|
|
19
|
+
)
|
|
20
|
+
execution: Literal["sequential", "parallel"] = Field(
|
|
21
|
+
default="sequential",
|
|
22
|
+
description="How loop iterations are scheduled.",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DirectoryInput(BaseModel):
|
|
27
|
+
"""Read and concatenate files from a directory."""
|
|
28
|
+
|
|
29
|
+
model_config = ConfigDict(extra="forbid")
|
|
30
|
+
|
|
31
|
+
path: str
|
|
32
|
+
include: str = Field(default="*", description="Glob pattern for files to include.")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _StepBase(BaseModel):
|
|
36
|
+
"""Fields shared by every step in a flow."""
|
|
37
|
+
|
|
38
|
+
model_config = ConfigDict(extra="forbid")
|
|
39
|
+
|
|
40
|
+
name: str = Field(
|
|
41
|
+
min_length=1,
|
|
42
|
+
description="Unique step id within the flow; referenced as `steps.<name>.output`.",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class InputStep(_StepBase):
|
|
47
|
+
"""Load content from a file or directory."""
|
|
48
|
+
|
|
49
|
+
uses: Literal["input"] = "input"
|
|
50
|
+
file: str | None = Field(default=None, description="Path to a single file to read.")
|
|
51
|
+
directory: DirectoryInput | None = None
|
|
52
|
+
|
|
53
|
+
@model_validator(mode="after")
|
|
54
|
+
def validate_source(self) -> InputStep:
|
|
55
|
+
has_file = self.file is not None
|
|
56
|
+
has_dir = self.directory is not None
|
|
57
|
+
if has_file == has_dir:
|
|
58
|
+
msg = "input step requires exactly one of 'file' or 'directory'"
|
|
59
|
+
raise ValueError(msg)
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class LlmStep(_StepBase):
|
|
64
|
+
"""Call an LLM with a Jinja-templated prompt."""
|
|
65
|
+
|
|
66
|
+
uses: Literal["llm"] = "llm"
|
|
67
|
+
prompt: str
|
|
68
|
+
output_to: str | None = Field(
|
|
69
|
+
default=None,
|
|
70
|
+
description="Optional path to write raw output after the step completes.",
|
|
71
|
+
)
|
|
72
|
+
loop: LoopConfig | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ShellStep(_StepBase):
|
|
76
|
+
"""Execute a shell script or command block."""
|
|
77
|
+
|
|
78
|
+
uses: Literal["shell"] = "shell"
|
|
79
|
+
run: str = Field(description="Shell commands to execute.")
|
|
80
|
+
envs: dict[str, str] = Field(
|
|
81
|
+
default_factory=dict,
|
|
82
|
+
description="Extra environment variables (values may be Jinja templates).",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class PythonStep(_StepBase):
|
|
87
|
+
"""Invoke a Python callable by import path."""
|
|
88
|
+
|
|
89
|
+
uses: Literal["python"] = "python"
|
|
90
|
+
call: str = Field(
|
|
91
|
+
description="Dotted import path to a callable, e.g. `package.module.function`.",
|
|
92
|
+
)
|
|
93
|
+
args: dict[str, Any] = Field(
|
|
94
|
+
default_factory=dict,
|
|
95
|
+
description="Keyword arguments passed to the callable (values may be templates).",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class FlowRefStep(_StepBase):
|
|
100
|
+
"""Run another flow defined in the same workflow file."""
|
|
101
|
+
|
|
102
|
+
uses: Literal["flow"] = "flow"
|
|
103
|
+
run: str = Field(description="Name of the flow to execute.")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class YamlStep(_StepBase):
|
|
107
|
+
"""Run a workflow defined in an external YAML file."""
|
|
108
|
+
|
|
109
|
+
uses: Literal["yaml"] = "yaml"
|
|
110
|
+
file: str = Field(
|
|
111
|
+
description="Path to a Telize workflow YAML file relative to the workflow file.",
|
|
112
|
+
)
|
|
113
|
+
input: dict[str, Any] = Field(
|
|
114
|
+
default_factory=dict,
|
|
115
|
+
description=(
|
|
116
|
+
"Workflow input for the child file, rendered as Jinja in the parent then "
|
|
117
|
+
"passed as `{{ input.<key> }}` in the child."
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
Step = Annotated[
|
|
123
|
+
InputStep | LlmStep | ShellStep | PythonStep | FlowRefStep | YamlStep,
|
|
124
|
+
Field(discriminator="uses"),
|
|
125
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class GlobalConfig(BaseModel):
|
|
7
|
+
"""Top-level defaults and entrypoint for a workflow file."""
|
|
8
|
+
|
|
9
|
+
model_config = ConfigDict(extra="forbid")
|
|
10
|
+
|
|
11
|
+
model: str | None = Field(
|
|
12
|
+
default=None,
|
|
13
|
+
description="Default LLM model for steps that do not override it.",
|
|
14
|
+
)
|
|
15
|
+
temperature: float | None = Field(
|
|
16
|
+
default=None,
|
|
17
|
+
ge=0.0,
|
|
18
|
+
le=2.0,
|
|
19
|
+
description="Default sampling temperature.",
|
|
20
|
+
)
|
|
21
|
+
api_base_url: str = Field(
|
|
22
|
+
default="http://localhost:11434",
|
|
23
|
+
description="Ollama API base URL (default local instance).",
|
|
24
|
+
)
|
|
25
|
+
system_prompt: str | None = Field(
|
|
26
|
+
default=None,
|
|
27
|
+
description="System message for every llm step (Jinja-templated at runtime).",
|
|
28
|
+
)
|
|
29
|
+
entrypoint: str = Field(
|
|
30
|
+
description="Name of the flow in `flows` that runs when the file is executed.",
|
|
31
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
4
|
+
|
|
5
|
+
from telize.config.models.actions import Step
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Flow(BaseModel):
|
|
9
|
+
"""A named workflow: ordered steps executed sequentially."""
|
|
10
|
+
|
|
11
|
+
model_config = ConfigDict(extra="forbid")
|
|
12
|
+
|
|
13
|
+
steps: list[Step] = Field(min_length=1)
|
|
14
|
+
|
|
15
|
+
@model_validator(mode="after")
|
|
16
|
+
def validate_unique_step_names(self) -> Flow:
|
|
17
|
+
names = [step.name for step in self.steps]
|
|
18
|
+
if len(names) != len(set(names)):
|
|
19
|
+
duplicates = sorted({n for n in names if names.count(n) > 1})
|
|
20
|
+
msg = f"duplicate step name(s) in flow: {', '.join(duplicates)}"
|
|
21
|
+
raise ValueError(msg)
|
|
22
|
+
return self
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, model_validator
|
|
4
|
+
|
|
5
|
+
from telize.config.models.actions import FlowRefStep, Step
|
|
6
|
+
from telize.config.models.config import GlobalConfig
|
|
7
|
+
from telize.config.models.flow import Flow
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class WorkflowSpec(BaseModel):
|
|
11
|
+
"""Root document loaded from a Telize workflow YAML file."""
|
|
12
|
+
|
|
13
|
+
model_config = ConfigDict(extra="forbid")
|
|
14
|
+
|
|
15
|
+
config: GlobalConfig
|
|
16
|
+
flows: dict[str, Flow]
|
|
17
|
+
|
|
18
|
+
@model_validator(mode="after")
|
|
19
|
+
def validate_entrypoint(self) -> WorkflowSpec:
|
|
20
|
+
if self.config.entrypoint not in self.flows:
|
|
21
|
+
known = ", ".join(sorted(self.flows))
|
|
22
|
+
msg = (
|
|
23
|
+
f"config.entrypoint '{self.config.entrypoint}' not found in flows. "
|
|
24
|
+
f"Known flows: {known or '(none)'}"
|
|
25
|
+
)
|
|
26
|
+
raise ValueError(msg)
|
|
27
|
+
return self
|
|
28
|
+
|
|
29
|
+
@model_validator(mode="after")
|
|
30
|
+
def validate_flow_references(self) -> WorkflowSpec:
|
|
31
|
+
for flow_name, flow in self.flows.items():
|
|
32
|
+
for step in flow.steps:
|
|
33
|
+
if isinstance(step, FlowRefStep) and step.run not in self.flows:
|
|
34
|
+
msg = (
|
|
35
|
+
f"flow '{flow_name}' step '{step.name}' references unknown flow "
|
|
36
|
+
f"'{step.run}'"
|
|
37
|
+
)
|
|
38
|
+
raise ValueError(msg)
|
|
39
|
+
return self
|
|
40
|
+
|
|
41
|
+
def get_step(self, flow_name: str, step_name: str) -> Step | None:
|
|
42
|
+
flow = self.flows.get(flow_name)
|
|
43
|
+
if flow is None:
|
|
44
|
+
return None
|
|
45
|
+
for step in flow.steps:
|
|
46
|
+
if step.name == step_name:
|
|
47
|
+
return step
|
|
48
|
+
return None
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from telize.console.display import (
|
|
2
|
+
get_console,
|
|
3
|
+
print_validation_ok,
|
|
4
|
+
print_workflow_results,
|
|
5
|
+
)
|
|
6
|
+
from telize.console.observer import RichConsoleObserver
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"RichConsoleObserver",
|
|
10
|
+
"get_console",
|
|
11
|
+
"print_validation_ok",
|
|
12
|
+
"print_workflow_results",
|
|
13
|
+
]
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.markdown import Markdown
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.rule import Rule
|
|
9
|
+
from rich.syntax import Syntax
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from telize.config.models import WorkflowSpec
|
|
14
|
+
from telize.runtime.state import ExecutionState, StepResult
|
|
15
|
+
|
|
16
|
+
_CONSOLE: Console | None = None
|
|
17
|
+
|
|
18
|
+
ACTION_LABELS: dict[str, tuple[str, str]] = {
|
|
19
|
+
"llm": ("LLM", "bold magenta"),
|
|
20
|
+
"shell": ("SHELL", "bold yellow"),
|
|
21
|
+
"python": ("PYTHON", "bold cyan"),
|
|
22
|
+
"input": ("INPUT", "bold green"),
|
|
23
|
+
"flow": ("FLOW", "bold red"),
|
|
24
|
+
"yaml": ("YAML", "bold blue"),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_console() -> Console:
|
|
29
|
+
global _CONSOLE
|
|
30
|
+
if _CONSOLE is None:
|
|
31
|
+
_CONSOLE = Console(highlight=False)
|
|
32
|
+
return _CONSOLE
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def print_validation_ok(
|
|
36
|
+
*,
|
|
37
|
+
workflow_file: Path,
|
|
38
|
+
entrypoint: str,
|
|
39
|
+
step_count: int,
|
|
40
|
+
) -> None:
|
|
41
|
+
console = get_console()
|
|
42
|
+
table = Table.grid(padding=(0, 2))
|
|
43
|
+
table.add_column(style="dim")
|
|
44
|
+
table.add_column()
|
|
45
|
+
table.add_row("File", str(workflow_file))
|
|
46
|
+
table.add_row("Entrypoint", f"[cyan]{entrypoint}[/]")
|
|
47
|
+
table.add_row("Steps", str(step_count))
|
|
48
|
+
console.print(
|
|
49
|
+
Panel(
|
|
50
|
+
table,
|
|
51
|
+
title="[bold green]✓ Valid workflow[/]",
|
|
52
|
+
border_style="green",
|
|
53
|
+
padding=(1, 2),
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def print_workflow_header(
|
|
59
|
+
spec_path: Path,
|
|
60
|
+
entrypoint: str,
|
|
61
|
+
model: str | None,
|
|
62
|
+
api_base_url: str,
|
|
63
|
+
*,
|
|
64
|
+
estimated_steps: int,
|
|
65
|
+
) -> None:
|
|
66
|
+
console = get_console()
|
|
67
|
+
table = Table.grid(padding=(0, 2))
|
|
68
|
+
table.add_column(style="dim", no_wrap=True)
|
|
69
|
+
table.add_column()
|
|
70
|
+
table.add_row("Workflow", f"[bold]{spec_path.name}[/]")
|
|
71
|
+
table.add_row("Entrypoint", f"[cyan]{entrypoint}[/]")
|
|
72
|
+
if model:
|
|
73
|
+
table.add_row("Model", model)
|
|
74
|
+
table.add_row("Ollama", api_base_url)
|
|
75
|
+
table.add_row("Steps", str(estimated_steps))
|
|
76
|
+
console.print()
|
|
77
|
+
console.print(
|
|
78
|
+
Panel(
|
|
79
|
+
table,
|
|
80
|
+
title="[bold #a371f7]◈ Telize[/] [dim]running workflow[/]",
|
|
81
|
+
border_style="#a371f7",
|
|
82
|
+
padding=(1, 2),
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
console.print()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def print_step_panel(result: StepResult, *, index: int) -> None:
|
|
89
|
+
get_console().print(_build_step_panel(result, index=index))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def print_workflow_results(
|
|
93
|
+
spec: WorkflowSpec,
|
|
94
|
+
spec_path: Path,
|
|
95
|
+
state: ExecutionState,
|
|
96
|
+
*,
|
|
97
|
+
entrypoint: str,
|
|
98
|
+
elapsed: float,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Print all step results at once (used in tests and replay tooling)."""
|
|
101
|
+
console = get_console()
|
|
102
|
+
cfg = spec.config
|
|
103
|
+
|
|
104
|
+
print_workflow_header(
|
|
105
|
+
spec_path,
|
|
106
|
+
entrypoint,
|
|
107
|
+
cfg.model,
|
|
108
|
+
cfg.api_base_url,
|
|
109
|
+
estimated_steps=len(state.steps),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
for index, result in enumerate(state.steps.values(), start=1):
|
|
113
|
+
print_step_panel(result, index=index)
|
|
114
|
+
console.print()
|
|
115
|
+
|
|
116
|
+
console.print(
|
|
117
|
+
Rule(
|
|
118
|
+
f"[green]{len(state.steps)} step(s)[/] in [cyan]{elapsed:.1f}s[/]",
|
|
119
|
+
style="dim",
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _build_step_panel(result: StepResult, *, index: int) -> Panel:
|
|
125
|
+
uses = result.uses or "step"
|
|
126
|
+
label, style = ACTION_LABELS.get(uses, (uses.upper(), "bold white"))
|
|
127
|
+
title = Text()
|
|
128
|
+
title.append(f"{index:02d} ", style="dim")
|
|
129
|
+
title.append(f"{result.name} ", style="bold")
|
|
130
|
+
title.append(label, style=style)
|
|
131
|
+
if result.flow_name:
|
|
132
|
+
title.append(f" · {result.flow_name}", style="dim")
|
|
133
|
+
|
|
134
|
+
subtitle_parts: list[str] = []
|
|
135
|
+
if result.output_path:
|
|
136
|
+
subtitle_parts.append(f"→ {result.output_path}")
|
|
137
|
+
subtitle = " ".join(subtitle_parts)
|
|
138
|
+
|
|
139
|
+
if result.output:
|
|
140
|
+
body = _render_step_body(result.output, uses)
|
|
141
|
+
else:
|
|
142
|
+
body = Text("(no output)", style="dim")
|
|
143
|
+
|
|
144
|
+
return Panel(
|
|
145
|
+
body,
|
|
146
|
+
title=title,
|
|
147
|
+
subtitle=subtitle if subtitle else None,
|
|
148
|
+
border_style=_panel_border(uses),
|
|
149
|
+
padding=(1, 2),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _panel_border(uses: str) -> str:
|
|
154
|
+
return {
|
|
155
|
+
"llm": "magenta",
|
|
156
|
+
"shell": "yellow",
|
|
157
|
+
"python": "cyan",
|
|
158
|
+
"input": "green",
|
|
159
|
+
"flow": "red",
|
|
160
|
+
"yaml": "blue",
|
|
161
|
+
}.get(uses, "white")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _render_step_body(output: str, uses: str) -> Markdown | Syntax | Text:
|
|
165
|
+
text = output.rstrip()
|
|
166
|
+
if not text:
|
|
167
|
+
return Text("")
|
|
168
|
+
|
|
169
|
+
if uses == "shell":
|
|
170
|
+
return Text(text)
|
|
171
|
+
if uses == "yaml":
|
|
172
|
+
return Syntax(text, "yaml", theme="monokai", word_wrap=True)
|
|
173
|
+
if uses == "python":
|
|
174
|
+
return Syntax(text, "python", theme="monokai", word_wrap=True)
|
|
175
|
+
if uses == "llm" or (uses == "input" and _looks_like_markdown(text)):
|
|
176
|
+
return _as_markdown(text)
|
|
177
|
+
return Text(text)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _looks_like_markdown(text: str) -> bool:
|
|
181
|
+
markers = ("# ", "## ", "- ", "* ", "```", "**", "\n- ", "\n* ")
|
|
182
|
+
return any(marker in text for marker in markers)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _as_markdown(content: str) -> Markdown | Text:
|
|
186
|
+
try:
|
|
187
|
+
return Markdown(content)
|
|
188
|
+
except Exception:
|
|
189
|
+
return Text(content)
|